neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
android supp
c5a2f92 · gmorell · 2026-05-24 22:07
Files changed
modified
app.yml
+6
−1
| 187 | 187 | metadata: |
| 188 | 188 | name: waxx-headless |
| 189 | 189 | namespace: waxx |
| 190 | + # Labels here propagate to the Endpoints object. libcluster's K8s | |
| 191 | + # strategy filters endpoints by labelSelector (not by service name), | |
| 192 | + # so without `name: waxx` here the Endpoints have no matching label | |
| 193 | + # and discovery silently returns zero peers. | |
| 194 | + labels: | |
| 195 | + name: waxx | |
| 190 | 196 | spec: |
| 191 | 197 | clusterIP: None |
| 192 | 198 | publishNotReadyAddresses: true |
| 194 | 200 | - { name: http, port: 4000, targetPort: 4000, protocol: TCP } |
| 195 | 201 | - { name: epmd, port: 4369, targetPort: 4369, protocol: TCP } |
| 196 | 202 | - { name: dist, port: 9100, targetPort: 9100, protocol: TCP } |
| 197 | − name: http | |
| 198 | 203 | selector: |
| 199 | 204 | name: waxx |
| 200 | 205 | --- |
modified
lib/waxx/accounts.ex
+78
−0
| 313 | 313 | :ok |
| 314 | 314 | end |
| 315 | 315 | |
| 316 | + ## API tokens (native clients) --------------------------------------- | |
| 317 | + | |
| 318 | + @doc """ | |
| 319 | + Mints a new API token for a user. Returns the encoded token string. | |
| 320 | + Hand it to the client exactly once — the DB only stores the hash. | |
| 321 | + | |
| 322 | + Accepts an optional `attrs` map with a `:label` (or `"label"`) used to | |
| 323 | + distinguish tokens in the device list. | |
| 324 | + """ | |
| 325 | + def create_api_token(%User{} = user, attrs \\ %{}) do | |
| 326 | + {encoded_token, user_token} = UserToken.build_api_token(user, attrs) | |
| 327 | + Repo.insert!(user_token) | |
| 328 | + encoded_token | |
| 329 | + end | |
| 330 | + | |
| 331 | + @doc """ | |
| 332 | + Looks up a user by API token. Returns `{user, token_id}` or `nil` if the | |
| 333 | + token is missing, malformed, expired, or revoked. On success, | |
| 334 | + opportunistically refreshes `authenticated_at` if it's older than the | |
| 335 | + refresh threshold so an active client never gets logged out. | |
| 336 | + | |
| 337 | + The token_id is returned so the auth plug can assign it on the conn, | |
| 338 | + which `DELETE /api/v1/sessions/current` uses to revoke the current | |
| 339 | + device specifically. | |
| 340 | + """ | |
| 341 | + def fetch_user_by_api_token(token) when is_binary(token) do | |
| 342 | + with {:ok, query} <- UserToken.verify_api_token_query(token), | |
| 343 | + {user, token_struct} <- Repo.one(query) do | |
| 344 | + maybe_touch_api_token(token_struct) | |
| 345 | + {user, token_struct.id} | |
| 346 | + else | |
| 347 | + _ -> nil | |
| 348 | + end | |
| 349 | + end | |
| 350 | + | |
| 351 | + def fetch_user_by_api_token(_), do: nil | |
| 352 | + | |
| 353 | + defp maybe_touch_api_token(%UserToken{} = token_struct) do | |
| 354 | + if UserToken.api_token_needs_refresh?(token_struct) do | |
| 355 | + now = DateTime.utc_now(:second) | |
| 356 | + | |
| 357 | + Repo.update_all( | |
| 358 | + from(t in UserToken, where: t.id == ^token_struct.id), | |
| 359 | + set: [authenticated_at: now] | |
| 360 | + ) | |
| 361 | + end | |
| 362 | + | |
| 363 | + :ok | |
| 364 | + end | |
| 365 | + | |
| 366 | + @doc "Lists a user's active API tokens, newest first." | |
| 367 | + def list_api_tokens(%User{id: id}) do | |
| 368 | + from(t in UserToken, | |
| 369 | + where: t.user_id == ^id and t.context == "api", | |
| 370 | + order_by: [desc: t.authenticated_at], | |
| 371 | + select: %{ | |
| 372 | + id: t.id, | |
| 373 | + label: t.label, | |
| 374 | + sent_to: t.sent_to, | |
| 375 | + authenticated_at: t.authenticated_at, | |
| 376 | + inserted_at: t.inserted_at | |
| 377 | + } | |
| 378 | + ) | |
| 379 | + |> Repo.all() | |
| 380 | + end | |
| 381 | + | |
| 382 | + @doc "Revokes a single API token by id, scoped to its owner." | |
| 383 | + def delete_api_token(%User{id: user_id}, token_id) do | |
| 384 | + {count, _} = | |
| 385 | + Repo.delete_all( | |
| 386 | + from(t in UserToken, | |
| 387 | + where: t.id == ^token_id and t.user_id == ^user_id and t.context == "api" | |
| 388 | + ) | |
| 389 | + ) | |
| 390 | + | |
| 391 | + if count == 1, do: :ok, else: {:error, :not_found} | |
| 392 | + end | |
| 393 | + | |
| 316 | 394 | ## Token helper |
| 317 | 395 | |
| 318 | 396 | defp update_user_and_delete_all_tokens(changeset) do |
modified
lib/waxx/accounts/user_token.ex
+80
−0
| 12 | 12 | @change_email_validity_in_days 7 |
| 13 | 13 | @session_validity_in_days 14 |
| 14 | 14 | |
| 15 | + # API tokens (context "api") issued to native clients. Expiry is measured | |
| 16 | + # against authenticated_at, which is bumped by the auth plug at most once | |
| 17 | + # per refresh window so an active client never gets logged out. | |
| 18 | + @api_token_validity_in_days 90 | |
| 19 | + @api_token_refresh_after_seconds 24 * 60 * 60 | |
| 20 | + | |
| 15 | 21 | schema "users_tokens" do |
| 16 | 22 | field :token, :binary |
| 17 | 23 | field :context, :string |
| 18 | 24 | field :sent_to, :string |
| 25 | + field :label, :string | |
| 19 | 26 | field :authenticated_at, :utc_datetime |
| 20 | 27 | belongs_to :user, Waxx.Accounts.User |
| 21 | 28 |
| 109 | 116 | defp by_token_and_context_query(token, context) do |
| 110 | 117 | from UserToken, where: [token: ^token, context: ^context] |
| 111 | 118 | end |
| 119 | + | |
| 120 | + ## API tokens --------------------------------------------------------- | |
| 121 | + | |
| 122 | + @doc """ | |
| 123 | + Builds an API token for native clients. Returns `{encoded_token, struct}` | |
| 124 | + — the caller is expected to insert the struct and hand `encoded_token` | |
| 125 | + back to the client over a secure channel exactly once. | |
| 126 | + | |
| 127 | + Accepts an optional `label` (e.g. "Pixel 7, kitchen") to distinguish | |
| 128 | + tokens in the device list. | |
| 129 | + """ | |
| 130 | + def build_api_token(user, attrs \\ %{}) do | |
| 131 | + token = :crypto.strong_rand_bytes(@rand_size) | |
| 132 | + hashed_token = :crypto.hash(@hash_algorithm, token) | |
| 133 | + now = DateTime.utc_now(:second) | |
| 134 | + label = attrs |> normalise_label() |> truncate_label() | |
| 135 | + | |
| 136 | + {Base.url_encode64(token, padding: false), | |
| 137 | + %UserToken{ | |
| 138 | + token: hashed_token, | |
| 139 | + context: "api", | |
| 140 | + sent_to: user.email, | |
| 141 | + label: label, | |
| 142 | + authenticated_at: now, | |
| 143 | + user_id: user.id | |
| 144 | + }} | |
| 145 | + end | |
| 146 | + | |
| 147 | + defp normalise_label(attrs) do | |
| 148 | + case Map.get(attrs, :label) || Map.get(attrs, "label") do | |
| 149 | + nil -> nil | |
| 150 | + str when is_binary(str) -> str |> String.trim() |> nil_if_empty() | |
| 151 | + _ -> nil | |
| 152 | + end | |
| 153 | + end | |
| 154 | + | |
| 155 | + defp nil_if_empty(""), do: nil | |
| 156 | + defp nil_if_empty(s), do: s | |
| 157 | + | |
| 158 | + defp truncate_label(nil), do: nil | |
| 159 | + defp truncate_label(s), do: String.slice(s, 0, 80) | |
| 160 | + | |
| 161 | + @doc """ | |
| 162 | + Looks up an API token. Returns `{:ok, query}` selecting `{user, token}` | |
| 163 | + if the encoded token decodes; `:error` otherwise. The query enforces | |
| 164 | + the @api_token_validity_in_days window against `authenticated_at`. | |
| 165 | + """ | |
| 166 | + def verify_api_token_query(token) when is_binary(token) do | |
| 167 | + case Base.url_decode64(token, padding: false) do | |
| 168 | + {:ok, decoded_token} -> | |
| 169 | + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | |
| 170 | + | |
| 171 | + query = | |
| 172 | + from token in by_token_and_context_query(hashed_token, "api"), | |
| 173 | + join: user in assoc(token, :user), | |
| 174 | + where: token.authenticated_at > ago(@api_token_validity_in_days, "day"), | |
| 175 | + select: {user, token} | |
| 176 | + | |
| 177 | + {:ok, query} | |
| 178 | + | |
| 179 | + :error -> | |
| 180 | + :error | |
| 181 | + end | |
| 182 | + end | |
| 183 | + | |
| 184 | + @doc """ | |
| 185 | + Returns true if the API token's `authenticated_at` is stale enough to | |
| 186 | + warrant a refresh write. Lets the auth plug skip the DB write on the | |
| 187 | + common path where the token was used recently. | |
| 188 | + """ | |
| 189 | + def api_token_needs_refresh?(%UserToken{context: "api", authenticated_at: ts}) do | |
| 190 | + DateTime.diff(DateTime.utc_now(:second), ts, :second) >= @api_token_refresh_after_seconds | |
| 191 | + end | |
| 112 | 192 | end |
modified
lib/waxx/kanban.ex
+3
−4
| 866 | 866 | |
| 867 | 867 | case result do |
| 868 | 868 | {:ok, _} -> |
| 869 | − log(card.board_id, opts[:actor], "card_deleted", | |
| 870 | − card_id: card.id, | |
| 871 | − meta: %{title: title} | |
| 872 | − ) | |
| 869 | + # No card_id — the row is gone and the FK would reject the insert. | |
| 870 | + # `meta.title` keeps the entry readable in the history view. | |
| 871 | + log(card.board_id, opts[:actor], "card_deleted", meta: %{title: title}) | |
| 873 | 872 | |
| 874 | 873 | _ -> |
| 875 | 874 | :ok |
modified
lib/waxx_web.ex
+1
−1
| 17 | 17 | those modules here. |
| 18 | 18 | """ |
| 19 | 19 | |
| 20 | − def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) | |
| 20 | + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt .well-known) | |
| 21 | 21 | |
| 22 | 22 | def router do |
| 23 | 23 | quote do |
added
lib/waxx_web/api/auth.ex
+46
−0
Click to load diff…
added
lib/waxx_web/api/error_json.ex
+30
−0
Click to load diff…
added
lib/waxx_web/api/fallback_controller.ex
+80
−0
Click to load diff…
added
lib/waxx_web/api/v1/board_controller.ex
+97
−0
Click to load diff…
added
lib/waxx_web/api/v1/board_json.ex
+206
−0
Click to load diff…
added
lib/waxx_web/api/v1/card_controller.ex
+244
−0
Click to load diff…
added
lib/waxx_web/api/v1/note_controller.ex
+83
−0
Click to load diff…
added
lib/waxx_web/api/v1/session_controller.ex
+58
−0
Click to load diff…
added
lib/waxx_web/api/v1/user_controller.ex
+27
−0
Click to load diff…
added
lib/waxx_web/channels/board_channel.ex
+53
−0
Click to load diff…
added
lib/waxx_web/channels/user_socket.ex
+35
−0
Click to load diff…
added
lib/waxx_web/controllers/magic_link_controller.ex
+20
−0
Click to load diff…
modified
lib/waxx_web/endpoint.ex
+10
−2
Click to load diff…
modified
lib/waxx_web/live/user_live/settings.ex
+140
−0
Click to load diff…
added
lib/waxx_web/public_url.ex
+125
−0
Click to load diff…
added
lib/waxx_web/qr.ex
+24
−0
Click to load diff…
modified
lib/waxx_web/router.ex
+48
−5
Click to load diff…
modified
mix.exs
+2
−1
Click to load diff…
modified
mix.lock
+1
−0
Click to load diff…
added
priv/repo/migrations/20260524120000_add_label_to_users_tokens.exs
+9
−0
Click to load diff…
added
priv/static/.well-known/assetlinks.json
+13
−0
Click to load diff…
added
test/support/channel_case.ex
+22
−0
Click to load diff…
modified
test/support/conn_case.ex
+12
−0
Click to load diff…
modified
test/support/fixtures/accounts_fixtures.ex
+25
−1
Click to load diff…
added
test/support/fixtures/kanban_fixtures.ex
+74
−0
Click to load diff…
added
test/waxx/accounts_api_token_test.exs
+91
−0
Click to load diff…
added
test/waxx_web/api/auth_test.exs
+56
−0
Click to load diff…
added
test/waxx_web/api/v1/board_controller_test.exs
+133
−0
Click to load diff…
added
test/waxx_web/api/v1/card_controller_test.exs
+171
−0
Click to load diff…
added
test/waxx_web/api/v1/card_relations_test.exs
+229
−0
Click to load diff…
added
test/waxx_web/api/v1/session_controller_test.exs
+104
−0
Click to load diff…
added
test/waxx_web/api/v1/user_controller_test.exs
+41
−0
Click to load diff…
added
test/waxx_web/assetlinks_test.exs
+13
−0
Click to load diff…
added
test/waxx_web/channels/board_channel_test.exs
+47
−0
Click to load diff…
added
test/waxx_web/channels/user_socket_test.exs
+23
−0
Click to load diff…
added
test/waxx_web/controllers/magic_link_controller_test.exs
+8
−0
Click to load diff…
added
test/waxx_web/live/user_live/settings_test.exs
+95
−0
Click to load diff…
added
test/waxx_web/public_url_test.exs
+68
−0
Click to load diff…
added
test/waxx_web/qr_test.exs
+17
−0
Click to load diff…
Parents: 9a5b6ec