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 {