Skip to content

Commit 7f2b236

Browse files
committed
Remove site-wide message. update payment api.
1 parent acbaf81 commit 7f2b236

71 files changed

Lines changed: 1449 additions & 1465 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ROADMAP.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# Roadmap
2-
31
## 2026
42

53
- **Rename to Gamend** — repo, code and docs.

apps/game_server_core/lib/game_server/accounts.ex

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,18 +1333,25 @@ defmodule GameServer.Accounts do
13331333
end
13341334

13351335
# Mirror an external (OAuth provider) avatar into our object storage so avatars
1336-
# render from our storage/CDN rather than hotlinking the provider. Enqueued only
1337-
# when the user's avatar is not already one of our stored objects. Oban's
1338-
# `:infinity` uniqueness keeps it to one job per user, and once mirrored the URL
1339-
# points at our storage so `our_stored_avatar?/1` short-circuits future logins —
1340-
# we never re-mirror. See `GameServer.Accounts.AvatarMirror`.
1336+
# render from our storage/CDN rather than hotlinking the provider. Enqueued
1337+
# whenever the user's avatar is not already one of our stored objects — once
1338+
# mirrored the URL points at our storage, so `our_stored_avatar?/1`
1339+
# short-circuits every later sign-in and we never re-fetch.
1340+
#
1341+
# Uniqueness deliberately omits `:discarded` and `:cancelled`. Counting those
1342+
# meant one failed download — a provider 429 is enough — blocked every future
1343+
# job for that user forever, leaving the avatar hotlinked to the provider for
1344+
# good. Excluding them lets the next sign-in try again, while a job still
1345+
# in flight or already completed keeps the dedupe.
1346+
@mirror_dedupe_states [:available, :scheduled, :executing, :retryable, :completed]
1347+
13411348
defp maybe_mirror_avatar(%User{profile_url: url} = user) when is_binary(url) and url != "" do
13421349
unless our_stored_avatar?(user) do
13431350
_ =
13441351
Oban.insert(
13451352
AvatarMirror.new(
13461353
%{"user_id" => user.id, "source_url" => url},
1347-
unique: [keys: [:user_id], period: :infinity, states: Oban.Job.states()]
1354+
unique: [keys: [:user_id], period: :infinity, states: @mirror_dedupe_states]
13481355
)
13491356
)
13501357
end

apps/game_server_core/lib/game_server/accounts/avatar_mirror.ex

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ defmodule GameServer.Accounts.AvatarMirror do
44
own object storage, so avatars render from our storage/CDN instead of
55
hotlinking the provider.
66
7-
Enqueued **once** — the first time a user gets a provider avatar while nothing
8-
is stored yet (see `GameServer.Accounts.maybe_mirror_avatar/2`). We never
9-
re-mirror: a repeated fetch is wasteful and can trip a provider's rate limits,
10-
so if the download fails the provider URL simply stays as the fallback.
7+
Enqueued on sign-in whenever a user's avatar is still an external URL (see
8+
`GameServer.Accounts.maybe_mirror_avatar/1`). Once mirrored the stored URL
9+
lives under our own `avatars/<user_id>/` prefix, so the check short-circuits
10+
and we never re-fetch an avatar we already host.
11+
12+
A *failed* mirror is a different matter from a finished one. Provider CDNs
13+
rate-limit (Google answers 429 readily), so the download is retried with
14+
backoff, and a run that exhausts its attempts does not poison the user
15+
forever — the next sign-in enqueues a fresh job. Until one succeeds the
16+
provider URL stays as the fallback, which is why an un-mirrored avatar can
17+
still 429 in the browser.
1118
"""
12-
use Oban.Worker, queue: :storage, max_attempts: 1
19+
use Oban.Worker, queue: :storage, max_attempts: 5
1320

1421
require Logger
1522

@@ -21,22 +28,21 @@ defmodule GameServer.Accounts.AvatarMirror do
2128
case Accounts.get_user(user_id) do
2229
# Mirror only while the stored avatar is still exactly the provider URL we
2330
# were asked to mirror. If the user has since uploaded or changed it, or is
24-
# gone, leave things alone.
31+
# gone, there is nothing to do and nothing to retry.
2532
%{profile_url: ^source_url} = user -> mirror(user, source_url)
2633
_ -> :ok
2734
end
2835
rescue
2936
e ->
30-
Logger.info("avatar mirror crashed user=#{user_id}: #{inspect(e)}")
31-
:ok
37+
Logger.warning("avatar mirror crashed user=#{user_id}: #{inspect(e)}")
38+
{:error, e}
3239
end
3340

3441
defp mirror(user, source_url) do
3542
# `:avatar_mirror_req_options` lets tests inject a Req.Test plug; empty in prod.
3643
req_opts = Application.get_env(:game_server_core, :avatar_mirror_req_options, [])
3744

38-
with {:ok, %Req.Response{status: 200} = resp} <-
39-
Req.get(source_url, [decode_body: false] ++ req_opts),
45+
with {:ok, %Req.Response{status: 200} = resp} <- fetch(source_url, req_opts),
4046
body when is_binary(body) <- resp.body,
4147
content_type <- content_type(resp, source_url),
4248
:ok <- Storage.validate_upload(content_type, byte_size(body)),
@@ -46,13 +52,29 @@ defmodule GameServer.Accounts.AvatarMirror do
4652
_ = Accounts.prune_user_avatars(user.id, key)
4753
:ok
4854
else
55+
# Rate limits and provider hiccups are worth another go; Oban backs off.
56+
{:ok, %Req.Response{status: status}} when status in [408, 429] or status >= 500 ->
57+
Logger.info("avatar mirror retrying user=#{user.id}: HTTP #{status}")
58+
{:error, {:http, status}}
59+
60+
{:error, reason} ->
61+
Logger.info("avatar mirror retrying user=#{user.id}: #{inspect(reason)}")
62+
{:error, reason}
63+
64+
# A 404, an image type we refuse, a body that is not binary: retrying
65+
# changes nothing, so stop and leave the provider URL as the fallback.
4966
other ->
50-
# Keep the provider URL as the fallback; do not retry.
51-
Logger.info("avatar mirror skipped user=#{user.id}: #{inspect(other)}")
52-
:ok
67+
Logger.info("avatar mirror gave up user=#{user.id}: #{inspect(other)}")
68+
{:cancel, other}
5369
end
5470
end
5571

72+
defp fetch(source_url, req_opts) do
73+
Req.get(source_url, [decode_body: false] ++ req_opts)
74+
rescue
75+
e -> {:error, e}
76+
end
77+
5678
defp content_type(resp, url) do
5779
case Req.Response.get_header(resp, "content-type") do
5880
[ct | _] when is_binary(ct) -> ct |> String.split(";") |> List.first() |> String.trim()

apps/game_server_core/lib/game_server/theme/translatable.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule GameServer.Theme.Translatable do
1414
translated, and cannot vary by locale.
1515
"""
1616

