feat: Add hide option to lease metadata for Gosuda badge visibility control

sinwoojin committed Nov 14, 2025 at 14:11 UTC 5f8cde5b6e0e1d7246492827d257724200639abc
5 files changed +190 -95
cmd/demo-app/main.go
+1 -1
@@ -188,7 +188,7 @@ func runPaint() error {
188 defer client.Close()
189
190 // 3) Register lease and obtain a net.Listener that accepts relayed connections
191 - listener, err := client.Listen(cred, flagName, []string{"http/1.1"}, sdk.WithDescription("Portal demo paint app"), sdk.WithTags([]string{"demo", "paint"}), sdk.WithOwner("PortalApp Developer"), sdk.WithCountry("KR"), sdk.WithHide(true))
191 + listener, err := client.Listen(cred, flagName, []string{"http/1.1"}, sdk.WithDescription("Portal demo paint app"), sdk.WithTags([]string{"demo", "paint"}), sdk.WithOwner("PortalApp Developer"), sdk.WithCountry("KR"), sdk.WithHide(false)) // normal: view badge
192 if err != nil {
193 return fmt.Errorf("listen: %w", err)
194 }
cmd/relay-server/static/app.js new
+28
@@ -0,0 +1,28 @@
1 +document.addEventListener("DOMContentLoaded", fetchAndRenderLeases);
2 +
3 +async function fetchAndRenderLeases() {
4 + try {
5 + const res = await fetch("/api/leases");
6 + const data = await res.json();
7 + renderLeaseList(data);
8 + } catch (err) {
9 + console.error("Failed to fetch leases:", err);
10 + }
11 +}
12 +
13 +function renderLeaseList(leases) {
14 + const list = document.getElementById("lease-list");
15 + if (!list) return;
16 + list.innerHTML = "";
17 + leases.forEach((lease) => {
18 + const item = document.createElement("div");
19 + item.className = "lease-item";
20 + item.innerHTML = `
21 + <span class="lease-name">${lease.metadata?.description || lease.id}</span>
22 + <span id="gosuda-badge-${lease.id}" class="gosuda-badge">고수다</span>`;
23 + if (lease.metadata?.hide) {
24 + item.querySelector(`#gosuda-badge-${lease.id}`).style.display = "none";
25 + }
26 + list.appendChild(item);
27 + });
28 +}
cmd/relay-server/static/index.html
+147 -90
@@ -1,96 +1,153 @@
1 -<!doctype html>
1 +<!DOCTYPE html>
2 <html lang="ko">
3 + <head>
4 + <meta charset="utf-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
6 + <title>Portal</title>
7 + <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8 + <link rel="stylesheet" href="/static/style.css" />
9 + </head>
10
4 -<head>
5 - <meta charset="utf-8" />
6 - <meta name="viewport" content="width=device-width, initial-scale=1" />
7 - <title>Portal</title>
8 - <link rel="icon" type="image/x-icon" href="/favicon.ico" />
9 - <link rel="stylesheet" href="/static/style.css" />
10 -</head>
11 -
12 -<body>
13 - <div class="wrap">
14 - <header>
15 - <div class="brand">Portal</div>
16 - <div class="rightbar">
17 - <div class="searchbar" role="search">
18 - <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
19 - aria-hidden="true">
20 - <path
21 - d="M15.5 14h-.79l-.28-.27a6.471 6.471 0 0 0 1.57-4.23C15.99 6.01 13.48 3.5 10.49 3.5S5 6.01 5 9s2.51 5.5 5.5 5.5c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14Zm-5 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14Z" />
22 - </svg>
23 - <input id="search" type="text" placeholder="Search by name" aria-label="Search by name"
24 - oninput="filterCards(this.value)">
25 - </div>
26 - <a class="gh-btn" href="https://github.com/gosuda/portal" target="_blank" rel="noopener"
27 - aria-label="GitHub repository" title="gosuda/portal">
28 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
29 - aria-hidden="true">
30 - <path
31 - d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.37 7.86 10.88.58.1.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.69-3.88-1.39-3.88-1.39-.53-1.35-1.29-1.71-1.29-1.71-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.19 1.78 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.26-1.28-5.26-5.72 0-1.26.45-2.3 1.19-3.11-.12-.29-.52-1.45.11-3.02 0 0 .98-.31 3.2 1.19.93-.26 1.94-.39 2.94-.39s2.01.13 2.95.39c2.22-1.5 3.2-1.19 3.2-1.19.63 1.57.23 2.73.12 3.02.74.81 1.19 1.85 1.19 3.11 0 4.45-2.7 5.43-5.28 5.72.41.36.77 1.07.77 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.56C20.22 21.36 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5Z" />
32 - </svg>
33 - </a>
34 - <div class="counter" aria-label="Active devices" title="Active devices">
35 - <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
36 - aria-hidden="true">
37 - <circle cx="12" cy="12" r="6" />
38 - </svg>
39 - <span>{{len .Rows}}</span>
40 - </div>
41 - </div>
42 - </header>
43 - <main>
44 - <div class="grid">
45 - {{range .Rows}}
46 - <a class="card {{if .Connected}}connected{{else}}disconnected{{end}}" id="peer-{{.Peer}}" data-peer="{{.Peer}}"
47 - data-name="{{.Name}}" href="{{.Link}}" title="{{if .Name}}{{.Name}}{{else}}(unnamed){{end}} 열기">
48 - <div class="card-head">
49 - <span class="status-dot {{if .Connected}}ok{{else}}{{if .StaleRed}}bad{{end}}{{end}}"></span>
50 - <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
51 - <svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
52 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
53 - <path d="M9 6l6 6-6 6" />
11 + <body>
12 + <div class="wrap">
13 + <header>
14 + <div class="brand">Portal</div>
15 + <div class="rightbar">
16 + <div class="searchbar" role="search">
17 + <svg
18 + class="icon"
19 + xmlns="http://www.w3.org/2000/svg"
20 + viewBox="0 0 24 24"
21 + fill="currentColor"
22 + aria-hidden="true"
23 + >
24 + <path
25 + d="M15.5 14h-.79l-.28-.27a6.471 6.471 0 0 0 1.57-4.23C15.99 6.01 13.48 3.5 10.49 3.5S5 6.01 5 9s2.51 5.5 5.5 5.5c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14Zm-5 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14Z"
26 + />
27 </svg>
28 + <input
29 + id="search"
30 + type="text"
31 + placeholder="Search by name"
32 + aria-label="Search by name"
33 + oninput="filterCards(this.value)"
34 + />
35 </div>
56 - <div class="muted">Last seen <time class="lastseen"
57 - datetime="{{.LastSeenISO}}">{{.LastSeenISO}}</time><br />Active ~{{.LastSeen}} ago</div>
58 - <div class="muted idtext" style="margin-top:auto">{{.Peer}}</div>
59 - </a>
60 - {{else}}
61 - <article class="card">
62 - <div class="title">No clients discovered</div>
63 - <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.
36 + <a
37 + class="gh-btn"
38 + href="https://github.com/gosuda/portal"
39 + target="_blank"
40 + rel="noopener"
41 + aria-label="GitHub repository"
42 + title="gosuda/portal"
43 + >
44 + <svg
45 + xmlns="http://www.w3.org/2000/svg"
46 + width="24"
47 + height="24"
48 + viewBox="0 0 24 24"
49 + fill="currentColor"
50 + aria-hidden="true"
51 + >
52 + <path
53 + d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.37 7.86 10.88.58.1.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.69-3.88-1.39-3.88-1.39-.53-1.35-1.29-1.71-1.29-1.71-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.19 1.78 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.26-1.28-5.26-5.72 0-1.26.45-2.3 1.19-3.11-.12-.29-.52-1.45.11-3.02 0 0 .98-.31 3.2 1.19.93-.26 1.94-.39 2.94-.39s2.01.13 2.95.39c2.22-1.5 3.2-1.19 3.2-1.19.63 1.57.23 2.73.12 3.02.74.81 1.19 1.85 1.19 3.11 0 4.45-2.7 5.43-5.28 5.72.41.36.77 1.07.77 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.56C20.22 21.36 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5Z"
54 + />
55 + </svg>
56 + </a>
57 + <div
58 + class="counter"
59 + aria-label="Active devices"
60 + title="Active devices"
61 + >
62 + <svg
63 + class="icon"
64 + xmlns="http://www.w3.org/2000/svg"
65 + viewBox="0 0 24 24"
66 + fill="currentColor"
67 + aria-hidden="true"
68 + >
69 + <circle cx="12" cy="12" r="6" />
70 + </svg>
71 + <span>{{len .Rows}}</span>
72 </div>
65 - </article>
66 - {{end}}
67 - </div>
68 - </main>
69 - </div>
70 - <script>
71 - function filterCards(q) {
72 - q = (q || '').toLowerCase().trim();
73 - const cards = document.querySelectorAll('.grid .card');
74 - cards.forEach(card => {
75 - const name = (card.getAttribute('data-name') || '').toLowerCase();
76 - const show = !q || name.includes(q);
77 - card.style.display = show ? '' : 'none';
78 - });
79 - }
80 - (function () {
81 - try {
82 - const opts = { dateStyle: 'medium', timeStyle: 'short' };
83 - document.querySelectorAll('time.lastseen').forEach(el => {
84 - const iso = el.getAttribute('datetime');
85 - if (!iso) return;
86 - const d = new Date(iso);
87 - if (isNaN(d.getTime())) return;
88 - el.textContent = new Intl.DateTimeFormat(undefined, opts).format(d);
89 - el.title = d.toString();
73 + </div>
74 + </header>
75 + <main>
76 + <div class="grid">
77 + {{range .Rows}}
78 + <a
79 + class="card {{if .Connected}}connected{{else}}disconnected{{end}}"
80 + id="peer-{{.Peer}}"
81 + data-peer="{{.Peer}}"
82 + data-name="{{.Name}}"
83 + href="{{.Link}}"
84 + title="{{if .Name}}{{.Name}}{{else}}(unnamed){{end}} 열기"
85 + >
86 + <div class="card-head">
87 + <span
88 + class="status-dot {{if .Connected}}ok{{else}}{{if .StaleRed}}bad{{end}}{{end}}"
89 + ></span>
90 + <div class="title">
91 + {{if .Name}}{{.Name}}{{else}}(unnamed){{end}}
92 + </div>
93 + <svg
94 + class="chevron"
95 + xmlns="http://www.w3.org/2000/svg"
96 + viewBox="0 0 24 24"
97 + fill="none"
98 + stroke="currentColor"
99 + stroke-width="2"
100 + stroke-linecap="round"
101 + stroke-linejoin="round"
102 + aria-hidden="true"
103 + >
104 + <path d="M9 6l6 6-6 6" />
105 + </svg>
106 + </div>
107 + <div class="muted">
108 + Last seen
109 + <time class="lastseen" datetime="{{.LastSeenISO}}"
110 + >{{.LastSeenISO}}</time
111 + ><br />Active ~{{.LastSeen}} ago
112 + </div>
113 + <div class="muted idtext" style="margin-top: auto">{{.Peer}}</div>
114 + </a>
115 + {{else}}
116 + <article class="card">
117 + <div class="title">No clients discovered</div>
118 + <div class="muted">
119 + Start a client and ensure bootstrap URLs point at this server's
120 + /relay WebSocket endpoint.
121 + </div>
122 + </article>
123 + {{end}}
124 + </div>
125 + </main>
126 + </div>
127 + <script>
128 + function filterCards(q) {
129 + q = (q || "").toLowerCase().trim();
130 + const cards = document.querySelectorAll(".grid .card");
131 + cards.forEach((card) => {
132 + const name = (card.getAttribute("data-name") || "").toLowerCase();
133 + const show = !q || name.includes(q);
134 + card.style.display = show ? "" : "none";
135 });
91 - } catch (_) { }
92 - })();
93 - </script>
94 -</body>
95 -
96 -</html>
\ No newline at end of file
136 + }
137 + (function () {
138 + try {
139 + const opts = { dateStyle: "medium", timeStyle: "short" };
140 + document.querySelectorAll("time.lastseen").forEach((el) => {
141 + const iso = el.getAttribute("datetime");
142 + if (!iso) return;
143 + const d = new Date(iso);
144 + if (isNaN(d.getTime())) return;
145 + el.textContent = new Intl.DateTimeFormat(undefined, opts).format(d);
146 + el.title = d.toString();
147 + });
148 + } catch (_) {}
149 + })();
150 + </script>
151 + <script src="app.js"></script>
152 + </body>
153 +</html>
cmd/relay-server/view.go
+9
@@ -3,6 +3,7 @@ package main
3 import (
4 "context"
5 "embed"
6 + "encoding/json"
7 "fmt"
8 "html/template"
9 "net/http"
@@ -192,6 +193,14 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
193 lease := leaseEntry.Lease
194 identityID := string(lease.Identity.Id)
195
196 + // Metadata parsing and hide check
197 + var meta struct {
198 + Hide bool `json:"hide"`
199 + }
200 + if err := json.Unmarshal([]byte(lease.Metadata), &meta); err == nil && meta.Hide {
201 + continue
202 + }
203 +
204 // Calculate TTL
205 ttl := time.Until(leaseEntry.Expires)
206 ttlStr := ""
sdk/sdk.go
+5 -4
@@ -14,6 +14,7 @@ import (
14
15 "github.com/gorilla/websocket"
16 "github.com/rs/zerolog/log"
17 +
18 "gosuda.org/portal/portal"
19 "gosuda.org/portal/portal/core/cryptoops"
20 "gosuda.org/portal/portal/core/proto/rdsec"
@@ -175,7 +176,7 @@ type Metadata struct {
176 Thumbnail string `json:"thumbnail"`
177 Owner string `json:"owner"`
178 Country string `json:"country"`
178 - Hide bool `json:"hide"` // 고수다 숨김 여부
179 + Hide bool `json:"hide"` // 고수다 숨김 여부
180 }
181
182 func (m Metadata) isEmpty() bool {
@@ -219,9 +220,9 @@ func WithCountry(country string) MetadataOption {
220 }
221
222 func WithHide(hide bool) MetadataOption {
222 - return func(m *Metadata) {
223 - m.Hide = hide
224 - }
223 + return func(m *Metadata) {
224 + m.Hide = hide
225 + }
226 }
227
228 type RDClient struct {