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 -}