17-
@keys ~w(label title text tagline description alt site_message cta subtitle)
17+
@keys ~w(label title text tagline description alt cta subtitle)
1818

1919
@doc "Keys whose string values are user-facing text."
2020
@spec keys() :: [String.t()]

apps/game_server_core/lib/mix/tasks/gen.sdk.ex

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ defmodule Mix.Tasks.Gen.Sdk do
3030
{GameServer.Jobs, "jobs.ex"},
3131
{GameServer.Economy, "economy.ex"},
3232
{GameServer.Inventory, "inventory.ex"},
33+
# Plugins get `after_purchase_fulfilled`, so they need to be able to read
34+
# the product that was bought.
35+
{GameServer.Payments, "payments.ex"},
3336
{GameServer.KV, "kv.ex"},
3437
{GameServer.Lock, "lock.ex"},
3538
{GameServer.Tournaments, "tournaments.ex"},
@@ -352,6 +355,11 @@ defmodule Mix.Tasks.Gen.Sdk do
352355
{fn rt ->
353356
String.contains?(rt, "GameServer.Groups.Group.t()") and String.contains?(rt, "| nil")
354357
end, "if :erlang.phash2(make_ref(), 2) == 0, do: nil, else: #{group_placeholder_expr()}"},
358+
{fn rt ->
359+
String.contains?(rt, "GameServer.Payments.Product.t()") and
360+
String.contains?(rt, "| nil")
361+
end,
362+
"if :erlang.phash2(make_ref(), 2) == 0, do: nil, else: #{product_placeholder_expr()}"},
355363
{fn rt ->
356364
String.contains?(rt, "GameServer.Quests.Quest.t()") and String.contains?(rt, "| nil")
357365
end, "if :erlang.phash2(make_ref(), 2) == 0, do: nil, else: #{quest_placeholder_expr()}"},
@@ -391,7 +399,11 @@ defmodule Mix.Tasks.Gen.Sdk do
391399
defp placeholder_expr_for_primitives(return_type) when is_binary(return_type) do
392400
cond do
393401
String.contains?(return_type, "boolean()") ->
394-
"false"
402+
# Both branches, not a literal `false`: a constant makes every
403+
# `if Something.enabled?(...)` in plugin code look unreachable to the
404+
# type checker, which is a warning the plugin author cannot fix.
405+
" :erlang.phash2(make_ref(), 2) == 0"
406+
|> String.trim()
395407

