android supp

c5a2f92 · gmorell · 2026-05-24 22:07

70 files +2748 -15

Files changed

modified app.yml
+6 −1
@@ -187,6 +187,12 @@ kind: Service
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,7 +200,6 @@ spec:
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,6 +313,84 @@ defmodule Waxx.Accounts do
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,10 +12,17 @@ defmodule Waxx.Accounts.UserToken do
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,4 +116,77 @@ defmodule Waxx.Accounts.UserToken do
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,10 +866,9 @@ defmodule Waxx.Kanban do
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,7 +17,7 @@ defmodule WaxxWeb do
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