add landing page option

rabbitprincess committed Mar 22, 2026 at 00:48 UTC 14904e2a9f27627fb89e2946e915d769e8b17c6b
15 files changed +375 -89
cmd/relay-server/admin.go
+53 -28
@@ -97,26 +97,29 @@ func (a *adminAuth) cleanupExpiredSessionsLocked() {
97 }
98 }
99
100 -func loadAdminState(path string, runtime *policy.Runtime) error {
100 +func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) {
101 root, name, err := openSettingsRoot(path)
102 if err != nil {
103 - return err
103 + return persistedAdminState{}, err
104 }
105 defer root.Close()
106
107 data, err := root.ReadFile(name)
108 if err != nil {
109 if errors.Is(err, os.ErrNotExist) {
110 - return nil
110 + return persistedAdminState{}, nil
111 }
112 - return err
112 + return persistedAdminState{}, err
113 }
114
115 var payload persistedAdminState
116 if err := json.Unmarshal(data, &payload); err != nil {
117 - return err
117 + return persistedAdminState{}, err
118 }
119 - return payload.apply(runtime)
119 + if err := payload.apply(runtime); err != nil {
120 + return persistedAdminState{}, err
121 + }
122 + return payload, nil
123 }
124
125 func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
@@ -196,13 +199,29 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
199 return
200 }
201 utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
199 - ApprovalMode: string(runtime.Approver().Mode()),
200 - Leases: f.adminLeaseSnapshots(),
202 + ApprovalMode: string(runtime.Approver().Mode()),
203 + LandingPageEnabled: f.isLandingPageEnabled(),
204 + Leases: f.adminLeaseSnapshots(),
205 UDP: types.AdminUDPSettingsResponse{
206 Enabled: runtime.IsUDPEnabled(),
207 MaxLeases: runtime.UDPMaxLeases(),
208 },
209 })
210 + case types.PathAdminLandingPage:
211 + if r.Method != http.MethodPost {
212 + methodNotAllowed()
213 + return
214 + }
215 + var req types.AdminLandingPageSettingsRequest
216 + if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
217 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
218 + return
219 + }
220 + f.setLandingPageEnabled(req.Enabled)
221 + f.saveAdminState(runtime)
222 + utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{
223 + Enabled: f.isLandingPageEnabled(),
224 + })
225 case types.PathAdminUDP:
226 if r.Method != http.MethodPost {
227 methodNotAllowed()
@@ -399,11 +418,11 @@ func (f *Frontend) saveAdminState(runtime *policy.Runtime) {
418 if f == nil {
419 return
420 }
402 - saveAdminState(f.adminSettingsPath, runtime)
421 + saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
422 }
423
405 -func saveAdminState(path string, runtime *policy.Runtime) {
406 - payload := persistedStateFromRuntime(runtime)
424 +func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) {
425 + payload := persistedStateFromRuntime(runtime, landingPageEnabled)
426 data, err := json.MarshalIndent(payload, "", " ")
427 if err != nil {
428 return
@@ -418,32 +437,38 @@ func saveAdminState(path string, runtime *policy.Runtime) {
437 }
438
439 type persistedAdminState struct {
421 - ApprovalMode string `json:"approval_mode"`
422 - ApprovedLeases []string `json:"approved_leases,omitempty"`
423 - DeniedLeases []string `json:"denied_leases,omitempty"`
424 - BannedLeases []string `json:"banned_leases,omitempty"`
425 - BannedIPs []string `json:"banned_ips,omitempty"`
426 - LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
427 - UDPEnabled *bool `json:"udp_enabled,omitempty"`
428 - UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
440 + ApprovalMode string `json:"approval_mode"`
441 + ApprovedLeases []string `json:"approved_leases,omitempty"`
442 + DeniedLeases []string `json:"denied_leases,omitempty"`
443 + BannedLeases []string `json:"banned_leases,omitempty"`
444 + BannedIPs []string `json:"banned_ips,omitempty"`
445 + LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
446 + UDPEnabled *bool `json:"udp_enabled,omitempty"`
447 + UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
448 + LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
449 }
450
431 -func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
451 +func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool) persistedAdminState {
452 approver := runtime.Approver()
453 udpEnabled := runtime.IsUDPEnabled()
454 udpMaxLeases := runtime.UDPMaxLeases()
455 return persistedAdminState{
436 - ApprovalMode: string(approver.Mode()),
437 - ApprovedLeases: approver.ApprovedLeases(),
438 - DeniedLeases: approver.DeniedLeases(),
439 - BannedLeases: runtime.BannedLeases(),
440 - BannedIPs: runtime.IPFilter().BannedIPs(),
441 - LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
442 - UDPEnabled: &udpEnabled,
443 - UDPMaxLeases: &udpMaxLeases,
456 + ApprovalMode: string(approver.Mode()),
457 + ApprovedLeases: approver.ApprovedLeases(),
458 + DeniedLeases: approver.DeniedLeases(),
459 + BannedLeases: runtime.BannedLeases(),
460 + BannedIPs: runtime.IPFilter().BannedIPs(),
461 + LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
462 + UDPEnabled: &udpEnabled,
463 + UDPMaxLeases: &udpMaxLeases,
464 + LandingPageEnabled: &landingPageEnabled,
465 }
466 }
467
468 +func (s persistedAdminState) landingPageEnabled() bool {
469 + return s.LandingPageEnabled == nil || *s.LandingPageEnabled
470 +}
471 +
472 func (s persistedAdminState) apply(runtime *policy.Runtime) error {
473 if runtime == nil {
474 return nil
cmd/relay-server/frontend.go
+25 -3
@@ -9,8 +9,10 @@ import (
9 "mime"
10 "net/http"
11 "path"
12 + "strconv"
13 "strings"
14 "sync"
15 + "sync/atomic"
16 "time"
17
18 "github.com/gosuda/portal/v2/portal"
@@ -34,6 +36,7 @@ type Frontend struct {
36
37 cachedPortalHTML []byte
38 cachedPortalHTMLOnce sync.Once
39 + landingPageEnabled atomic.Bool
40 }
41
42 func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string) (*Frontend, error) {
@@ -44,16 +47,19 @@ func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath st
47 if runtime == nil {
48 return nil, errors.New("frontend requires policy runtime")
49 }
47 - if err := loadAdminState(adminSettingsPath, runtime); err != nil {
50 + state, err := loadAdminState(adminSettingsPath, runtime)
51 + if err != nil {
52 return nil, err
53 }
54
51 - return &Frontend{
55 + frontend := &Frontend{
56 distFS: embeddedDistFS,
57 server: server,
58 auth: newAdminAuth(adminSecret),
59 adminSettingsPath: strings.TrimSpace(adminSettingsPath),
56 - }, nil
60 + }
61 + frontend.setLandingPageEnabled(state.landingPageEnabled())
62 + return frontend, nil
63 }
64
65 func (f *Frontend) Handler() *http.ServeMux {
@@ -228,11 +234,27 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description string) stri
234 replacer := strings.NewReplacer(
235 "[%OG_TITLE%]", html.EscapeString(title),
236 "[%OG_DESCRIPTION%]", html.EscapeString(description),
237 + "[%LANDING_PAGE_ENABLED%]", html.EscapeString(strconv.FormatBool(f.isLandingPageEnabled())),
238 + "[%SERVER_OWNER_ADDRESS%]", html.EscapeString(f.server.OwnerAddress()),
239 "[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion),
240 )
241 return replacer.Replace(htmlContent)
242 }
243
244 +func (f *Frontend) isLandingPageEnabled() bool {
245 + if f == nil {
246 + return true
247 + }
248 + return f.landingPageEnabled.Load()
249 +}
250 +
251 +func (f *Frontend) setLandingPageEnabled(enabled bool) {
252 + if f == nil {
253 + return
254 + }
255 + f.landingPageEnabled.Store(enabled)
256 +}
257 +
258 func (f *Frontend) adminLeaseSnapshots() []types.Lease {
259 snapshots := f.server.LeaseSnapshots()
260 if len(snapshots) == 0 {
frontend/AGENTS.md
+1 -1
@@ -23,7 +23,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
23 - Why: any tooling or script assuming `index.html` post-build will fail.
24
25 5. **HTML metadata placeholders must match between HTML and Go.**
26 - `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
26 + `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%LANDING_PAGE_ENABLED%]`, `[%SERVER_OWNER_ADDRESS%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
27 - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
28
29 6. **Admin state reads are aggregated through `/admin/snapshot`.**
frontend/index.html
+2
@@ -13,6 +13,8 @@
13 <meta name="twitter:card" content="summary" />
14 <meta name="twitter:title" content="[%OG_TITLE%]" />
15 <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
16 + <meta name="portal-landing-page-enabled" content="[%LANDING_PAGE_ENABLED%]" />
17 + <meta name="portal-server-owner-address" content="[%SERVER_OWNER_ADDRESS%]" />
18 <meta name="portal-release-version" content="[%RELEASE_VERSION%]" />
19 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
20 <title>Portal - Local to web. Instant access.</title>
frontend/src/components/Header.tsx
+150 -48
@@ -1,4 +1,5 @@
1 -import { LogOut } from "lucide-react";
1 +import { useEffect, useState } from "react";
2 +import { Check, Copy, LogOut } from "lucide-react";
3 import { Button } from "@/components/ui/button";
4 import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5 import {
@@ -18,45 +19,114 @@ interface HeaderProps {
19 }
20
21 const repoURL = "https://github.com/gosuda/portal";
22 +const SERVER_OWNER_ADDRESS_META_NAME = "portal-server-owner-address";
23 +
24 +function getServerOwnerAddress(doc?: Document): string {
25 + const targetDoc =
26 + doc ?? (typeof document !== "undefined" ? document : undefined);
27 + if (!targetDoc) {
28 + return "";
29 + }
30 +
31 + return (
32 + targetDoc
33 + .querySelector<HTMLMetaElement>(
34 + `meta[name="${SERVER_OWNER_ADDRESS_META_NAME}"]`
35 + )
36 + ?.content.trim() || ""
37 + );
38 +}
39 +
40 +function formatOwnerAddress(address: string): string {
41 + const trimmed = address.trim();
42 + if (trimmed.length <= 14) {
43 + return trimmed;
44 + }
45 +
46 + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
47 +}
48 +
49 export function Header({
50 title = "PORTAL",
51 isAdmin,
52 onLogout,
53 }: HeaderProps) {
54 const releaseVersion = getReleaseVersion();
55 + const serverOwnerAddress = getServerOwnerAddress();
56 + const [ownerAddressCopied, setOwnerAddressCopied] = useState(false);
57 + const addYourServerTrigger = (
58 + <Button
59 + className={clsx(
60 + "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
61 + "hidden sm:inline-flex"
62 + )}
63 + >
64 + <span className="truncate">Add Your Server</span>
65 + </Button>
66 + );
67 + const displayOwnerAddress = formatOwnerAddress(serverOwnerAddress);
68 +
69 + useEffect(() => {
70 + if (!ownerAddressCopied) {
71 + return;
72 + }
73 +
74 + const timer = window.setTimeout(() => {
75 + setOwnerAddressCopied(false);
76 + }, 1800);
77 +
78 + return () => {
79 + window.clearTimeout(timer);
80 + };
81 + }, [ownerAddressCopied]);
82 +
83 + const handleCopyOwnerAddress = async () => {
84 + if (!serverOwnerAddress) {
85 + return;
86 + }
87 +
88 + try {
89 + await navigator.clipboard.writeText(serverOwnerAddress);
90 + setOwnerAddressCopied(true);
91 + } catch (error) {
92 + console.error("Failed to copy owner address", error);
93 + }
94 + };
95
96 return (
29 - <header className="flex flex-wrap items-center justify-between gap-4 py-2">
30 - <div className="flex min-w-0 flex-wrap items-center gap-5 text-foreground sm:gap-8">
31 - <div className="flex min-w-0 items-center gap-1.5 text-foreground sm:gap-2">
32 - <div className="flex h-10 w-10 shrink-0 items-center justify-center">
33 - <svg
34 - xmlns="http://www.w3.org/2000/svg"
35 - width="27"
36 - height="27"
37 - viewBox="0 0 906.26 1457.543"
38 - className="h-6 w-6 text-primary"
39 - >
40 - <path
41 - fill="currentColor"
42 - d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
43 - />
44 - </svg>
45 - </div>
97 + <header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 py-2 lg:flex-nowrap">
98 + <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-foreground sm:gap-x-6 lg:gap-x-7">
99 + <div className="min-w-0 text-foreground">
100 + <div className="flex min-w-0 items-center gap-1.5 sm:gap-2">
101 + <div className="flex h-10 w-10 shrink-0 items-center justify-center">
102 + <svg
103 + xmlns="http://www.w3.org/2000/svg"
104 + width="27"
105 + height="27"
106 + viewBox="0 0 906.26 1457.543"
107 + className="h-6 w-6 text-primary"
108 + >
109 + <path
110 + fill="currentColor"
111 + d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
112 + />
113 + </svg>
114 + </div>
115
47 - <div className="flex min-w-0 flex-wrap items-center gap-2">
48 - <h2 className="min-w-0 break-words text-xl font-extrabold tracking-tight text-foreground sm:text-2xl">
49 - {title}
50 - </h2>
51 - {releaseVersion && (
52 - <span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-text-muted">
53 - {releaseVersion}
54 - </span>
55 - )}
116 + <div className="flex min-w-0 flex-wrap items-center gap-2.5">
117 + <h2 className="min-w-0 break-words text-xl leading-none font-extrabold tracking-tight text-foreground sm:text-2xl">
118 + {title}
119 + </h2>
120 + {releaseVersion && (
121 + <span className="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted">
122 + {releaseVersion}
123 + </span>
124 + )}
125 + </div>
126 </div>
127 </div>
128 {!isAdmin && (
59 - <nav className="hidden items-center gap-10 pl-3 text-base font-semibold text-text-muted md:flex lg:pl-5">
129 + <nav className="hidden items-center gap-6 pl-2 text-base font-semibold text-text-muted md:flex lg:pl-3">
130 <a
131 href="#live-servers"
132 className="transition-colors hover:text-foreground"
@@ -73,35 +143,67 @@ export function Header({
143 )}
144 </div>
145
76 - <div className="flex flex-wrap items-center gap-3 sm:gap-4">
77 -
78 - {isAdmin ? (
79 - <TunnelCommandModal
80 - trigger={
81 - <Button
82 - className={clsx(
83 - "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
84 - "hidden sm:inline-flex"
85 - )}
146 + <div className="flex shrink-0 flex-wrap items-center gap-2 sm:gap-3">
147 + {serverOwnerAddress && (
148 + <div
149 + title={serverOwnerAddress}
150 + className="hidden h-11 items-center gap-2 rounded-full border border-sky-500/20 bg-background/85 pl-1.5 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur lg:inline-flex"
151 + >
152 + <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-sky-400 via-cyan-300 to-blue-500 shadow-[0_8px_20px_rgba(56,189,248,0.28)]">
153 + <svg
154 + xmlns="http://www.w3.org/2000/svg"
155 + width="16"
156 + height="16"
157 + viewBox="0 0 906.26 1457.543"
158 + className="h-4 w-4 text-slate-950"
159 >
87 - <span className="truncate">Add Your Server</span>
88 - </Button>
89 - }
90 - />
91 - ) : (
160 + <path
161 + fill="currentColor"
162 + d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
163 + />
164 + </svg>
165 + </div>
166 +
167 + <span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-600 dark:text-sky-300">
168 + Address
169 + </span>
170 + <span className="max-w-[8.5rem] truncate font-mono text-[13px] font-semibold tracking-tight text-foreground">
171 + {displayOwnerAddress}
172 + </span>
173 +
174 + <button
175 + type="button"
176 + onClick={() => {
177 + void handleCopyOwnerAddress();
178 + }}
179 + className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/90 text-text-muted transition-colors hover:text-foreground"
180 + aria-label="Copy owner address"
181 + >
182 + {ownerAddressCopied ? (
183 + <Check className="h-4 w-4 text-sky-500" />
184 + ) : (
185 + <Copy className="h-4 w-4" />
186 + )}
187 + </button>
188 + </div>
189 + )}
190 +
191 + <TunnelCommandModal trigger={addYourServerTrigger} />
192 +
193 + {!isAdmin && (
194 <a
195 href={repoURL}
196 target="_blank"
197 rel="noopener noreferrer"
96 - className="text-foreground transition-colors hover:text-primary"
198 + className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card/95 text-foreground transition-colors hover:bg-secondary hover:text-primary"
199 aria-label="View source on GitHub"
200 >
201 <svg
100 - height="32"
101 - width="32"
202 + height="20"
203 + width="20"
204 viewBox="0 0 24 24"
205 fill="currentColor"
104 - className="opacity-80 hover:opacity-100"
206 + className="opacity-80 transition-opacity hover:opacity-100"
207 >
208 <path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z" />
209 </svg>
@@ -118,7 +220,7 @@ export function Header({
220 variant="outline"
221 size="icon"
222 onClick={onLogout}
121 - className="cursor-pointer rounded-full text-foreground hover:text-destructive"
223 + className="h-11 w-11 cursor-pointer rounded-full text-foreground hover:text-destructive"
224 aria-label="Logout"
225 >
226 <LogOut className="h-5 w-5" />
frontend/src/components/LandingHero.tsx
+1 -1
@@ -79,7 +79,7 @@ export function LandingHero() {
79 </div>
80
81 <div
82 - className="relative mx-auto mt-10 w-full max-w-[550px] rounded-[1.75rem] border px-4 py-5 sm:px-5 sm:py-6"
82 + className="relative mx-auto mt-10 w-full max-w-[520px] rounded-[1.75rem] border px-4 py-5 sm:px-5 sm:py-6"
83 style={{
84 background: "var(--hero-terminal-bg)",
85 borderColor: "var(--hero-terminal-border)",
frontend/src/components/ServerListView.tsx
+74 -5
@@ -106,6 +106,7 @@ interface ServerListViewProps {
106 isAdmin?: boolean;
107 banFilter?: BanFilter;
108 approvalMode?: ApprovalMode;
109 + landingPageEnabled?: boolean;
110 onBanFilterChange?: (value: BanFilter) => void;
111 onBanStatusChange?: (
112 leaseId: string,
@@ -113,6 +114,7 @@ interface ServerListViewProps {
114 ) => void | Promise<void>;
115 onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
116 onApprovalModeChange?: (mode: ApprovalMode) => void;
117 + onLandingPageEnabledChange?: (enabled: boolean) => void | Promise<void>;
118 udpSettings?: UDPSettings;
119 onUDPSettingsChange?: (settings: UDPSettings) => void | Promise<void>;
120 onApproveStatusChange?: (
@@ -155,10 +157,12 @@ export function ServerListView({
157 isAdmin = false,
158 banFilter = "all",
159 approvalMode = "auto",
160 + landingPageEnabled = true,
161 onBanFilterChange,
162 onBanStatusChange,
163 onBPSChange,
164 onApprovalModeChange,
165 + onLandingPageEnabledChange,
166 udpSettings,
167 onUDPSettingsChange,
168 onApproveStatusChange,
@@ -178,6 +182,7 @@ export function ServerListView({
182 );
183 const serverItems = filteredServers as ListServer[];
184 const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
185 + const showLandingHero = !isAdmin && landingPageEnabled;
186
187 const handleToggleSelect = (leaseId: string) => {
188 setSelectedLeaseIds((prev) => {
@@ -325,6 +330,12 @@ export function ServerListView({
330 }
331 };
332
333 + const handleLandingPageToggle = (enabled: boolean) => {
334 + if (onLandingPageEnabledChange) {
335 + void onLandingPageEnabledChange(enabled);
336 + }
337 + };
338 +
339 const handleMaxLeasesSave = () => {
340 if (onUDPSettingsChange && udpSettings) {
341 const value = Math.max(0, parseInt(maxLeasesInput, 10) || 0);
@@ -355,6 +366,33 @@ export function ServerListView({
366 />
367 </div>
368 )}
369 + {onLandingPageEnabledChange && (
370 + <div className="flex items-center gap-3">
371 + <span className="text-sm font-medium text-text-muted">Landing</span>
372 + <div className="flex overflow-hidden rounded-lg border border-foreground/20">
373 + <button
374 + onClick={() => handleLandingPageToggle(true)}
375 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
376 + landingPageEnabled
377 + ? "bg-primary text-primary-foreground"
378 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
379 + }`}
380 + >
381 + Shown
382 + </button>
383 + <button
384 + onClick={() => handleLandingPageToggle(false)}
385 + className={`cursor-pointer border-l border-foreground/20 px-4 h-10 text-sm font-medium transition-colors ${
386 + !landingPageEnabled
387 + ? "bg-primary text-primary-foreground"
388 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
389 + }`}
390 + >
391 + Hidden
392 + </button>
393 + </div>
394 + </div>
395 + )}
396 {onUDPSettingsChange && udpSettings && (
397 <>
398 <div className="flex items-center gap-3">
@@ -553,6 +591,35 @@ export function ServerListView({
591 </div>
592 )}
593 </div>
594 + {onLandingPageEnabledChange && (
595 + <div className="mt-4 flex items-center gap-3 px-4 sm:hidden">
596 + <span className="text-sm font-medium text-text-muted">
597 + Landing
598 + </span>
599 + <div className="flex overflow-hidden rounded-lg border border-foreground/20">
600 + <button
601 + onClick={() => handleLandingPageToggle(true)}
602 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
603 + landingPageEnabled
604 + ? "bg-primary text-primary-foreground"
605 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
606 + }`}
607 + >
608 + Shown
609 + </button>
610 + <button
611 + onClick={() => handleLandingPageToggle(false)}
612 + className={`cursor-pointer border-l border-foreground/20 px-4 h-10 text-sm font-medium transition-colors ${
613 + !landingPageEnabled
614 + ? "bg-primary text-primary-foreground"
615 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
616 + }`}
617 + >
618 + Hidden
619 + </button>
620 + </div>
621 + </div>
622 + )}
623 </div>
624 <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-0 md:px-8">
625 <main className="z-0 flex-1">
@@ -566,7 +633,7 @@ export function ServerListView({
633 </>
634 ) : (
635 <>
569 - <div className="sticky top-0 z-20 w-full bg-background/95 pt-5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
636 + <div className="sticky top-0 z-20 w-full bg-background/95 py-5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
637 <div className="flex w-full flex-col px-6 sm:px-8 lg:px-10">
638 <Header
639 title={title}
@@ -577,9 +644,11 @@ export function ServerListView({
644 </div>
645 <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col border-x border-border/80">
646 <main className="z-0 flex-1 pb-14">
580 - <section className="border-b border-border/80 px-4 pt-6 sm:px-6 md:px-8">
581 - <LandingHero />
582 - </section>
647 + {showLandingHero && (
648 + <section className="border-b border-border/80 px-4 pt-6 sm:px-6 md:px-8">
649 + <LandingHero />
650 + </section>
651 + )}
652
653 <section
654 id="live-servers"
@@ -674,7 +743,7 @@ export function ServerListView({
743 </span>
744 ) : relay.releaseVersion ? (
745 <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
677 - Release {relay.releaseVersion}
746 + {relay.releaseVersion}
747 </span>
748 ) : null}
749 </div>
frontend/src/hooks/useAdmin.ts
+21
@@ -19,8 +19,13 @@ type ApprovalModeResponse = {
19 approval_mode?: ApprovalMode;
20 };
21
22 +type LandingPageSettingsResponse = {
23 + enabled?: boolean;
24 +};
25 +
26 type AdminSnapshotResponse = {
27 approval_mode?: ApprovalMode;
28 + landing_page_enabled?: boolean;
29 leases?: ServerData[];
30 udp?: { enabled: boolean; max_leases: number };
31 };
@@ -132,6 +137,7 @@ function dedupeStrings(values: string[]): string[] {
137 interface AdminSnapshot {
138 serverData: ServerData[];
139 approvalMode: ApprovalMode;
140 + landingPageEnabled: boolean;
141 udpSettings: UDPSettings;
142 }
143
@@ -142,6 +148,7 @@ async function loadAdminSnapshot(): Promise<AdminSnapshot> {
148 return {
149 serverData: normalizedLeases,
150 approvalMode: normalizeApprovalMode(snapshot?.approval_mode),
151 + landingPageEnabled: snapshot?.landing_page_enabled ?? true,
152 udpSettings: {
153 enabled: snapshot?.udp?.enabled ?? false,
154 maxLeases: snapshot?.udp?.max_leases ?? 0,
@@ -152,6 +159,7 @@ async function loadAdminSnapshot(): Promise<AdminSnapshot> {
159 export function useAdmin() {
160 const [serverData, setServerData] = useState<ServerData[]>([]);
161 const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
162 + const [landingPageEnabled, setLandingPageEnabled] = useState(true);
163 const [udpSettings, setUDPSettings] = useState<UDPSettings>({ enabled: false, maxLeases: 0 });
164 const [loading, setLoading] = useState(true);
165 const [error, setError] = useState("");
@@ -161,6 +169,7 @@ export function useAdmin() {
169 const applySnapshot = (snapshot: AdminSnapshot) => {
170 setServerData(snapshot.serverData);
171 setApprovalMode(snapshot.approvalMode);
172 + setLandingPageEnabled(snapshot.landingPageEnabled);
173 setUDPSettings(snapshot.udpSettings);
174 };
175
@@ -319,6 +328,16 @@ export function useAdmin() {
328 });
329 };
330
331 + const handleLandingPageEnabledChange = async (enabled: boolean) => {
332 + await runAdminAction(async () => {
333 + const response = await apiClient.post<LandingPageSettingsResponse>(
334 + API_PATHS.admin.landingPage,
335 + { enabled }
336 + );
337 + setLandingPageEnabled(response?.enabled ?? enabled);
338 + });
339 + };
340 +
341 const handleApproveStatus = (peerId: string, approve: boolean) =>
342 runAdminAction(() => updateLeaseAction(peerId, "approve", approve));
343
@@ -379,6 +398,7 @@ export function useAdmin() {
398 ...listState,
399 banFilter,
400 approvalMode,
401 + landingPageEnabled,
402 udpSettings,
403 loading,
404 error,
@@ -386,6 +406,7 @@ export function useAdmin() {
406 handleBanStatus,
407 handleBPSChange,
408 handleApprovalModeChange,
409 + handleLandingPageEnabledChange,
410 handleUDPSettingsChange,
411 handleApproveStatus,
412 handleDenyStatus,
frontend/src/lib/apiPaths.test.ts
+1
@@ -5,6 +5,7 @@ import { API_PATHS, adminLeasePath, encodeLeaseID } from "@/lib/apiPaths";
5 describe("API_PATHS contract alignment", () => {
6 it("keeps admin snapshot path aligned", () => {
7 expect(API_PATHS.admin.snapshot).toBe("/admin/snapshot");
8 + expect(API_PATHS.admin.landingPage).toBe("/admin/settings/landing-page");
9 });
10
11 it("keeps sdk endpoint paths aligned", () => {
frontend/src/lib/apiPaths.ts
+1
@@ -8,6 +8,7 @@ export const API_PATHS = {
8 leases: "/admin/leases",
9 stats: "/admin/stats",
10 approvalMode: "/admin/settings/approval-mode",
11 + landingPage: "/admin/settings/landing-page",
12 udpSettings: "/admin/settings/udp",
13 },
14 sdk: {
frontend/src/pages/Admin.tsx
+4
@@ -19,6 +19,7 @@ export function Admin() {
19 selectedTags,
20 banFilter,
21 approvalMode,
22 + landingPageEnabled,
23 udpSettings,
24 favorites,
25 loading,
@@ -32,6 +33,7 @@ export function Admin() {
33 handleBanStatus,
34 handleBPSChange,
35 handleApprovalModeChange,
36 + handleLandingPageEnabledChange,
37 handleUDPSettingsChange,
38 handleApproveStatus,
39 handleDenyStatus,
@@ -88,11 +90,13 @@ export function Admin() {
90 isAdmin={true}
91 banFilter={banFilter}
92 approvalMode={approvalMode}
93 + landingPageEnabled={landingPageEnabled}
94 udpSettings={udpSettings}
95 onBanFilterChange={handleBanFilterChange}
96 onBanStatusChange={handleBanStatus}
97 onBPSChange={handleBPSChange}
98 onApprovalModeChange={handleApprovalModeChange}
99 + onLandingPageEnabledChange={handleLandingPageEnabledChange}
100 onUDPSettingsChange={handleUDPSettingsChange}
101 onApproveStatusChange={handleApproveStatus}
102 onDenyStatusChange={handleDenyStatus}
frontend/src/pages/ServerList.tsx
+22
@@ -2,6 +2,26 @@ import { SsgoiTransition } from "@ssgoi/react";
2 import { useServerList } from "@/hooks/useServerList";
3 import { ServerListView } from "@/components/ServerListView";
4
5 +const LANDING_PAGE_ENABLED_META_NAME = "portal-landing-page-enabled";
6 +
7 +function readLandingPageEnabled(doc?: Document): boolean {
8 + const targetDoc =
9 + doc ?? (typeof document !== "undefined" ? document : undefined);
10 + if (!targetDoc) {
11 + return true;
12 + }
13 +
14 + const value =
15 + targetDoc
16 + .querySelector<HTMLMetaElement>(
17 + `meta[name="${LANDING_PAGE_ENABLED_META_NAME}"]`
18 + )
19 + ?.content.trim()
20 + .toLowerCase() || "";
21 +
22 + return value !== "false" && value !== "0" && value !== "no";
23 +}
24 +
25 export function ServerList() {
26 // Controller: useServerList hook handles all server list logic
27 const {
@@ -18,10 +38,12 @@ export function ServerList() {
38 handleTagToggle,
39 handleToggleFavorite,
40 } = useServerList();
41 + const landingPageEnabled = readLandingPageEnabled();
42
43 return (
44 <SsgoiTransition id="/">
45 <ServerListView
46 + landingPageEnabled={landingPageEnabled}
47 searchQuery={searchQuery}
48 status={status}
49 sortBy={sortBy}
portal/server.go
+7
@@ -298,6 +298,13 @@ func (s *Server) PortalURL() string {
298 return s.cfg.PortalURL
299 }
300
301 +func (s *Server) OwnerAddress() string {
302 + if s == nil {
303 + return ""
304 + }
305 + return s.ownerIdentity.Address
306 +}
307 +
308 func (s *Server) RootHost() string {
309 if s == nil {
310 return ""
types/api.go
+12 -3
@@ -139,9 +139,10 @@ type AdminAuthStatusResponse struct {
139 }
140
141 type AdminSnapshotResponse struct {
142 - ApprovalMode string `json:"approval_mode"`
143 - Leases []Lease `json:"leases,omitempty"`
144 - UDP AdminUDPSettingsResponse `json:"udp"`
142 + ApprovalMode string `json:"approval_mode"`
143 + LandingPageEnabled bool `json:"landing_page_enabled"`
144 + Leases []Lease `json:"leases,omitempty"`
145 + UDP AdminUDPSettingsResponse `json:"udp"`
146 }
147
148 type AdminApprovalModeRequest struct {
@@ -152,6 +153,14 @@ type AdminApprovalModeResponse struct {
153 ApprovalMode string `json:"approval_mode"`
154 }
155
156 +type AdminLandingPageSettingsRequest struct {
157 + Enabled bool `json:"enabled"`
158 +}
159 +
160 +type AdminLandingPageSettingsResponse struct {
161 + Enabled bool `json:"enabled"`
162 +}
163 +
164 type AdminBPSRequest struct {
165 BPS int64 `json:"bps"`
166 }
types/paths.go
+1
@@ -16,6 +16,7 @@ const (
16 PathAdminLogout = "/admin/logout"
17 PathAdminAuthStatus = "/admin/auth/status"
18 PathAdminApproval = "/admin/settings/approval-mode"
19 + PathAdminLandingPage = "/admin/settings/landing-page"
20 PathAdminUDP = "/admin/settings/udp"
21 PathAdminIPsPrefix = "/admin/ips/"
22 PathInstallShell = "/install.sh"