tinyrelaycommit

Commit d5f8a6f473d6

browse files at this commit | history

1diff --git a/internal/webui/badge-96.png b/internal/webui/badge-96.png
2new file mode 100644
3index 0000000..a8ff413
4Binary files /dev/null and b/internal/webui/badge-96.png differ
5diff --git a/internal/webui/components.js b/internal/webui/components.js
6index d0cfb0b..e3bc926 100644
7--- a/internal/webui/components.js
8+++ b/internal/webui/components.js
9@@ -26,6 +26,9 @@
10 // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
11 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
12 // posts it to the relay.
13+// <push-toggle>
14+// Opts this browser into relay notifications; asks for permission only
15+// when pressed and registers the device with a signed request.
16 // <file-upload> and <file-workspace-root> live in file-workspace.js;
17 // <private-services> lives in private-services.js.
18 (() => {
19@@ -402,6 +405,57 @@
20 // JsonView renders any JSON value. Arrays of objects become tables, objects
21 // become definition lists, scalar arrays become lists, and deep nesting
22 // falls back to compact JSON so a response never explodes the page.
23+ // PushToggle opts this browser into relay notifications. Nothing happens
24+ // until the button is pressed: permission is requested then, and the
25+ // device subscription is registered with a signed request.
26+ class PushToggle extends HTMLElement {
27+ connectedCallback() {
28+ if (this.bound) return;
29+ this.bound = true;
30+ this.innerHTML = '<p><button type="button">Enable on this device</button></p><output role="status"></output>';
31+ this.button = this.querySelector("button");
32+ this.output = this.querySelector("output");
33+ if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
34+ this.button.disabled = true;
35+ this.say("This browser does not support notifications.");
36+ return;
37+ }
38+ this.button.addEventListener("click", () => this.toggle().catch(err => this.say("error: " + err.message, true)));
39+ this.refresh().catch(() => {});
40+ }
41+ say(text, error) { this.output.textContent = text; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }
42+ async subscription() { return (await navigator.serviceWorker.ready).pushManager.getSubscription(); }
43+ async refresh() {
44+ const current = await this.subscription();
45+ this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint;
46+ this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device";
47+ if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings.");
48+ }
49+ async toggle() {
50+ this.button.disabled = true;
51+ try {
52+ if (this.enabled) {
53+ const current = await this.subscription();
54+ if (current) {
55+ await tiny.signedFetch("/push/unsubscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});
56+ await current.unsubscribe();
57+ }
58+ localStorage.removeItem("tiny.push");
59+ this.say("Notifications are off on this device.");
60+ } else {
61+ if ((await Notification.requestPermission()) !== "granted") throw Error("Permission was not granted.");
62+ const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json();
63+ const registration = await navigator.serviceWorker.ready;
64+ const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)}));
65+ await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});
66+ localStorage.setItem("tiny.push", current.endpoint);
67+ this.say("Notifications are on for this device.");
68+ }
69+ await this.refresh();
70+ } finally { this.button.disabled = false; }
71+ }
72+ }
73+
74 class JsonView extends HTMLElement {
75 connectedCallback() {
76 if (this.rendered) return;
77@@ -755,6 +809,7 @@
78 customElements.define("file-tools", FileTools);
79 customElements.define("publish-list", PublishList);
80 customElements.define("nostr-compose", NostrCompose);
81+ customElements.define("push-toggle", PushToggle);
82 customElements.define("nostr-key", NostrKey);
83 customElements.define("json-view", JsonView);
84
85diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
86index d7729bd..53550e3 100644
87--- a/internal/webui/file-workspace.js
88+++ b/internal/webui/file-workspace.js
89@@ -175,6 +175,37 @@
90 });
91 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());
92 this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));
93+ this.intake().catch(error => this.say(error.message, true));
94+ }
95+ // intake collects files parked by the service worker for a share from
96+ // another app. They wait here until the person presses Upload.
97+ async intake() {
98+ const id = new URLSearchParams(location.hash.slice(1)).get("share");
99+ if (!id || !/^[0-9a-f-]{36}$/.test(id) || !globalThis.caches) return;
100+ const cache = await caches.open("tiny-share");
101+ const meta = await (await cache.match("/share/" + id + "/meta"))?.json();
102+ if (!meta) return;
103+ const files = [];
104+ for (let index = 0; index < (meta.count || 0); index++) {
105+ const response = await cache.match("/share/" + id + "/" + index);
106+ if (!response) continue;
107+ const blob = await response.blob();
108+ files.push(new File([blob], decodeURIComponent(response.headers.get("x-name") || "shared"), {type: response.headers.get("content-type") || blob.type}));
109+ }
110+ for (const key of await cache.keys()) if (new URL(key.url).pathname.startsWith("/share/" + id + "/")) await cache.delete(key);
111+ history.replaceState?.(null, "", location.pathname + location.search);
112+ if (files.length) {
113+ this.choose(files);
114+ this.say(files.length + " shared file" + (files.length === 1 ? "" : "s") + " ready. Press Upload.");
115+ return;
116+ }
117+ const link = meta.url || (meta.text || "").match(/https?:\/\/\S+/)?.[0];
118+ const mirror = link && document.querySelector("file-mirror input[name=url]");
119+ if (mirror) {
120+ mirror.value = link;
121+ mirror.closest("details")?.setAttribute("open", "");
122+ this.say("Shared link ready to import.");
123+ }
124 }
125 // choose records a selection. A folder is recognized from the relative
126 // paths that folder pickers and folder drops attach to their files.
127diff --git a/internal/webui/manifest.webmanifest b/internal/webui/manifest.webmanifest
128index 378fb8e..36d884c 100644
129--- a/internal/webui/manifest.webmanifest
130+++ b/internal/webui/manifest.webmanifest
131@@ -12,6 +12,13 @@
132 "theme_color": "THEME",
133 "categories": ["social", "utilities"],
134 "prefer_related_applications": false,
135+ "share_target": {
136+ "action": "BASE/share",
137+ "method": "POST",
138+ "enctype": "multipart/form-data",
139+ "params": {"title": "title", "text": "text", "url": "url", "files": [{"name": "files", "accept": ["*/*"]}]}
140+ },
141+ "protocol_handlers": [{"protocol": "web+nostr", "url": "BASE/open?target=%s"}],
142 "icons": [
143 {"src": "BASE/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
144 {"src": "BASE/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
145diff --git a/internal/webui/open.go b/internal/webui/open.go
146new file mode 100644
147index 0000000..11259c8
148--- /dev/null
149+++ b/internal/webui/open.go
150@@ -0,0 +1,139 @@
151+package webui
152+
153+import (
154+ "encoding/binary"
155+ "encoding/hex"
156+ "net/http"
157+ "net/url"
158+ "regexp"
159+ "strconv"
160+ "strings"
161+)
162+
163+var hexID = regexp.MustCompile(`^[0-9a-f]{64}$`)
164+
165+// openLink handles web+nostr links registered through the installed app.
166+// It decodes the NIP-19 target and redirects to the page that shows it.
167+func (a *App) openLink(writer http.ResponseWriter, request *http.Request) {
168+ path, ok := nostrTargetPath(request.URL.Query().Get("target"))
169+ if !ok {
170+ http.Error(writer, "unsupported nostr link", http.StatusBadRequest)
171+ return
172+ }
173+ http.Redirect(writer, request, requestPrefix(request)+path, http.StatusSeeOther)
174+}
175+
176+// nostrTargetPath maps a nostr: or web+nostr: target to a relay page.
177+func nostrTargetPath(target string) (string, bool) {
178+ value := strings.TrimSpace(target)
179+ for _, prefix := range []string{"web+nostr:", "nostr:"} {
180+ if strings.HasPrefix(strings.ToLower(value), prefix) {
181+ value = value[len(prefix):]
182+ break
183+ }
184+ }
185+ value = strings.Trim(value, "/")
186+ if hexID.MatchString(value) {
187+ return "/e/" + value, true
188+ }
189+ hrp, data, ok := bech32Decode(strings.ToLower(value))
190+ if !ok {
191+ return "", false
192+ }
193+ switch hrp {
194+ case "npub":
195+ if len(data) != 32 {
196+ return "", false
197+ }
198+ return "/search?author=" + hex.EncodeToString(data), true
199+ case "note":
200+ if len(data) != 32 {
201+ return "", false
202+ }
203+ return "/e/" + hex.EncodeToString(data), true
204+ case "nprofile", "nevent", "naddr":
205+ fields := tlvFields(data)
206+ switch hrp {
207+ case "nprofile":
208+ if len(fields[0]) != 32 {
209+ return "", false
210+ }
211+ return "/search?author=" + hex.EncodeToString(fields[0]), true
212+ case "nevent":
213+ if len(fields[0]) != 32 {
214+ return "", false
215+ }
216+ return "/e/" + hex.EncodeToString(fields[0]), true
217+ default:
218+ if len(fields[2]) != 32 || len(fields[3]) != 4 {
219+ return "", false
220+ }
221+ kind := binary.BigEndian.Uint32(fields[3])
222+ return "/a/" + strconv.FormatUint(uint64(kind), 10) + ":" + hex.EncodeToString(fields[2]) + ":" + url.PathEscape(string(fields[0])), true
223+ }
224+ }
225+ return "", false
226+}
227+
228+// tlvFields returns the first value of each NIP-19 TLV type.
229+func tlvFields(data []byte) map[byte][]byte {
230+ fields := map[byte][]byte{}
231+ for len(data) >= 2 {
232+ kind, length := data[0], int(data[1])
233+ if len(data) < 2+length {
234+ break
235+ }
236+ if _, seen := fields[kind]; !seen {
237+ fields[kind] = data[2 : 2+length]
238+ }
239+ data = data[2+length:]
240+ }
241+ return fields
242+}
243+
244+// bech32Decode verifies a bech32 string and returns its 8-bit payload.
245+func bech32Decode(value string) (string, []byte, bool) {
246+ separator := strings.LastIndexByte(value, '1')
247+ if separator < 1 || separator+7 > len(value) || len(value) > 5000 {
248+ return "", nil, false
249+ }
250+ hrp, encoded := value[:separator], value[separator+1:]
251+ values := make([]byte, len(encoded))
252+ for i, char := range encoded {
253+ position := strings.IndexRune(bech32Charset, char)
254+ if position < 0 {
255+ return "", nil, false
256+ }
257+ values[i] = byte(position)
258+ }
259+ if bech32Polymod(append(bech32Expand(hrp), values...)) != 1 {
260+ return "", nil, false
261+ }
262+ data := values[:len(values)-6]
263+ accumulator, bits := 0, 0
264+ var result []byte
265+ for _, v := range data {
266+ accumulator = accumulator<<5 | int(v)
267+ bits += 5
268+ if bits >= 8 {
269+ bits -= 8
270+ result = append(result, byte(accumulator>>bits&255))
271+ }
272+ }
273+ if bits >= 5 || accumulator<<(8-bits)&255 != 0 {
274+ return "", nil, false
275+ }
276+ return hrp, result, true
277+}
278+
279+func bech32Expand(hrp string) []byte {
280+ expanded := make([]byte, 0, len(hrp)*2+1)
281+ for _, c := range hrp {
282+ expanded = append(expanded, byte(c)>>5)
283+ }
284+ expanded = append(expanded, 0)
285+ for _, c := range hrp {
286+ expanded = append(expanded, byte(c)&31)
287+ }
288+ return expanded
289+}
290diff --git a/internal/webui/open_test.go b/internal/webui/open_test.go
291new file mode 100644
292index 0000000..a8209ec
293--- /dev/null
294+++ b/internal/webui/open_test.go
295@@ -0,0 +1,35 @@
296+package webui
297+
298+import (
299+ "encoding/binary"
300+ "encoding/hex"
301+ "testing"
302+)
303+
304+func TestNostrTargetPathDecodesNIP19Links(t *testing.T) {
305+ pubkey := "5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369"
306+ raw, _ := hex.DecodeString(pubkey)
307+ id := "0f8fc45a6e3cd2d022cf68f3ea0f413665a6d92c3d783e3ab6c2bc324432b5af"
308+ idRaw, _ := hex.DecodeString(id)
309+ kind := make([]byte, 4)
310+ binary.BigEndian.PutUint32(kind, 30023)
311+ naddr := append(append(append([]byte{0, 5}, []byte("hello")...), append([]byte{2, 32}, raw...)...), append([]byte{3, 4}, kind...)...)
312+ cases := map[string]string{
313+ "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2": "/search?author=" + pubkey,
314+ "nostr:" + bech32("note", idRaw): "/e/" + id,
315+ bech32("nevent", append([]byte{0, 32}, idRaw...)): "/e/" + id,
316+ "web+nostr://" + bech32("nprofile", append([]byte{0, 32}, raw...)): "/search?author=" + pubkey,
317+ bech32("naddr", naddr): "/a/30023:" + pubkey + ":hello",
318+ id: "/e/" + id,
319+ }
320+ for target, want := range cases {
321+ if got, ok := nostrTargetPath(target); !ok || got != want {
322+ t.Fatalf("%s: got %q ok=%v, want %q", target, got, ok, want)
323+ }
324+ }
325+ for _, bad := range []string{"", "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc3", "nostr:javascript:alert(1)", "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs4g4gaj"} {
326+ if _, ok := nostrTargetPath(bad); ok {
327+ t.Fatalf("%q accepted", bad)
328+ }
329+ }
330+}
331diff --git a/internal/webui/page.html b/internal/webui/page.html
332index 32f6adb..6184b33 100644
333--- a/internal/webui/page.html
334+++ b/internal/webui/page.html
335@@ -114,7 +114,8 @@
336 {{if .Actor}}<section><h4>Upload</h4><file-upload></file-upload><p><small>Encrypt keeps the contents and key in your browser. Keep the complete share link.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}
337
338 {{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section>
339-{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}
340+{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}
341+{{if and .Actor (eq .Tab "inbox")}}<section><h4>Notifications</h4><push-toggle></push-toggle><p><small>Relay notices for this key, on this device only.</small></p><noscript><p>Enabling notifications needs JavaScript.</p></noscript></section>{{end}}{{end}}
342
343 {{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>
344 <section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>
345diff --git a/internal/webui/sw.js b/internal/webui/sw.js
346index 52c91d7..d412cd6 100644
347--- a/internal/webui/sw.js
348+++ b/internal/webui/sw.js
349@@ -10,9 +10,13 @@ self.addEventListener("activate", event => {
350 });
351 self.addEventListener("fetch", event => {
352 const request = event.request;
353- if (request.method !== "GET") return;
354 const url = new URL(request.url);
355 if (url.origin !== location.origin) return;
356+ if (request.method === "POST" && /\/share$/.test(url.pathname)) {
357+ event.respondWith(receiveShare(request, url));
358+ return;
359+ }
360+ if (request.method !== "GET") return;
361 const keep = request.mode === "navigate" || shellAsset(url);
362 if (!keep) return;
363 event.respondWith(fetch(request).then(response => {
364@@ -23,3 +27,36 @@ self.addEventListener("fetch", event => {
365 return response;
366 }).catch(async () => (await caches.match(request)) || (request.mode === "navigate" ? caches.match(new Request(url.origin + url.pathname.replace(/\/[^/]*$/, "/"))) : undefined)));
367 });
368+
369+// Shares from other apps arrive as a form post. The files are parked in a
370+// cache under a fresh id and the Files page picks them up from its fragment,
371+// so nothing is uploaded until the person presses Upload there.
372+const receiveShare = async (request, url) => {
373+ const form = await request.formData();
374+ const id = crypto.randomUUID();
375+ const cache = await caches.open("tiny-share");
376+ const files = form.getAll("files").filter(item => typeof item === "object" && "arrayBuffer" in item);
377+ await Promise.all(files.map((file, index) => cache.put(new Request(url.origin + "/share/" + id + "/" + index), new Response(file, {headers: {"content-type": file.type || "application/octet-stream", "x-name": encodeURIComponent(file.name || "shared")}}))));
378+ await cache.put(new Request(url.origin + "/share/" + id + "/meta"), Response.json({title: form.get("title") || "", text: form.get("text") || "", url: form.get("url") || "", count: files.length}));
379+ return Response.redirect(url.pathname.replace(/\/share$/, "/files") + "#share=" + id, 303);
380+};
381+
382+// Notifications are opt-in per device; a push only arrives after the person
383+// enabled them on this browser. The payload is the relay's short summary.
384+self.addEventListener("push", event => {
385+ let data = {};
386+ try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; }
387+ const base = new URL(self.registration.scope);
388+ event.waitUntil(self.registration.showNotification(data.title || "tiny", {
389+ body: data.body || "", tag: data.tag || "tiny", icon: new URL("icon-192.png", base).href, badge: new URL("badge-96.png", base).href, data: {url: data.url || base.href}
390+ }));
391+});
392+self.addEventListener("notificationclick", event => {
393+ event.notification.close();
394+ const target = event.notification.data?.url || self.registration.scope;
395+ event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => {
396+ const open = list.find(client => client.url.startsWith(self.registration.scope));
397+ if (open) { const focused = await open.navigate(target); return focused?.focus(); }
398+ return self.clients.openWindow(target);
399+ }));
400+});
401diff --git a/internal/webui/templates.go b/internal/webui/templates.go
402index 8e4f849..e130967 100644
403--- a/internal/webui/templates.go
404+++ b/internal/webui/templates.go
405@@ -71,6 +71,9 @@ var iconMaskablePNG []byte
406 //go:embed apple-touch-icon.png
407 var appleTouchIconPNG []byte
408
409+//go:embed badge-96.png
410+var badgePNG []byte
411+
412 //go:embed sw.js
413 var serviceWorkerJS []byte
414
415diff --git a/internal/webui/webui.go b/internal/webui/webui.go
416index ce250d2..57bf9a8 100644
417--- a/internal/webui/webui.go
418+++ b/internal/webui/webui.go
419@@ -151,6 +151,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
420 a.browse(writer, request)
421 return
422 }
423+ if request.URL.Path == "/share" {
424+ // The service worker receives shares before they reach the network.
425+ // Without it, send the person to the Files page to upload directly.
426+ http.Redirect(writer, request, requestPrefix(request)+"/files", http.StatusSeeOther)
427+ return
428+ }
429 if request.Method != http.MethodGet && request.Method != http.MethodHead {
430 http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
431 return
432@@ -199,7 +205,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
433 _, _ = writer.Write(serviceWorkerJS)
434 return
435 }
436- if png, ok := map[string][]byte{"/icon-192.png": icon192PNG, "/icon-512.png": icon512PNG, "/icon-maskable-512.png": iconMaskablePNG, "/apple-touch-icon.png": appleTouchIconPNG}[request.URL.Path]; ok {
437+ if png, ok := map[string][]byte{"/icon-192.png": icon192PNG, "/icon-512.png": icon512PNG, "/icon-maskable-512.png": iconMaskablePNG, "/apple-touch-icon.png": appleTouchIconPNG, "/badge-96.png": badgePNG}[request.URL.Path]; ok {
438 writer.Header().Set("content-type", "image/png")
439 writer.Header().Set("cache-control", "public, max-age=86400")
440 _, _ = writer.Write(png)
441@@ -214,6 +220,10 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
442 }
443 return
444 }
445+ if request.URL.Path == "/open" {
446+ a.openLink(writer, request)
447+ return
448+ }
449 if request.URL.Path == "/manifest.webmanifest" {
450 a.manifest(writer, request)
451 return

About

ownerc01503e8035f
visibilitypublic
headd5f8a6f473d6

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs