feat: Refactor admin and frontend integration for improved asset serving and SSR
Kim committed
Dec 12, 2025 at 18:24 UTC
54c2915150f5829504541a1edb3852f6ce12b1c3
4 files changed
+266
-259
cmd/relay-server/admin.go
+23
-13
@@ -26,9 +26,11 @@ type Admin struct {
26
approveManager *manager.ApproveManager
27
bpsManager *manager.BPSManager
28
ipManager *manager.IPManager
29
+
30
+ frontend *Frontend
31
}
32
31
-func NewAdmin(defaultLeaseBPS int64) *Admin {
33
+func NewAdmin(defaultLeaseBPS int64, frontend *Frontend) *Admin {
34
bpsManager := manager.NewBPSManager()
35
if defaultLeaseBPS > 0 {
36
bpsManager.SetDefaultBPS(defaultLeaseBPS)
@@ -38,6 +40,7 @@ func NewAdmin(defaultLeaseBPS int64) *Admin {
40
approveManager: manager.NewApproveManager(),
41
bpsManager: bpsManager,
42
ipManager: manager.NewIPManager(),
43
+ frontend: frontend,
44
}
45
}
46
@@ -194,7 +197,7 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
197
198
switch {
199
case route == "":
197
- serveAppStatic(w, r, "", serv, a)
200
+ a.frontend.ServeAppStatic(w, r, "", serv)
201
case route == "leases" && r.Method == http.MethodGet:
202
writeJSON(w, a.convertLeaseEntriesToAdminRows(serv))
203
case route == "leases/banned" && r.Method == http.MethodGet:
@@ -435,17 +438,6 @@ func (a *Admin) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv
438
}
439
}
440
438
-func decodeLeaseID(encoded string) (string, bool) {
439
- idBytes, err := base64.URLEncoding.DecodeString(encoded)
440
- if err != nil {
441
- idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
442
- if err != nil {
443
- return "", false
444
- }
445
- }
446
- return string(idBytes), true
447
-}
448
-
441
// convertLeaseEntriesToAdminRows converts LeaseEntry data to leaseRow format for admin API
442
func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
443
leaseEntries := serv.GetAllLeaseEntries()
@@ -560,3 +552,21 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
552
553
return rows
554
}
555
+
556
+func writeJSON(w http.ResponseWriter, v any) {
557
+ w.Header().Set("Content-Type", "application/json")
558
+ if err := json.NewEncoder(w).Encode(v); err != nil {
559
+ log.Error().Err(err).Msg("[HTTP] Failed to encode response")
560
+ }
561
+}
562
+
563
+func decodeLeaseID(encoded string) (string, bool) {
564
+ idBytes, err := base64.URLEncoding.DecodeString(encoded)
565
+ if err != nil {
566
+ idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
567
+ if err != nil {
568
+ return "", false
569
+ }
570
+ }
571
+ return string(idBytes), true
572
+}
cmd/relay-server/main.go
+5
-3
@@ -84,8 +84,10 @@ func runServer() error {
84
serv.SetMaxRelayedPerLease(flagMaxLease)
85
}
86
87
- // Create Admin instance for approvals/settings (also initializes managers)
88
- admin := NewAdmin(int64(flagLeaseBPS))
87
+ // Create Frontend first, then Admin, then attach Admin back to Frontend.
88
+ frontend := NewFrontend()
89
+ admin := NewAdmin(int64(flagLeaseBPS), frontend)
90
+ frontend.SetAdmin(admin)
91
92
// Load persisted admin settings (ban list, BPS limits, IP bans)
93
admin.LoadSettings(serv)
@@ -106,7 +108,7 @@ func runServer() error {
108
serv.Start()
109
defer serv.Stop()
110
109
- httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, admin, flagNoIndex, stop)
111
+ httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, admin, frontend, flagNoIndex, stop)
112
113
<-ctx.Done()
114
log.Info().Msg("[server] shutting down...")
cmd/relay-server/serve.go
+224
-67
@@ -2,34 +2,61 @@ package main
2
3
import (
4
"encoding/json"
5
+ "fmt"
6
+ "io/fs"
7
"net/http"
8
"path"
9
"strconv"
10
"strings"
11
"sync"
12
+ "time"
13
14
"github.com/rs/zerolog/log"
15
+ "gosuda.org/portal/cmd/relay-server/manager"
16
"gosuda.org/portal/portal"
17
+ "gosuda.org/portal/sdk"
18
"gosuda.org/portal/utils"
19
)
20
16
-// Cached portal.html template for efficient SSR
17
-var (
21
+type readDirFileFS interface {
22
+ fs.ReadFileFS
23
+ fs.ReadDirFS
24
+}
25
+
26
+// Frontend handles serving embedded frontend assets and SSR.
27
+type Frontend struct {
28
+ distFS readDirFileFS
29
+ admin *Admin
30
+
31
cachedPortalHTML []byte
32
cachedPortalHTMLOnce sync.Once
20
-)
33
22
-func initPortalHTMLCache() error {
34
+ wasmCache map[string]*wasmCacheEntry
35
+ wasmCacheMu sync.RWMutex
36
+}
37
+
38
+func NewFrontend() *Frontend {
39
+ return &Frontend{
40
+ distFS: distFS,
41
+ wasmCache: make(map[string]*wasmCacheEntry),
42
+ }
43
+}
44
+
45
+// SetAdmin attaches an Admin instance. Frontend methods tolerate nil admin.
46
+func (f *Frontend) SetAdmin(admin *Admin) {
47
+ f.admin = admin
48
+}
49
+
50
+func (f *Frontend) initPortalHTMLCache() error {
51
var err error
24
- cachedPortalHTML, err = distFS.ReadFile("dist/app/portal.html")
52
+ f.cachedPortalHTML, err = f.distFS.ReadFile("dist/app/portal.html")
53
return err
54
}
55
28
-func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
56
+func (f *Frontend) ServeAsset(mux *http.ServeMux, route, assetPath, contentType string) {
57
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
30
- // Read from dist/app subdirectory of the embedded FS
58
fullPath := path.Join("dist", "app", assetPath)
32
- b, err := distFS.ReadFile(fullPath)
59
+ b, err := f.distFS.ReadFile(fullPath)
60
if err != nil {
61
http.NotFound(w, r)
62
return
@@ -42,24 +69,24 @@ func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
69
})
70
}
71
45
-// servePortalHTMLWithSSR serves portal.html with SSR data injection
46
-func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, admin *Admin) {
72
+// servePortalHTMLWithSSR serves portal.html with SSR data injection.
73
+func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
74
utils.SetCORSHeaders(w)
75
76
// Initialize cache on first use
50
- cachedPortalHTMLOnce.Do(func() {
51
- if err := initPortalHTMLCache(); err != nil {
77
+ f.cachedPortalHTMLOnce.Do(func() {
78
+ if err := f.initPortalHTMLCache(); err != nil {
79
log.Error().Err(err).Msg("Failed to cache portal.html")
80
}
81
})
82
56
- if cachedPortalHTML == nil {
83
+ if f.cachedPortalHTML == nil {
84
http.NotFound(w, r)
85
return
86
}
87
88
// Inject SSR data into cached template
62
- injectedHTML := injectServerData(string(cachedPortalHTML), serv, admin)
89
+ injectedHTML := f.injectServerData(string(f.cachedPortalHTML), serv)
90
91
// Set headers
92
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -73,9 +100,12 @@ func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal
100
}
101
102
// injectServerData injects server data into HTML for SSR
76
-func injectServerData(htmlContent string, serv *portal.RelayServer, admin *Admin) string {
103
+func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer) string {
104
// Get server data from lease manager
78
- rows := convertLeaseEntriesToRows(serv, admin)
105
+ rows := []leaseRow{}
106
+ if f.admin != nil {
107
+ rows = convertLeaseEntriesToRows(serv, f.admin)
108
+ }
109
110
// Marshal to JSON
111
jsonData, err := json.Marshal(rows)
@@ -98,25 +128,158 @@ func injectServerData(htmlContent string, serv *portal.RelayServer, admin *Admin
128
return injected
129
}
130
101
-// servePortalStaticFile serves static files for portal frontend with caching
102
-func servePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
131
+// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page.
132
+func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRow {
133
+ leaseEntries := serv.GetAllLeaseEntries()
134
+ rows := []leaseRow{}
135
+ now := time.Now()
136
+
137
+ bannedList := serv.GetLeaseManager().GetBannedLeases()
138
+ bannedMap := make(map[string]struct{}, len(bannedList))
139
+ for _, b := range bannedList {
140
+ bannedMap[string(b)] = struct{}{}
141
+ }
142
+
143
+ for _, leaseEntry := range leaseEntries {
144
+ if now.After(leaseEntry.Expires) {
145
+ continue
146
+ }
147
+
148
+ lease := leaseEntry.Lease
149
+ identityID := string(lease.Identity.Id)
150
+
151
+ var metadata sdk.Metadata
152
+ _ = json.Unmarshal([]byte(lease.Metadata), &metadata)
153
+
154
+ if _, banned := bannedMap[identityID]; banned {
155
+ continue
156
+ }
157
+
158
+ if admin != nil {
159
+ approveManager := admin.GetApproveManager()
160
+ if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
161
+ continue
162
+ }
163
+ }
164
+
165
+ if metadata.Hide {
166
+ continue
167
+ }
168
+
169
+ ttl := time.Until(leaseEntry.Expires)
170
+ ttlStr := ""
171
+ if ttl > 0 {
172
+ if ttl > time.Hour {
173
+ ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
174
+ } else if ttl > time.Minute {
175
+ ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
176
+ } else {
177
+ ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
178
+ }
179
+ }
180
+
181
+ since := now.Sub(leaseEntry.LastSeen)
182
+ if since < 0 {
183
+ since = 0
184
+ }
185
+ lastSeenStr := func(d time.Duration) string {
186
+ if d >= time.Hour {
187
+ h := int(d / time.Hour)
188
+ m := int((d % time.Hour) / time.Minute)
189
+ if m > 0 {
190
+ return fmt.Sprintf("%dh %dm", h, m)
191
+ }
192
+ return fmt.Sprintf("%dh", h)
193
+ }
194
+ if d >= time.Minute {
195
+ m := int(d / time.Minute)
196
+ s := int((d % time.Minute) / time.Second)
197
+ if s > 0 {
198
+ return fmt.Sprintf("%dm %ds", m, s)
199
+ }
200
+ return fmt.Sprintf("%dm", m)
201
+ }
202
+ return fmt.Sprintf("%ds", int(d/time.Second))
203
+ }(since)
204
+ lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
205
+ firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
206
+
207
+ connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
208
+
209
+ if !connected && since >= 3*time.Minute {
210
+ continue
211
+ }
212
+
213
+ name := lease.Name
214
+ if name == "" {
215
+ name = "(unnamed)"
216
+ }
217
+
218
+ kind := "client"
219
+ if len(lease.Alpn) > 0 {
220
+ kind = lease.Alpn[0]
221
+ }
222
+
223
+ dnsLabel := identityID
224
+ if len(dnsLabel) > 8 {
225
+ dnsLabel = dnsLabel[:8] + "..."
226
+ }
227
+
228
+ base := flagPortalAppURL
229
+ if base == "" {
230
+ base = flagPortalURL
231
+ }
232
+ link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
233
+
234
+ var bps int64
235
+ if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
236
+ bps = bpsMgr.GetBPSLimit(identityID)
237
+ }
238
+
239
+ row := leaseRow{
240
+ Peer: identityID,
241
+ Name: name,
242
+ Kind: kind,
243
+ Connected: connected,
244
+ DNS: dnsLabel,
245
+ LastSeen: lastSeenStr,
246
+ LastSeenISO: lastSeenISO,
247
+ FirstSeenISO: firstSeenISO,
248
+ TTL: ttlStr,
249
+ Link: link,
250
+ StaleRed: !connected && since >= 15*time.Second,
251
+ Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
252
+ Metadata: lease.Metadata,
253
+ BPS: bps,
254
+ }
255
+
256
+ if !metadata.Hide {
257
+ rows = append(rows, row)
258
+ }
259
+ }
260
+
261
+ return rows
262
+}
263
+
264
+// ServePortalStaticFile serves static files for portal frontend with caching.
265
+func (f *Frontend) ServePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
266
// Check if this is a content-addressed WASM file
267
if strings.HasSuffix(filePath, ".wasm") {
268
hash := strings.TrimSuffix(filePath, ".wasm")
269
if utils.IsHexString(hash) {
107
- serveCompressedWasm(w, r, filePath)
270
+ f.serveCompressedWasm(w, r, filePath)
271
return
272
}
273
}
274
275
// Regular static file serving
276
w.Header().Set("Cache-Control", "public, max-age=3600")
114
- serveStaticFile(w, r, filePath, "")
277
+ f.ServeStaticFile(w, r, filePath, "")
278
}
279
117
-// serveAppStatic serves static files for app UI (React app) from embedded FS
118
-// Falls back to portal.html with SSR when path is root or file not found
119
-func serveAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer, admin *Admin) {
280
+// ServeAppStatic serves static files for app UI (React app) from embedded FS.
281
+// Falls back to portal.html with SSR when path is root or file not found.
282
+func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer) {
283
// Prevent directory traversal
284
if strings.Contains(appPath, "..") {
285
http.Error(w, "Invalid path", http.StatusBadRequest)
@@ -127,17 +290,17 @@ func serveAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv
290
291
// If path is empty or "/", serve portal.html with SSR
292
if appPath == "" || appPath == "/" {
130
- servePortalHTMLWithSSR(w, r, serv, admin)
293
+ f.servePortalHTMLWithSSR(w, r, serv)
294
return
295
}
296
297
// Try to read from embedded FS
298
fullPath := path.Join("dist", "app", appPath)
136
- data, err := distFS.ReadFile(fullPath)
299
+ data, err := f.distFS.ReadFile(fullPath)
300
if err != nil {
301
// File not found - fallback to portal.html with SSR for SPA routing
302
log.Debug().Err(err).Str("path", appPath).Msg("app static file not found, falling back to SSR")
140
- servePortalHTMLWithSSR(w, r, serv, admin)
303
+ f.servePortalHTMLWithSSR(w, r, serv)
304
return
305
}
306
@@ -158,21 +321,15 @@ func serveAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv
321
Msg("served app static file")
322
}
323
161
-// wasmCache stores pre-loaded WASM files in memory (optional)
324
type wasmCacheEntry struct {
325
brotli []byte
326
hash string
327
}
328
167
-var (
168
- wasmCache = make(map[string]*wasmCacheEntry)
169
- wasmCacheMu sync.RWMutex
170
-)
171
-
329
// initWasmCache loads pre-built WASM artifacts (precompressed) into memory on startup.
173
-func initWasmCache() error {
330
+func (f *Frontend) InitWasmCache() error {
331
// Read all files in embedded dist/wasm directory
175
- entries, err := distFS.ReadDir("dist/wasm")
332
+ entries, err := f.distFS.ReadDir("dist/wasm")
333
if err != nil {
334
return err
335
}
@@ -191,7 +348,7 @@ func initWasmCache() error {
348
// Cache under the URL path (<hash>.wasm) while reading the
349
// brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
350
cacheKey := hash + ".wasm"
194
- if err := cacheWasmFile(cacheKey, fullPath); err != nil {
351
+ if err := f.cacheWasmFile(cacheKey, fullPath); err != nil {
352
log.Warn().Err(err).Str("file", name).Msg("failed to cache WASM file")
353
} else {
354
log.Info().Str("file", cacheKey).Msg("cached WASM file")
@@ -204,7 +361,7 @@ func initWasmCache() error {
361
}
362
363
// cacheWasmFile reads and caches a WASM file and its pre-compressed variant (brotli).
207
-func cacheWasmFile(name, fullPath string) error {
364
+func (f *Frontend) cacheWasmFile(name, fullPath string) error {
365
// Verify name looks like a hex hash (name is <hash>.wasm).
366
hashHex := strings.TrimSuffix(name, ".wasm")
367
if !utils.IsHexString(hashHex) {
@@ -213,7 +370,7 @@ func cacheWasmFile(name, fullPath string) error {
370
371
// Load precompressed variant (brotli) from embed.FS (<hash>.wasm.br)
372
var brData []byte
216
- data, err := distFS.ReadFile(fullPath)
373
+ data, err := f.distFS.ReadFile(fullPath)
374
if err != nil {
375
log.Warn().Err(err).Str("file", fullPath).Msg("failed to read brotli-compressed WASM")
376
} else {
@@ -225,9 +382,9 @@ func cacheWasmFile(name, fullPath string) error {
382
hash: hashHex,
383
}
384
228
- wasmCacheMu.Lock()
229
- wasmCache[name] = entry
230
- wasmCacheMu.Unlock()
385
+ f.wasmCacheMu.Lock()
386
+ f.wasmCache[name] = entry
387
+ f.wasmCacheMu.Unlock()
388
389
log.Debug().
390
Str("file", name).
@@ -238,16 +395,16 @@ func cacheWasmFile(name, fullPath string) error {
395
}
396
397
// serveCompressedWasm serves pre-compressed WASM files from memory cache
241
-func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
242
- wasmCacheMu.RLock()
243
- entry, ok := wasmCache[filePath]
244
- wasmCacheMu.RUnlock()
398
+func (f *Frontend) serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
399
+ f.wasmCacheMu.RLock()
400
+ entry, ok := f.wasmCache[filePath]
401
+ f.wasmCacheMu.RUnlock()
402
403
if !ok {
404
log.Debug().Str("path", filePath).Msg("WASM file not in cache")
405
// Fallback: try to serve uncompressed WASM from embedded FS
406
fullPath := path.Join("dist", "wasm", filePath)
250
- data, err := distFS.ReadFile(fullPath)
407
+ data, err := f.distFS.ReadFile(fullPath)
408
if err != nil {
409
log.Debug().Err(err).Str("path", fullPath).Msg("WASM file not found in embedded FS")
410
http.NotFound(w, r)
@@ -297,9 +454,9 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string
454
Msg("served compressed WASM")
455
}
456
300
-// servePortalStatic serves static files for portal frontend with appropriate cache headers
301
-// Falls back to portal.html for SPA routing (404 -> portal.html)
302
-func servePortalStatic(w http.ResponseWriter, r *http.Request) {
457
+// ServePortalStatic serves static files for portal frontend with appropriate cache headers.
458
+// Falls back to portal.html for SPA routing (404 -> portal.html).
459
+func (f *Frontend) ServePortalStatic(w http.ResponseWriter, r *http.Request) {
460
staticPath := strings.TrimPrefix(r.URL.Path, "/")
461
462
// Prevent directory traversal
@@ -312,37 +469,37 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
469
switch staticPath {
470
case "manifest.json":
471
// Serve dynamic manifest regardless of static presence
315
- serveDynamicManifest(w, r)
472
+ f.ServeDynamicManifest(w, r)
473
return
474
475
case "service-worker.js":
319
- serveDynamicServiceWorker(w, r)
476
+ f.ServeDynamicServiceWorker(w, r)
477
return
478
479
case "wasm_exec.js":
480
w.Header().Set("Cache-Control", "public, max-age=86400")
481
w.Header().Set("Content-Type", "application/javascript")
325
- serveStaticFileWithFallback(w, r, staticPath, "application/javascript")
482
+ f.serveStaticFileWithFallback(w, r, staticPath, "application/javascript")
483
return
484
485
case "portal.mp4":
486
w.Header().Set("Cache-Control", "public, max-age=604800")
487
w.Header().Set("Content-Type", "video/mp4")
331
- serveStaticFileWithFallback(w, r, staticPath, "video/mp4")
488
+ f.serveStaticFileWithFallback(w, r, staticPath, "video/mp4")
489
return
490
}
491
492
// Default caching for other files
493
w.Header().Set("Cache-Control", "public, max-age=3600")
337
- serveStaticFileWithFallback(w, r, staticPath, "")
494
+ f.serveStaticFileWithFallback(w, r, staticPath, "")
495
}
496
340
-// serveStaticFile reads and serves a file from the static directory
341
-func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
497
+// ServeStaticFile reads and serves a file from the static directory.
498
+func (f *Frontend) ServeStaticFile(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
499
utils.SetCORSHeaders(w)
500
501
fullPath := path.Join("dist", "wasm", filePath)
345
- data, err := distFS.ReadFile(fullPath)
502
+ data, err := f.distFS.ReadFile(fullPath)
503
if err != nil {
504
log.Debug().Err(err).Str("path", filePath).Msg("static file not found")
505
http.NotFound(w, r)
@@ -371,16 +528,16 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath string, co
528
529
// serveStaticFileWithFallback reads and serves a file from the static directory
530
// If the file is not found, it falls back to portal.html for SPA routing
374
-func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
531
+func (f *Frontend) serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
532
utils.SetCORSHeaders(w)
533
534
fullPath := path.Join("dist", "wasm", filePath)
378
- data, err := distFS.ReadFile(fullPath)
535
+ data, err := f.distFS.ReadFile(fullPath)
536
if err != nil {
537
// File not found - fallback to portal.html for SPA routing
538
log.Debug().Err(err).Str("path", filePath).Msg("static file not found, serving portal.html")
539
w.Header().Set("Content-Type", "text/html; charset=utf-8")
383
- serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
540
+ f.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
541
return
542
}
543
@@ -405,23 +562,23 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePat
562
}
563
564
// serveDynamicManifest generates and serves manifest.json dynamically
408
-func serveDynamicManifest(w http.ResponseWriter, _ *http.Request) {
565
+func (f *Frontend) ServeDynamicManifest(w http.ResponseWriter, _ *http.Request) {
566
utils.SetCORSHeaders(w)
567
568
// Find the content-addressed WASM file
412
- wasmCacheMu.RLock()
569
+ f.wasmCacheMu.RLock()
570
var wasmHash string
571
var wasmFile string
415
- for filename, entry := range wasmCache {
572
+ for filename, entry := range f.wasmCache {
573
wasmHash = entry.hash
574
wasmFile = filename
575
break // Use the first (and should be only) WASM file
576
}
420
- wasmCacheMu.RUnlock()
577
+ f.wasmCacheMu.RUnlock()
578
579
// Fallback: scan embedded WASM directory if cache is empty
580
if wasmHash == "" {
424
- entries, err := distFS.ReadDir("dist/wasm")
581
+ entries, err := f.distFS.ReadDir("dist/wasm")
582
if err == nil {
583
for _, entry := range entries {
584
if entry.IsDir() {
@@ -472,13 +629,13 @@ func serveDynamicManifest(w http.ResponseWriter, _ *http.Request) {
629
Msg("Served dynamic manifest")
630
}
631
475
-// serveDynamicServiceWorker serves service-worker.js with injected manifest and config
476
-func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
632
+// ServeDynamicServiceWorker serves service-worker.js with injected manifest and config.
633
+func (f *Frontend) ServeDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
634
utils.SetCORSHeaders(w)
635
636
// Read the service-worker.js template
637
fullPath := path.Join("dist", "wasm", "service-worker.js")
481
- content, err := distFS.ReadFile(fullPath)
638
+ content, err := f.distFS.ReadFile(fullPath)
639
if err != nil {
640
log.Error().Err(err).Msg("Failed to read service-worker.js")
641
http.NotFound(w, r)
cmd/relay-server/view.go
+14
-176
@@ -3,17 +3,13 @@ package main
3
import (
4
"context"
5
"embed"
6
- "encoding/json"
7
- "fmt"
6
"net/http"
7
"strings"
10
- "time"
8
9
"github.com/rs/zerolog/log"
10
11
"gosuda.org/portal/cmd/relay-server/manager"
12
"gosuda.org/portal/portal"
16
- "gosuda.org/portal/sdk"
13
"gosuda.org/portal/utils"
14
)
15
@@ -21,13 +17,13 @@ import (
17
var distFS embed.FS
18
19
// serveHTTP builds the HTTP mux and returns the server.
24
-func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool, cancel context.CancelFunc) *http.Server {
20
+func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
21
if addr == "" {
22
addr = ":0"
23
}
24
25
// Initialize WASM cache used by content handlers
30
- if err := initWasmCache(); err != nil {
26
+ if err := frontend.InitWasmCache(); err != nil {
27
log.Error().Err(err).Msg("failed to initialize WASM cache")
28
}
29
@@ -35,9 +31,9 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
31
appMux := http.NewServeMux()
32
33
// Serve favicons (ico/png/svg) from dist/app
38
- serveAsset(appMux, "/favicon.ico", "favicon.ico", "image/x-icon")
39
- serveAsset(appMux, "/favicon.png", "favicon.png", "image/png")
40
- serveAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
34
+ frontend.ServeAsset(appMux, "/favicon.ico", "favicon.ico", "image/x-icon")
35
+ frontend.ServeAsset(appMux, "/favicon.png", "favicon.png", "image/png")
36
+ frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
37
38
if noIndex {
39
appMux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
@@ -55,7 +51,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
51
return
52
}
53
p := strings.TrimPrefix(r.URL.Path, "/app/")
58
- serveAppStatic(w, r, p, serv, admin)
54
+ frontend.ServeAppStatic(w, r, p, serv)
55
})
56
57
// Portal frontend files (for unified caching)
@@ -67,11 +63,11 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
63
}
64
p := strings.TrimPrefix(r.URL.Path, "/frontend/")
65
if p == "manifest.json" {
70
- serveDynamicManifest(w, r)
66
+ frontend.ServeDynamicManifest(w, r)
67
return
68
}
69
74
- servePortalStaticFile(w, r, p)
70
+ frontend.ServePortalStaticFile(w, r, p)
71
})
72
73
// Tunnel installer script and binaries
@@ -120,7 +116,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
116
appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
117
// serveAppStatic handles both "/" and 404 fallback with SSR
118
p := strings.TrimPrefix(r.URL.Path, "/")
123
- serveAppStatic(w, r, p, serv, admin)
119
+ frontend.ServeAppStatic(w, r, p, serv)
120
})
121
122
appMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
@@ -145,15 +141,15 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
141
}
142
p := strings.TrimPrefix(r.URL.Path, "/frontend/")
143
if p == "manifest.json" {
148
- serveDynamicManifest(w, r)
144
+ frontend.ServeDynamicManifest(w, r)
145
return
146
}
151
- servePortalStaticFile(w, r, p)
147
+ frontend.ServePortalStaticFile(w, r, p)
148
})
149
150
// Service worker for portal subdomains (serve from dist/wasm)
151
portalMux.HandleFunc("/service-worker.js", func(w http.ResponseWriter, r *http.Request) {
156
- serveDynamicServiceWorker(w, r)
152
+ frontend.ServeDynamicServiceWorker(w, r)
153
})
154
155
// Root and SPA fallback for portal subdomains
@@ -165,10 +161,10 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, noIndex bool
161
}
162
if r.URL.Path == "/" {
163
// Serve portal HTML from dist/wasm
168
- serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
164
+ frontend.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
165
return
166
}
171
- servePortalStatic(w, r)
167
+ frontend.ServePortalStatic(w, r)
168
})
169
170
// routes based on host and path
@@ -218,161 +214,3 @@ type leaseRow struct {
214
IP string // client IP address (for IP-based ban)
215
IsIPBanned bool // whether the IP is banned
216
}
221
-
222
-// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page
223
-func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRow {
224
- // Get all lease entries directly from the lease manager
225
- leaseEntries := serv.GetAllLeaseEntries()
226
-
227
- // Initialize with empty slice instead of nil to avoid "null" in JSON
228
- rows := []leaseRow{}
229
- now := time.Now()
230
-
231
- // Build banned map once for O(1) lookup per lease
232
- bannedList := serv.GetLeaseManager().GetBannedLeases()
233
- bannedMap := make(map[string]struct{}, len(bannedList))
234
- for _, b := range bannedList {
235
- bannedMap[string(b)] = struct{}{}
236
- }
237
-
238
- for _, leaseEntry := range leaseEntries {
239
- // Check if lease is still valid
240
- if now.After(leaseEntry.Expires) {
241
- continue
242
- }
243
-
244
- lease := leaseEntry.Lease
245
- identityID := string(lease.Identity.Id)
246
-
247
- var metadata sdk.Metadata
248
- json.Unmarshal([]byte(lease.Metadata), &metadata)
249
-
250
- // Skip banned leases for user-facing list
251
- if _, banned := bannedMap[identityID]; banned {
252
- continue
253
- }
254
-
255
- // Skip unapproved leases in manual mode for user-facing list
256
- approveManager := admin.GetApproveManager()
257
- if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
258
- continue
259
- }
260
-
261
- // Check hidden status
262
- if metadata.Hide {
263
- continue
264
- }
265
-
266
- // Calculate TTL
267
- ttl := time.Until(leaseEntry.Expires)
268
- ttlStr := ""
269
- if ttl > 0 {
270
- if ttl > time.Hour {
271
- ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
272
- } else if ttl > time.Minute {
273
- ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
274
- } else {
275
- ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
276
- }
277
- }
278
-
279
- // Format last active as relative time (e.g., "1h 4m", "12m 5s", "8s")
280
- since := now.Sub(leaseEntry.LastSeen)
281
- if since < 0 {
282
- since = 0
283
- }
284
- lastSeenStr := func(d time.Duration) string {
285
- if d >= time.Hour {
286
- h := int(d / time.Hour)
287
- m := int((d % time.Hour) / time.Minute)
288
- if m > 0 {
289
- return fmt.Sprintf("%dh %dm", h, m)
290
- }
291
- return fmt.Sprintf("%dh", h)
292
- }
293
- if d >= time.Minute {
294
- m := int(d / time.Minute)
295
- s := int((d % time.Minute) / time.Second)
296
- if s > 0 {
297
- return fmt.Sprintf("%dm %ds", m, s)
298
- }
299
- return fmt.Sprintf("%dm", m)
300
- }
301
- s := int(d / time.Second)
302
- return fmt.Sprintf("%ds", s)
303
- }(since)
304
- lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
305
- firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
306
-
307
- // Check if connection is still active
308
- connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
309
-
310
- // Skip entries that have been disconnected for 3 minutes or more
311
- if !connected && since >= 3*time.Minute {
312
- continue
313
- }
314
-
315
- // Use name from lease if available
316
- name := lease.Name
317
- if name == "" {
318
- name = "(unnamed)"
319
- }
320
-
321
- // Determine kind/type based on ALPN if available
322
- kind := "client"
323
- if len(lease.Alpn) > 0 {
324
- kind = lease.Alpn[0]
325
- }
326
-
327
- // Create DNS label from identity (first 8 chars for display)
328
- dnsLabel := identityID
329
- if len(dnsLabel) > 8 {
330
- dnsLabel = dnsLabel[:8] + "..."
331
- }
332
-
333
- // Build link using the configured subdomain base
334
- base := flagPortalAppURL
335
- if base == "" {
336
- base = flagPortalURL
337
- }
338
- link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
339
-
340
- // Get BPS limit for this lease from BPSManager
341
- var bps int64
342
- bpsMgr := admin.GetBPSManager()
343
- if bpsMgr != nil {
344
- bps = bpsMgr.GetBPSLimit(identityID)
345
- }
346
-
347
- row := leaseRow{
348
- Peer: identityID,
349
- Name: name,
350
- Kind: kind,
351
- Connected: connected,
352
- DNS: dnsLabel,
353
- LastSeen: lastSeenStr,
354
- LastSeenISO: lastSeenISO,
355
- FirstSeenISO: firstSeenISO,
356
- TTL: ttlStr,
357
- Link: link,
358
- StaleRed: !connected && since >= 15*time.Second,
359
- Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
360
- Metadata: lease.Metadata,
361
- BPS: bps,
362
- }
363
-
364
- // Hidden entries are already filtered above, but keep check for safety
365
- if !metadata.Hide {
366
- rows = append(rows, row)
367
- }
368
- }
369
-
370
- return rows
371
-}
372
-
373
-func writeJSON(w http.ResponseWriter, v any) {
374
- w.Header().Set("Content-Type", "application/json")
375
- if err := json.NewEncoder(w).Encode(v); err != nil {
376
- log.Error().Err(err).Msg("[HTTP] Failed to encode response")
377
- }
378
-}