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
+ "<script>alert('xss')</script>",
42
+ "Double "quotes" and <tags>",
43
+ "https://example.com/img?q=1&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()