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"