feat: Add server-side rendering for Open Graph and Twitter card metadata, and update webclient font.

lemon-mint committed Dec 23, 2025 at 14:35 UTC 014e708b79fd5768599e7c22c9bf37bcfa3d3925
9 files changed +605 -403
Makefile
+1
@@ -59,6 +59,7 @@ build-wasm:
59 @cp cmd/webclient/service-worker.js cmd/relay-server/dist/wasm/service-worker.js
60 @cp cmd/webclient/index.html cmd/relay-server/dist/wasm/portal.html
61 @cp cmd/webclient/portal.mp4 cmd/relay-server/dist/wasm/portal.mp4
62 + @cp cmd/webclient/portal.jpg cmd/relay-server/dist/wasm/portal.jpg
63 @echo "[wasm] build complete"
64
65 @echo "[wasm] precompressing webclient WASM with brotli..."
cmd/relay-server/frontend.go
+88
@@ -3,6 +3,7 @@ package main
3 import (
4 "encoding/json"
5 "fmt"
6 + "html"
7 "io/fs"
8 "net/http"
9 "path"
@@ -88,6 +89,9 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
89 // Inject SSR data into cached template
90 injectedHTML := f.injectServerData(string(f.cachedPortalHTML), serv)
91
92 + // Inject OG metadata (defaults for main app)
93 + injectedHTML = f.injectOGMetadata(injectedHTML, "", "", "")
94 +
95 // Set headers
96 w.Header().Set("Content-Type", "text/html; charset=utf-8")
97 w.Header().Set("Cache-Control", "no-cache, must-revalidate")
@@ -99,6 +103,84 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
103 log.Debug().Msg("Served portal.html with SSR data")
104 }
105
106 +// ServePortalHTMLWithSSR serves portal.html for subdomain requests with SSR OG metadata.
107 +func (f *Frontend) ServePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
108 + utils.SetCORSHeaders(w)
109 +
110 + // Read portal.html from dist/wasm
111 + data, err := f.distFS.ReadFile("dist/wasm/portal.html")
112 + if err != nil {
113 + log.Error().Err(err).Msg("Failed to read dist/wasm/portal.html")
114 + http.NotFound(w, r)
115 + return
116 + }
117 +
118 + htmlContent := string(data)
119 + title := ""
120 + description := ""
121 + imageURL := ""
122 +
123 + // Extract lease name from host
124 + leaseName := ""
125 + h := strings.ToLower(utils.StripPort(utils.StripScheme(r.Host)))
126 + p := strings.ToLower(utils.StripPort(utils.StripScheme(flagPortalAppURL)))
127 + if strings.HasPrefix(p, "*.") {
128 + suffix := p[1:] // .example.com
129 + if strings.HasSuffix(h, suffix) {
130 + leaseName = h[:len(h)-len(suffix)]
131 + }
132 + }
133 +
134 + if leaseName != "" {
135 + if lease, ok := serv.GetLeaseByName(leaseName); ok {
136 + title = lease.Lease.Name
137 + if lease.ParsedMetadata != nil {
138 + description = lease.ParsedMetadata.Description
139 + imageURL = lease.ParsedMetadata.Thumbnail
140 + }
141 + }
142 + }
143 +
144 + // Inject OG metadata
145 + htmlContent = f.injectOGMetadata(htmlContent, title, description, imageURL)
146 +
147 + // Set headers
148 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
149 + w.Header().Set("Cache-Control", "no-cache, must-revalidate")
150 +
151 + // Send response
152 + w.WriteHeader(http.StatusOK)
153 + w.Write([]byte(htmlContent))
154 +
155 + log.Debug().Str("lease", leaseName).Msg("Served portal.html with subdomain SSR OG metadata")
156 +}
157 +
158 +// injectOGMetadata replaces OG placeholders with actual values.
159 +func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
160 + if title == "" {
161 + title = "Portal Proxy Gateway"
162 + }
163 + if description == "" {
164 + description = "Transform your local services into web-accessible endpoints. Instant access from anywhere."
165 + }
166 + if imageURL == "" {
167 + // Use absolute URL if possible
168 + base := strings.TrimSuffix(flagPortalURL, "/")
169 + if !strings.HasPrefix(base, "http") {
170 + base = "https://" + base
171 + }
172 + imageURL = base + "/portal.jpg"
173 + }
174 +
175 + replacer := strings.NewReplacer(
176 + "[%OG_TITLE%]", html.EscapeString(title),
177 + "[%OG_DESCRIPTION%]", html.EscapeString(description),
178 + "[%OG_IMAGE_URL%]", html.EscapeString(imageURL),
179 + )
180 +
181 + return replacer.Replace(htmlContent)
182 +}
183 +
184 // injectServerData injects server data into HTML for SSR
185 func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer) string {
186 // Get server data from lease manager
@@ -487,6 +569,12 @@ func (f *Frontend) ServePortalStatic(w http.ResponseWriter, r *http.Request) {
569 w.Header().Set("Content-Type", "video/mp4")
570 f.serveStaticFileWithFallback(w, r, staticPath, "video/mp4")
571 return
572 +
573 + case "portal.jpg":
574 + w.Header().Set("Cache-Control", "public, max-age=604800")
575 + w.Header().Set("Content-Type", "image/jpeg")
576 + f.serveStaticFileWithFallback(w, r, staticPath, "image/jpeg")
577 + return
578 }
579
580 // Default caching for other files
cmd/relay-server/frontend/index.html
+24 -17
@@ -1,19 +1,26 @@
1 -<!doctype html>
1 +<!DOCTYPE html>
2 <html lang="en" class="dark">
3 + <head>
4 + <meta charset="UTF-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 + <meta name="color-scheme" content="dark" />
7 + <meta
8 + name="description"
9 + content="Transform your local services into web-accessible endpoints. Instant access from anywhere."
10 + />
11 + <meta property="og:title" content="[%OG_TITLE%]" />
12 + <meta property="og:description" content="[%OG_DESCRIPTION%]" />
13 + <meta property="og:image" content="[%OG_IMAGE_URL%]" />
14 + <meta name="twitter:card" content="summary_large_image" />
15 + <meta name="twitter:title" content="[%OG_TITLE%]" />
16 + <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
17 + <meta name="twitter:image" content="[%OG_IMAGE_URL%]" />
18 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
19 + <title>Portal - Local to web. Instant access.</title>
20 + </head>
21
4 -<head>
5 - <meta charset="UTF-8" />
6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 - <meta name="color-scheme" content="dark" />
8 - <meta name="description"
9 - content="Transform your local services into web-accessible endpoints. Instant access from anywhere." />
10 - <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
11 - <title>Portal - Local to web. Instant access.</title>
12 -</head>
13 -
14 -<body class="dark">
15 - <div id="root"></div>
16 - <script type="module" src="/src/main.tsx"></script>
17 -</body>
18 -
19 -</html>
\ No newline at end of file
22 + <body class="dark">
23 + <div id="root"></div>
24 + <script type="module" src="/src/main.tsx"></script>
25 + </body>
26 +</html>
cmd/relay-server/frontend_test.go new
+70
@@ -0,0 +1,70 @@
1 +package main
2 +
3 +import (
4 + "strings"
5 + "testing"
6 +)
7 +
8 +func TestInjectOGMetadata(t *testing.T) {
9 + f := &Frontend{}
10 +
11 + // Set global flag for testing
12 + flagPortalURL = "https://portal.example.com"
13 +
14 + tests := []struct {
15 + name string
16 + title string
17 + description string
18 + imageURL string
19 + html string
20 + want []string // strings that should be present in the output
21 + }{
22 + {
23 + name: "Basic injection",
24 + title: "Hello World",
25 + description: "This is a test description",
26 + imageURL: "https://example.com/image.png",
27 + html: "<title>[%OG_TITLE%]</title><meta name=\"description\" content=\"[%OG_DESCRIPTION%]\"><meta property=\"og:image\" content=\"[%OG_IMAGE_URL%]\">",
28 + want: []string{
29 + "<title>Hello World</title>",
30 + "content=\"This is a test description\"",
31 + "content=\"https://example.com/image.png\"",
32 + },
33 + },
34 + {
35 + name: "HTML Escaping",
36 + title: "<script>alert('xss')</script>",
37 + description: "Double \"quotes\" and <tags>",
38 + imageURL: "https://example.com/img?q=1&b=2",
39 + html: "[%OG_TITLE%] | [%OG_DESCRIPTION%] | [%OG_IMAGE_URL%]",
40 + want: []string{
41 + "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
42 + "Double &#34;quotes&#34; and &lt;tags&gt;",
43 + "https://example.com/img?q=1&amp;b=2",
44 + },
45 + },
46 + {
47 + name: "Empty values (Defaults)",
48 + title: "",
49 + description: "",
50 + imageURL: "",
51 + html: "[%OG_TITLE%] | [%OG_DESCRIPTION%] | [%OG_IMAGE_URL%]",
52 + want: []string{
53 + "Portal Proxy Gateway",
54 + "Transform your local services into web-accessible endpoints",
55 + "https://portal.example.com/portal.jpg",
56 + },
57 + },
58 + }
59 +
60 + for _, tt := range tests {
61 + t.Run(tt.name, func(t *testing.T) {
62 + got := f.injectOGMetadata(tt.html, tt.title, tt.description, tt.imageURL)
63 + for _, w := range tt.want {
64 + if !strings.Contains(got, w) {
65 + t.Errorf("injectOGMetadata() = %v, want to contain %v", got, w)
66 + }
67 + }
68 + })
69 + }
70 +}
cmd/relay-server/serve.go
+2 -2
@@ -140,8 +140,8 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fr
140 // Root and SPA fallback for portal subdomains
141 portalMux.HandleFunc("/", withCORSMiddleware(func(w http.ResponseWriter, r *http.Request) {
142 if r.URL.Path == "/" {
143 - // Serve portal HTML from dist/wasm
144 - frontend.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
143 + // Serve portal HTML with SSR for OG metadata
144 + frontend.ServePortalHTMLWithSSR(w, r, serv)
145 return
146 }
147 frontend.ServePortalStatic(w, r)
cmd/webclient/index.html
+390 -384
@@ -1,428 +1,434 @@
1 <!DOCTYPE html>
2 <html>
3 -
4 -<head>
3 + <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Portal Proxy Gateway</title>
8 - <link rel="stylesheet" as="style" crossorigin
9 - href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
10 - <style>
11 - body {
12 - margin: 0;
13 - padding: 0;
14 - font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI",
15 - Roboto, sans-serif;
16 - background: #000;
17 - }
18 -
19 - #loading-screen {
20 - position: fixed;
21 - top: 0;
22 - left: 0;
23 - width: 100%;
24 - height: 100%;
25 - background: #000;
26 - display: flex;
27 - flex-direction: column;
28 - justify-content: center;
29 - align-items: center;
30 - z-index: 9999;
31 - transition: opacity 0.5s ease-out;
32 - }
33 -
34 - #loading-screen.hide {
35 - opacity: 0;
36 - pointer-events: none;
37 - }
38 -
39 - #loading-logo {
40 - position: relative;
41 - max-width: 500px;
42 - width: 90%;
43 - margin-bottom: 40px;
44 - /* 컨테이너 쿼리를 위한 설정 */
45 - container-type: inline-size;
46 - }
47 -
48 - #loading-logo video {
49 - width: 100%;
50 - height: auto;
51 - display: block;
52 - }
53 -
54 - .logo-overlay {
55 - position: absolute;
56 - top: 0;
57 - left: 0;
58 - width: 100%;
59 - height: 100%;
60 - display: flex;
61 - flex-direction: column;
62 - justify-content: space-between;
63 - align-items: center;
64 - padding: 10% 1%;
65 - box-sizing: border-box;
66 - pointer-events: none;
67 - }
68 -
69 - .logo-title {
70 - font-family: "Pretendard", sans-serif;
71 - /* 51.5px at 500px container = 51.5/500*100 = 10.3cqw */
72 - font-size: 10.3cqw;
73 - font-weight: 600;
74 - color: #ffffff;
75 - letter-spacing: 0.2em;
76 - text-align: center;
77 - text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
78 - margin: 0;
79 - }
7 + <meta property="og:title" content="[%OG_TITLE%]" />
8 + <meta property="og:description" content="[%OG_DESCRIPTION%]" />
9 + <meta property="og:image" content="[%OG_IMAGE_URL%]" />
10 + <meta name="twitter:card" content="summary_large_image" />
11 + <meta name="twitter:title" content="[%OG_TITLE%]" />
12 + <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
13 + <meta name="twitter:image" content="[%OG_IMAGE_URL%]" />
14
81 - .logo-subtitle {
82 - font-family: "Pretendard", sans-serif;
83 - /* 18px at 500px container = 18/500*100 = 3.6cqw */
84 - font-size: 3.6cqw;
85 - font-weight: 600;
86 - color: #ffffff;
87 - letter-spacing: -0.05em;
88 - text-align: center;
89 - text-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
90 - margin: 0;
91 - }
92 -
93 - .loading-bar-container {
94 - width: 300px;
95 - max-width: 80%;
96 - height: 4px;
97 - background: rgba(255, 255, 255, 0.1);
98 - border-radius: 2px;
99 - overflow: hidden;
100 - position: relative;
101 - }
102 -
103 - .loading-bar {
104 - height: 100%;
105 - background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
106 - border-radius: 2px;
107 - animation: loading 1.5s ease-in-out infinite;
108 - }
109 -
110 - @keyframes loading {
111 - 0% {
112 - width: 0%;
113 - margin-left: 0%;
114 - }
115 -
116 - 50% {
117 - width: 50%;
118 - margin-left: 25%;
119 - }
120 -
121 - 100% {
122 - width: 0%;
123 - margin-left: 100%;
124 - }
15 + <style>
16 + body {
17 + margin: 0;
18 + padding: 0;
19 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
20 + sans-serif;
21 + background: #000;
22 + }
23 +
24 + #loading-screen {
25 + position: fixed;
26 + top: 0;
27 + left: 0;
28 + width: 100%;
29 + height: 100%;
30 + background: #000;
31 + display: flex;
32 + flex-direction: column;
33 + justify-content: center;
34 + align-items: center;
35 + z-index: 9999;
36 + transition: opacity 0.5s ease-out;
37 + }
38 +
39 + #loading-screen.hide {
40 + opacity: 0;
41 + pointer-events: none;
42 + }
43 +
44 + #loading-logo {
45 + position: relative;
46 + max-width: 500px;
47 + width: 90%;
48 + margin-bottom: 40px;
49 + /* 컨테이너 쿼리를 위한 설정 */
50 + container-type: inline-size;
51 + }
52 +
53 + #loading-logo video {
54 + width: 100%;
55 + height: auto;
56 + display: block;
57 + }
58 +
59 + .logo-overlay {
60 + position: absolute;
61 + top: 0;
62 + left: 0;
63 + width: 100%;
64 + height: 100%;
65 + display: flex;
66 + flex-direction: column;
67 + justify-content: space-between;
68 + align-items: center;
69 + padding: 10% 1%;
70 + box-sizing: border-box;
71 + pointer-events: none;
72 + }
73 +
74 + .logo-title {
75 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
76 + sans-serif;
77 + /* 51.5px at 500px container = 51.5/500*100 = 10.3cqw */
78 + font-size: 10.3cqw;
79 + font-weight: 600;
80 + color: #ffffff;
81 + letter-spacing: 0.2em;
82 + text-align: center;
83 + text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
84 + margin: 0;
85 + }
86 +
87 + .logo-subtitle {
88 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
89 + sans-serif;
90 + /* 18px at 500px container = 18/500*100 = 3.6cqw */
91 + font-size: 3.6cqw;
92 + font-weight: 600;
93 + color: #ffffff;
94 + letter-spacing: -0.05em;
95 + text-align: center;
96 + text-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
97 + margin: 0;
98 + }
99 +
100 + .loading-bar-container {
101 + width: 300px;
102 + max-width: 80%;
103 + height: 4px;
104 + background: rgba(255, 255, 255, 0.1);
105 + border-radius: 2px;
106 + overflow: hidden;
107 + position: relative;
108 + }
109 +
110 + .loading-bar {
111 + height: 100%;
112 + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
113 + border-radius: 2px;
114 + animation: loading 1.5s ease-in-out infinite;
115 + }
116 +
117 + @keyframes loading {
118 + 0% {
119 + width: 0%;
120 + margin-left: 0%;
121 }
122
127 - .loading-text {
128 - color: rgba(255, 255, 255, 0.7);
129 - margin-top: 20px;
130 - font-size: 14px;
131 - text-align: center;
132 - transition: color 0.3s ease;
123 + 50% {
124 + width: 50%;
125 + margin-left: 25%;
126 }
127
135 - .loading-text.error {
136 - color: #ef4444;
128 + 100% {
129 + width: 0%;
130 + margin-left: 100%;
131 }
132 + }
133 +
134 + .loading-text {
135 + color: rgba(255, 255, 255, 0.7);
136 + margin-top: 20px;
137 + font-size: 14px;
138 + text-align: center;
139 + transition: color 0.3s ease;
140 + }
141 +
142 + .loading-text.error {
143 + color: #ef4444;
144 + }
145 </style>
139 -</head>
146 + </head>
147
141 -<body>
148 + <body>
149 <!-- Loading Screen -->
150 <div id="loading-screen">
144 - <div id="loading-logo">
145 - <video autoplay loop muted playsinline>
146 - <source src="/portal.mp4" type="video/mp4" />
147 - </video>
148 - <div class="logo-overlay">
149 - <h1 class="logo-title">PORTAL</h1>
150 - <p class="logo-subtitle">LOCAL TO WEB. INSTANT ACCESS.</p>
151 - </div>
152 - </div>
153 - <div class="loading-bar-container" id="loading-bar-container">
154 - <div class="loading-bar"></div>
155 - </div>
156 - <div class="loading-text" id="loading-text">
157 - Initializing Portal Network...
151 + <div id="loading-logo">
152 + <video autoplay loop muted playsinline>
153 + <source src="/portal.mp4" type="video/mp4" />
154 + </video>
155 + <div class="logo-overlay">
156 + <h1 class="logo-title">PORTAL</h1>
157 + <p class="logo-subtitle">LOCAL TO WEB. INSTANT ACCESS.</p>
158 </div>
159 + </div>
160 + <div class="loading-bar-container" id="loading-bar-container">
161 + <div class="loading-bar"></div>
162 + </div>
163 + <div class="loading-text" id="loading-text">
164 + Initializing Portal Network...
165 + </div>
166 </div>
167
168 <script>
162 - // In-app browser detection and redirect handler
163 - (function () {
164 - const userAgent = navigator.userAgent.toLowerCase();
165 - const finalUrl = window.location.href;
166 -
167 - // Detect various in-app browsers
168 - const isKakao = /kakaotalk/i.test(userAgent);
169 - const isNaver = /naver/i.test(userAgent);
170 - const isFacebook = /fb|fbav|fban/i.test(userAgent);
171 - const isInstagram = /instagram/i.test(userAgent);
172 - const isLine = /line/i.test(userAgent);
173 - const isInAppBrowser =
174 - isKakao || isNaver || isFacebook || isInstagram || isLine;
175 - const isAndroid = /android/.test(userAgent);
176 - const isIOS = /ipad|iphone|ipod/.test(userAgent);
177 -
178 - if (!isInAppBrowser) {
179 - return; // Not in-app browser, proceed normally
180 - }
169 + // In-app browser detection and redirect handler
170 + (function () {
171 + const userAgent = navigator.userAgent.toLowerCase();
172 + const finalUrl = window.location.href;
173 +
174 + // Detect various in-app browsers
175 + const isKakao = /kakaotalk/i.test(userAgent);
176 + const isNaver = /naver/i.test(userAgent);
177 + const isFacebook = /fb|fbav|fban/i.test(userAgent);
178 + const isInstagram = /instagram/i.test(userAgent);
179 + const isLine = /line/i.test(userAgent);
180 + const isInAppBrowser =
181 + isKakao || isNaver || isFacebook || isInstagram || isLine;
182 + const isAndroid = /android/.test(userAgent);
183 + const isIOS = /ipad|iphone|ipod/.test(userAgent);
184 +
185 + if (!isInAppBrowser) {
186 + return; // Not in-app browser, proceed normally
187 + }
188
182 - // Handle redirection to external browser
183 - if (isAndroid) {
184 - if (isKakao) {
185 - location.href =
186 - "kakaotalk://web/openExternal?url=" +
187 - encodeURIComponent(finalUrl);
188 - setTimeout(() => {
189 - location.href = "kakaotalk://inappbrowser/close";
190 - }, 10);
191 - } else {
192 - // Use Intent to open in Chrome or default browser
193 - const intentUrl =
194 - "intent://" +
195 - finalUrl.replace(/^https?:\/\//, "") +
196 - "#Intent;scheme=https;action=android.intent.action.VIEW;end";
197 - location.href = intentUrl;
198 - }
199 - return; // Stop execution
200 - } else if (isIOS) {
201 - if (isKakao) {
202 - location.href =
203 - "kakaotalk://web/openExternal?url=" +
204 - encodeURIComponent(finalUrl);
205 - // Set up auto-close listener for iOS KakaoTalk
206 - document.addEventListener("visibilitychange", () => {
207 - if (document.visibilityState == "visible") {
208 - location.href = "kakaoweb://closeBrowser";
209 - }
210 - });
211 - } else {
212 - // For other iOS in-app browsers, show message in loading screen
213 - // updateLoadingText("⚠️ Please open in external browser (Safari)");
214 - // document.getElementById("loading-text").classList.add("error");
215 - }
216 - return; // Stop execution
217 - }
218 - })();
189 + // Handle redirection to external browser
190 + if (isAndroid) {
191 + if (isKakao) {
192 + location.href =
193 + "kakaotalk://web/openExternal?url=" +
194 + encodeURIComponent(finalUrl);
195 + setTimeout(() => {
196 + location.href = "kakaotalk://inappbrowser/close";
197 + }, 10);
198 + } else {
199 + // Use Intent to open in Chrome or default browser
200 + const intentUrl =
201 + "intent://" +
202 + finalUrl.replace(/^https?:\/\//, "") +
203 + "#Intent;scheme=https;action=android.intent.action.VIEW;end";
204 + location.href = intentUrl;
205 + }
206 + return; // Stop execution
207 + } else if (isIOS) {
208 + if (isKakao) {
209 + location.href =
210 + "kakaotalk://web/openExternal?url=" +
211 + encodeURIComponent(finalUrl);
212 + // Set up auto-close listener for iOS KakaoTalk
213 + document.addEventListener("visibilitychange", () => {
214 + if (document.visibilityState == "visible") {
215 + location.href = "kakaoweb://closeBrowser";
216 + }
217 + });
218 + } else {
219 + // For other iOS in-app browsers, show message in loading screen
220 + // updateLoadingText("⚠️ Please open in external browser (Safari)");
221 + // document.getElementById("loading-text").classList.add("error");
222 + }
223 + return; // Stop execution
224 + }
225 + })();
226 </script>
227 <script>
221 - // Update loading text
222 - function updateLoadingText(message, isError = false) {
223 - const loadingText = document.getElementById("loading-text");
224 - if (loadingText) {
225 - loadingText.textContent = message;
226 - if (isError) {
227 - loadingText.classList.add("error");
228 - } else {
229 - loadingText.classList.remove("error");
230 - }
231 - }
228 + // Update loading text
229 + function updateLoadingText(message, isError = false) {
230 + const loadingText = document.getElementById("loading-text");
231 + if (loadingText) {
232 + loadingText.textContent = message;
233 + if (isError) {
234 + loadingText.classList.add("error");
235 + } else {
236 + loadingText.classList.remove("error");
237 + }
238 + }
239 + }
240 +
241 + // Show error in loading text
242 + function showError(error, context = "") {
243 + let errorMessage = "";
244 + if (typeof error === "string") {
245 + errorMessage = error;
246 + } else if (error instanceof Error) {
247 + errorMessage = `${error.message}`;
248 + } else {
249 + errorMessage = "An error occurred";
250 }
251
234 - // Show error in loading text
235 - function showError(error, context = "") {
236 - let errorMessage = "";
237 - if (typeof error === "string") {
238 - errorMessage = error;
239 - } else if (error instanceof Error) {
240 - errorMessage = `${error.message}`;
241 - } else {
242 - errorMessage = "An error occurred";
243 - }
244 -
245 - if (context) {
246 - errorMessage = `⚠️ ${context}: ${errorMessage}`;
247 - } else {
248 - errorMessage = `⚠️ ${errorMessage}`;
249 - }
250 -
251 - updateLoadingText(errorMessage, true);
252 - console.error(`[Portal Error - ${context}]`, error);
252 + if (context) {
253 + errorMessage = `⚠️ ${context}: ${errorMessage}`;
254 + } else {
255 + errorMessage = `⚠️ ${errorMessage}`;
256 }
257
255 - // Global error handler
256 - window.addEventListener("error", (event) => {
257 - showError(event.error || event.message, "Error");
258 - event.preventDefault();
258 + updateLoadingText(errorMessage, true);
259 + console.error(`[Portal Error - ${context}]`, error);
260 + }
261 +
262 + // Global error handler
263 + window.addEventListener("error", (event) => {
264 + showError(event.error || event.message, "Error");
265 + event.preventDefault();
266 + });
267 +
268 + // Unhandled promise rejection handler
269 + window.addEventListener("unhandledrejection", (event) => {
270 + showError(event.reason, "Promise Error");
271 + event.preventDefault();
272 + });
273 +
274 + // Listen for errors from Service Worker
275 + if ("serviceWorker" in navigator) {
276 + navigator.serviceWorker.addEventListener("message", (event) => {
277 + if (event.data && event.data.type === "SW_ERROR") {
278 + const error = event.data.error;
279 + showError(error.message, "Service Worker Error");
280 + }
281 });
282 + }
283
261 - // Unhandled promise rejection handler
262 - window.addEventListener("unhandledrejection", (event) => {
263 - showError(event.reason, "Promise Error");
264 - event.preventDefault();
265 - });
284 + async function registerServiceWorker() {
285 + let retryCount = 0;
286 + const maxRetries = 30; // 3초 (30 × 100ms)
287
267 - // Listen for errors from Service Worker
268 - if ("serviceWorker" in navigator) {
269 - navigator.serviceWorker.addEventListener("message", (event) => {
270 - if (event.data && event.data.type === "SW_ERROR") {
271 - const error = event.data.error;
272 - showError(error.message, "Service Worker Error");
273 - }
288 + const checkWASMReady = async () => {
289 + try {
290 + const resp = await fetch("/e8c2c70c-ec4a-40b2-b8af-d5638264f831", {
291 + cache: "no-store",
292 });
275 - }
276 -
277 - async function registerServiceWorker() {
278 - let retryCount = 0;
279 - const maxRetries = 30; // 3초 (30 × 100ms)
280 -
281 - const checkWASMReady = async () => {
282 - try {
283 - const resp = await fetch("/e8c2c70c-ec4a-40b2-b8af-d5638264f831", {
284 - cache: "no-store",
285 - });
286 - const text = await resp.text();
287 -
288 - if (text === "ACK-e8c2c70c-ec4a-40b2-b8af-d5638264f831") {
289 - updateLoadingText("Portal Network Ready!");
290 - setTimeout(() => {
291 - window.location.reload();
292 - }, 500);
293 - } else if (text === "NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831") {
294 - retryCount++;
295 - if (retryCount > maxRetries) {
296 - throw new Error(
297 - `WASM initialization timeout after ${maxRetries} retries`
298 - );
299 - }
300 - updateLoadingText(
301 - `Initializing WASM... (${retryCount}/${maxRetries})`
302 - );
303 - setTimeout(checkWASMReady, 100);
304 - } else {
305 - if (text.includes("<!DOCTYPE") || text.includes("<html>")) {
306 - throw new Error("Service Worker not active - please refresh");
307 - } else {
308 - throw new Error(
309 - `Unexpected response: ${text.substring(0, 50)}`
310 - );
311 - }
312 - }
313 - } catch (error) {
314 - showError(error, "Connection");
315 - }
293 + const text = await resp.text();
294 +
295 + if (text === "ACK-e8c2c70c-ec4a-40b2-b8af-d5638264f831") {
296 + updateLoadingText("Portal Network Ready!");
297 + setTimeout(() => {
298 + window.location.reload();
299 + }, 500);
300 + } else if (text === "NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831") {
301 + retryCount++;
302 + if (retryCount > maxRetries) {
303 + throw new Error(
304 + `WASM initialization timeout after ${maxRetries} retries`
305 + );
306 + }
307 + updateLoadingText(
308 + `Initializing WASM... (${retryCount}/${maxRetries})`
309 + );
310 + setTimeout(checkWASMReady, 100);
311 + } else {
312 + if (text.includes("<!DOCTYPE") || text.includes("<html>")) {
313 + throw new Error("Service Worker not active - please refresh");
314 + } else {
315 + throw new Error(
316 + `Unexpected response: ${text.substring(0, 50)}`
317 + );
318 + }
319 + }
320 + } catch (error) {
321 + showError(error, "Connection");
322 + }
323 + };
324 +
325 + const waitForController = () => {
326 + return new Promise((resolve, reject) => {
327 + const checkController = () => {
328 + if (navigator.serviceWorker.controller) {
329 + resolve();
330 + return true;
331 + }
332 + return false;
333 };
334
318 - const waitForController = () => {
319 - return new Promise((resolve, reject) => {
320 - const checkController = () => {
321 - if (navigator.serviceWorker.controller) {
322 - resolve();
323 - return true;
324 - }
325 - return false;
326 - };
327 -
328 - if (checkController()) {
329 - return;
330 - }
331 -
332 - let timeoutId;
333 - let pollIntervalId;
334 -
335 - const onControllerChange = () => {
336 - if (checkController()) {
337 - clearTimeout(timeoutId);
338 - clearInterval(pollIntervalId);
339 - navigator.serviceWorker.removeEventListener(
340 - "controllerchange",
341 - onControllerChange
342 - );
343 - }
344 - };
345 -
346 - navigator.serviceWorker.addEventListener(
347 - "controllerchange",
348 - onControllerChange
349 - );
350 -
351 - pollIntervalId = setInterval(() => {
352 - if (checkController()) {
353 - clearTimeout(timeoutId);
354 - clearInterval(pollIntervalId);
355 - navigator.serviceWorker.removeEventListener(
356 - "controllerchange",
357 - onControllerChange
358 - );
359 - }
360 - }, 100);
361 -
362 - timeoutId = setTimeout(() => {
363 - clearInterval(pollIntervalId);
364 - navigator.serviceWorker.removeEventListener(
365 - "controllerchange",
366 - onControllerChange
367 - );
368 -
369 - if (navigator.serviceWorker.controller) {
370 - resolve();
371 - } else {
372 - reject(new Error("Service Worker activation timeout"));
373 - setTimeout(() => {
374 - location.reload();
375 - }, 500);
376 - }
377 - }, 3000);
378 - });
379 - };
335 + if (checkController()) {
336 + return;
337 + }
338
381 - try {
382 - if (!("serviceWorker" in navigator)) {
383 - throw new Error("Service Worker not supported in this browser");
384 - }
385 -
386 - updateLoadingText("Registering Service Worker...");
387 -
388 - const registration = await navigator.serviceWorker.register(
389 - "/service-worker.js",
390 - {
391 - scope: "/",
392 - updateViaCache: "none",
393 - }
394 - );
339 + let timeoutId;
340 + let pollIntervalId;
341
396 - if (!navigator.serviceWorker.controller && registration.active) {
397 - registration.active.postMessage({ type: "CLAIM_CLIENTS" });
398 - }
342 + const onControllerChange = () => {
343 + if (checkController()) {
344 + clearTimeout(timeoutId);
345 + clearInterval(pollIntervalId);
346 + navigator.serviceWorker.removeEventListener(
347 + "controllerchange",
348 + onControllerChange
349 + );
350 + }
351 + };
352
400 - updateLoadingText("Activating Service Worker...");
353 + navigator.serviceWorker.addEventListener(
354 + "controllerchange",
355 + onControllerChange
356 + );
357 +
358 + pollIntervalId = setInterval(() => {
359 + if (checkController()) {
360 + clearTimeout(timeoutId);
361 + clearInterval(pollIntervalId);
362 + navigator.serviceWorker.removeEventListener(
363 + "controllerchange",
364 + onControllerChange
365 + );
366 + }
367 + }, 100);
368 +
369 + timeoutId = setTimeout(() => {
370 + clearInterval(pollIntervalId);
371 + navigator.serviceWorker.removeEventListener(
372 + "controllerchange",
373 + onControllerChange
374 + );
375 +
376 + if (navigator.serviceWorker.controller) {
377 + resolve();
378 + } else {
379 + reject(new Error("Service Worker activation timeout"));
380 + setTimeout(() => {
381 + location.reload();
382 + }, 500);
383 + }
384 + }, 3000);
385 + });
386 + };
387 +
388 + try {
389 + if (!("serviceWorker" in navigator)) {
390 + throw new Error("Service Worker not supported in this browser");
391 + }
392 +
393 + updateLoadingText("Registering Service Worker...");
394 +
395 + const registration = await navigator.serviceWorker.register(
396 + "/service-worker.js",
397 + {
398 + scope: "/",
399 + updateViaCache: "none",
400 + }
401 + );
402
402 - await navigator.serviceWorker.ready;
403 + if (!navigator.serviceWorker.controller && registration.active) {
404 + registration.active.postMessage({ type: "CLAIM_CLIENTS" });
405 + }
406
404 - if (!navigator.serviceWorker.controller) {
405 - updateLoadingText("Waiting for activation...");
406 - try {
407 - await Promise.race([
408 - waitForController(),
409 - new Promise((resolve) => setTimeout(resolve, 500))
410 - ]);
411 - } catch (error) {
412 - // Ignore timeout, proceed anyway
413 - }
414 - }
407 + updateLoadingText("Activating Service Worker...");
408
416 - updateLoadingText("Connecting to Portal Network...");
409 + await navigator.serviceWorker.ready;
410
418 - setTimeout(checkWASMReady, 100);
411 + if (!navigator.serviceWorker.controller) {
412 + updateLoadingText("Waiting for activation...");
413 + try {
414 + await Promise.race([
415 + waitForController(),
416 + new Promise((resolve) => setTimeout(resolve, 500)),
417 + ]);
418 } catch (error) {
420 - showError(error, "Initialization");
419 + // Ignore timeout, proceed anyway
420 }
421 + }
422 +
423 + updateLoadingText("Connecting to Portal Network...");
424 +
425 + setTimeout(checkWASMReady, 100);
426 + } catch (error) {
427 + showError(error, "Initialization");
428 }
429 + }
430
424 - registerServiceWorker();
431 + registerServiceWorker();
432 </script>
426 -</body>
427 -
428 -</html>
\ No newline at end of file
433 + </body>
434 +</html>
cmd/webclient/portal.jpg
Binary files /dev/null and b/cmd/webclient/portal.jpg differ
portal/lease.go
+25
@@ -224,6 +224,31 @@ func (lm *LeaseManager) GetLeaseByID(leaseID string) (*LeaseEntry, bool) {
224 return lease, true
225 }
226
227 +func (lm *LeaseManager) GetLeaseByName(name string) (*LeaseEntry, bool) {
228 + lm.leasesLock.RLock()
229 + defer lm.leasesLock.RUnlock()
230 +
231 + if name == "" {
232 + return nil, false
233 + }
234 +
235 + now := time.Now()
236 + for _, lease := range lm.leases {
237 + if lease.Lease.Name == name {
238 + // Check if banned
239 + if _, banned := lm.bannedLeases[string(lease.Lease.Identity.Id)]; banned {
240 + continue
241 + }
242 + // Check if expired
243 + if now.After(lease.Expires) {
244 + continue
245 + }
246 + return lease, true
247 + }
248 + }
249 + return nil, false
250 +}
251 +
252 func (lm *LeaseManager) GetAllLeases() []*rdverb.Lease {
253 lm.leasesLock.RLock()
254 defer lm.leasesLock.RUnlock()
portal/relay.go
+5
@@ -269,6 +269,11 @@ func (g *RelayServer) GetLeaseManager() *LeaseManager {
269 return g.leaseManager
270 }
271
272 +// GetLeaseByName returns a lease entry by its name
273 +func (g *RelayServer) GetLeaseByName(name string) (*LeaseEntry, bool) {
274 + return g.leaseManager.GetLeaseByName(name)
275 +}
276 +
277 // IsConnectionActive checks if a connection with the given ID is still active
278 func (g *RelayServer) IsConnectionActive(connectionID int64) bool {
279 g.connectionsLock.RLock()