396408
String.contains?(return_type, "integer()") ->
397409
"0"
@@ -465,6 +477,12 @@ defmodule Mix.Tasks.Gen.Sdk do
465477
"%GameServer.Lobbies.Lobby{id: 0, title: \"\", host_id: nil, hostless: false, max_users: 0, is_hidden: false, is_locked: false, metadata: %{}, inserted_at: #{dt}, updated_at: #{dt}}"
466478
end
467479

480+
defp product_placeholder_expr do
481+
dt = dt_placeholder_expr()
482+
483+
"%GameServer.Payments.Product{id: \"\", sku: \"\", title: \"\", description: \"\", kind: \"entitlement\", active: true, grant_config: %{}, metadata: %{}, inserted_at: #{dt}, updated_at: #{dt}}"
484+
end
485+
468486
defp group_placeholder_expr do
469487
dt = dt_placeholder_expr()
470488

apps/game_server_web/assets/js/app.js

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -159,55 +159,6 @@ const Hooks = {
159159
})
160160
},
161161
},
162-
/**
163-
* SiteBanner — dismissible site-wide announcement banner.
164-
*
165-
* Reads `data-message-hash` to identify the current message. Dismissed hashes
166-
* are stored in localStorage so the banner stays hidden across page loads.
167-
* When the message changes (hash differs), the banner re-appears.
168-
*/
169-
SiteBanner: {
170-
mounted() {
171-
const STORAGE_KEY = "dismissed_site_banners"
172-
const hash = this.el.dataset.messageHash
173-
if (!hash) return
174-
175-
// Read dismissed set from localStorage
176-
const dismissed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]")
177-
178-
if (dismissed.includes(hash)) {
179-
// Already dismissed — stay hidden
180-
return
181-
}
182-
183-
// Not dismissed — reveal the banner
184-
this.el.classList.remove("hidden")
185-
186-
// Wire up dismiss button
187-
const btn = this.el.querySelector("[data-dismiss-banner]")
188-
if (btn) {
189-
btn.addEventListener("click", () => {
190-
// Slide up animation
191-
this.el.style.maxHeight = this.el.scrollHeight + "px"
192-
requestAnimationFrame(() => {
193-
this.el.style.overflow = "hidden"
194-
this.el.style.maxHeight = "0"
195-
this.el.style.paddingTop = "0"
196-
this.el.style.paddingBottom = "0"
197-
})
198-
199-
// Persist after animation completes
200-
setTimeout(() => {
201-
this.el.style.display = "none"
202-
// Keep only the last 50 dismissed hashes to avoid unbounded growth
203-
const current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]")
204-
const updated = [...current.slice(-49), hash]
205-
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
206-
}, 300)
207-
})
208-
}
209-
}
210-
},
211162
GameAuth: {
212163
mounted() {
213164
const access = this.el.dataset.accessToken

apps/game_server_web/config/test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ else
2929
config :game_server_core, GameServer.Repo,
3030
database: database_path,
3131
adapter: Ecto.Adapters.SQLite3,
32+
# Match production: see the note in config/host_runtime.exs.
33+
default_transaction_mode: :immediate,
3234
pool: Ecto.Adapters.SQL.Sandbox,
3335
pool_size: 1,
3436
pool_timeout: 10_000,

apps/game_server_web/lib/game_server_web/components/host_layout_navigation.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,22 @@ defmodule GameServerWeb.HostLayoutNavigation do
854854
# (icon + label, no href/click) — e.g. a live status/value indicator.
855855
defp readonly?(entry), do: Map.get(entry, "readonly") == true
856856

857+
@doc """
858+
Whether `entry` should be shown to a viewer with this `current_scope`.
859+
860+
Same `"auth"` convention the nav uses (`"any"`, `"unauthenticated"`,
861+
`"authenticated"`, `"admin"`, or `"admin_only": true`), exposed so the footer
862+
filters identically — a link to a page the viewer cannot open is a link to an
863+
error page.
864+
"""
865+
@spec entry_visible?(map(), map() | nil) :: boolean()
866+
def entry_visible?(entry, current_scope) do
867+
link_visible?(entry, auth_level(scope_user(current_scope)), "any")
868+
end
869+
870+
defp scope_user(%{user: user}), do: user
871+
defp scope_user(_scope), do: nil
872+
857873
defp link_visible?(link, auth_level, default_auth) do
858874
required = required_auth(link, default_auth)
859875

apps/game_server_web/lib/game_server_web/components/host_layout_shell.ex

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ defmodule GameServerWeb.HostLayoutShell do
1212
attr :navigation, :map, default: %{}
1313
attr :footer, :map, default: %{}
1414
attr :background_icons, :list, default: []
15-
attr :site_message, :string, default: ""
16-
attr :site_message_hash, :string, default: ""
1715
attr :notif_unread_count, :integer, default: 0
1816
attr :locale, :string, required: true
1917
attr :known_locales, :list, default: []
@@ -95,8 +93,6 @@ defmodule GameServerWeb.HostLayoutShell do
9593
known_locales={@known_locales}
9694
/>
9795
98-
<.site_banner site_message={@site_message} site_message_hash={@site_message_hash} />
99-
10096
<%= if @flush do %>
10197
<div class="flex-1 min-h-0 relative">
10298
{render_slot(@inner_block)}
@@ -118,7 +114,7 @@ defmodule GameServerWeb.HostLayoutShell do
118114
</h2>
119115
<nav class="flex flex-col gap-1.5">
120116
<a
121-
:for={link <- Map.get(section, "links", [])}
117+
:for={link <- visible_footer_links(section, @current_scope)}
122118
href={link["href"]}
123119
target={if(link["external"], do: "_blank", else: nil)}
124120
rel={if(link["external"], do: "noopener noreferrer", else: nil)}
@@ -138,6 +134,15 @@ defmodule GameServerWeb.HostLayoutShell do
138134
defp footer_sections(%{"sections" => sections}) when is_list(sections), do: sections
139135
defp footer_sections(_footer), do: []
140136

137+
# A footer link obeys the same `"auth"` rule as the nav: /store is behind
138+
# `require_authenticated_user`, so advertising it to a signed-out visitor
139+
# just sends them to an error.
140+
defp visible_footer_links(section, current_scope) do
141+
section
142+
|> Map.get("links", [])
143+
|> Enum.filter(&GameServerWeb.HostLayoutNavigation.entry_visible?(&1, current_scope))
144+
end
145+
141146
attr :background_icons, :list, default: []
142147
attr :current_path, :string, default: nil
143148

@@ -160,30 +165,4 @@ defmodule GameServerWeb.HostLayoutShell do
160165
<% end %>
161166
"""
162167
end
163-
164-
attr :site_message, :string, default: ""
165-
attr :site_message_hash, :string, default: ""
166-
167-
def site_banner(assigns) do
168-
~H"""
169-
<%= if @site_message != "" do %>
170-
<div
171-
id="site-banner"
172-
phx-hook="SiteBanner"
173-
data-message-hash={@site_message_hash}
174-
class="hidden relative z-40 bg-base-200/60 backdrop-blur-sm text-base-content/70 px-4 py-1.5 text-center text-xs transition-all duration-300 border-b border-base-300/40"
175-
>
176-
<span>{@site_message}</span>
177-
<button
178-
type="button"
179-
data-dismiss-banner
180-
class="absolute right-3 top-1/2 -translate-y-1/2 opacity-40 hover:opacity-80 transition-opacity cursor-pointer"
181-
aria-label={GameServerWeb.HostLayouts.translate("Dismiss")}
182-
>
183-
<.icon name="hero-x-mark" class="w-3.5 h-3.5" />
184-
</button>
185-
</div>
186-
<% end %>
187-
"""
188-
end
189168
end

apps/game_server_web/lib/game_server_web/components/host_layouts.ex

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ defmodule GameServerWeb.HostLayouts do
7474

7575
@host_theme_css_path "/theme.css"
7676

77-
@theme_translatable_top_keys ~w(title tagline description site_message)
77+
@theme_translatable_top_keys ~w(title tagline description)
7878

7979
@theme_translatable_array_fields [
8080
{["footer", "sections"], "title"},
@@ -320,20 +320,6 @@ defmodule GameServerWeb.HostLayouts do
320320

321321
navigation = navigation_config(theme, en_theme)
322322
background_icons = theme_list(theme, en_theme, "background_icons")
323-
site_message_source = Map.get(en_theme, "site_message", "")
324-
325-
site_message =
326-
case Map.get(theme, "site_message", "") do
327-
"" -> site_message_source
328-
message -> message
329-
end
330-
331-
site_message_hash =
332-
if site_message_source != "" do
333-
:erlang.phash2(site_message_source) |> Integer.to_string()
334-
else
335-
""
336-
end
337323

338324
notif_unread_count =
339325
if assigns[:current_scope] do
@@ -351,8 +337,6 @@ defmodule GameServerWeb.HostLayouts do
351337
navigation: navigation,
352338
footer: Map.get(theme, "footer", %{}),
353339
background_icons: background_icons,
354-
site_message: site_message,
355-
site_message_hash: site_message_hash,
356340
notif_unread_count: notif_unread_count
357341
)
358342
end

0 commit comments

Comments
 (0)