clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

accept shares and web+nostr links in the installed app and add the device notification toggle

Dami committed d5f8a6f
commit
d5f8a6f473d69cf49167f4758a9e507aede22b35
parent
a5b469fc4059087d07278775b82ace3450fe31c7
author
Dami <dami.etoile@gmail.com>
10 files changed, +321 -3
Ainternal/webui/badge-96.pngbin
Minternal/webui/components.js55++++++++++++
Minternal/webui/file-workspace.js31+++++++
Minternal/webui/manifest.webmanifest7++
Ainternal/webui/open.go139++++++++++++++++++++++++++++++
Ainternal/webui/open_test.go35++++++++
Minternal/webui/page.html3+-
Minternal/webui/sw.js39++++++++-
Minternal/webui/templates.go3+
Minternal/webui/webui.go12++-
A internal/webui/badge-96.png +0 -0

Binary file.

M internal/webui/components.js +55 -0
@@ -26,6 +26,9 @@
2626 // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>2727 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and2828 // posts it to the relay.29+// <push-toggle>30+// Opts this browser into relay notifications; asks for permission only31+// when pressed and registers the device with a signed request.2932 // <file-upload> and <file-workspace-root> live in file-workspace.js;3033 // <private-services> lives in private-services.js.3134 (() => {
@@ -402,6 +405,57 @@
402405 // JsonView renders any JSON value. Arrays of objects become tables, objects403406 // become definition lists, scalar arrays become lists, and deep nesting404407 // falls back to compact JSON so a response never explodes the page.408+ // PushToggle opts this browser into relay notifications. Nothing happens409+ // until the button is pressed: permission is requested then, and the410+ // device subscription is registered with a signed request.411+ class PushToggle extends HTMLElement {412+ connectedCallback() {413+ if (this.bound) return;414+ this.bound = true;415+ this.innerHTML = '<p><button type="button">Enable on this device</button></p><output role="status"></output>';416+ this.button = this.querySelector("button");417+ this.output = this.querySelector("output");418+ if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {419+ this.button.disabled = true;420+ this.say("This browser does not support notifications.");421+ return;422+ }423+ this.button.addEventListener("click", () => this.toggle().catch(err => this.say("error: " + err.message, true)));424+ this.refresh().catch(() => {});425+ }426+ say(text, error) { this.output.textContent = text; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }427+ async subscription() { return (await navigator.serviceWorker.ready).pushManager.getSubscription(); }428+ async refresh() {429+ const current = await this.subscription();430+ this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint;431+ this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device";432+ if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings.");433+ }434+ async toggle() {435+ this.button.disabled = true;436+ try {437+ if (this.enabled) {438+ const current = await this.subscription();439+ if (current) {440+ await tiny.signedFetch("/push/unsubscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});441+ await current.unsubscribe();442+ }443+ localStorage.removeItem("tiny.push");444+ this.say("Notifications are off on this device.");445+ } else {446+ if ((await Notification.requestPermission()) !== "granted") throw Error("Permission was not granted.");447+ const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json();448+ const registration = await navigator.serviceWorker.ready;449+ const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)}));450+ await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});451+ localStorage.setItem("tiny.push", current.endpoint);452+ this.say("Notifications are on for this device.");453+ }454+ await this.refresh();455+ } finally { this.button.disabled = false; }456+ }457+ }458+405459 class JsonView extends HTMLElement {406460 connectedCallback() {407461 if (this.rendered) return;
@@ -755,6 +809,7 @@
755809 customElements.define("file-tools", FileTools);756810 customElements.define("publish-list", PublishList);757811 customElements.define("nostr-compose", NostrCompose);812+ customElements.define("push-toggle", PushToggle);758813 customElements.define("nostr-key", NostrKey);759814 customElements.define("json-view", JsonView);760815
M internal/webui/file-workspace.js +31 -0
@@ -175,6 +175,37 @@
175175 });176176 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());177177 this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));178+ this.intake().catch(error => this.say(error.message, true));179+ }180+ // intake collects files parked by the service worker for a share from181+ // another app. They wait here until the person presses Upload.182+ async intake() {183+ const id = new URLSearchParams(location.hash.slice(1)).get("share");184+ if (!id || !/^[0-9a-f-]{36}$/.test(id) || !globalThis.caches) return;185+ const cache = await caches.open("tiny-share");186+ const meta = await (await cache.match("/share/" + id + "/meta"))?.json();187+ if (!meta) return;188+ const files = [];189+ for (let index = 0; index < (meta.count || 0); index++) {190+ const response = await cache.match("/share/" + id + "/" + index);191+ if (!response) continue;192+ const blob = await response.blob();193+ files.push(new File([blob], decodeURIComponent(response.headers.get("x-name") || "shared"), {type: response.headers.get("content-type") || blob.type}));194+ }195+ for (const key of await cache.keys()) if (new URL(key.url).pathname.startsWith("/share/" + id + "/")) await cache.delete(key);196+ history.replaceState?.(null, "", location.pathname + location.search);197+ if (files.length) {198+ this.choose(files);199+ this.say(files.length + " shared file" + (files.length === 1 ? "" : "s") + " ready. Press Upload.");200+ return;201+ }202+ const link = meta.url || (meta.text || "").match(/https?:\/\/\S+/)?.[0];203+ const mirror = link && document.querySelector("file-mirror input[name=url]");204+ if (mirror) {205+ mirror.value = link;206+ mirror.closest("details")?.setAttribute("open", "");207+ this.say("Shared link ready to import.");208+ }178209 }179210 // choose records a selection. A folder is recognized from the relative180211 // paths that folder pickers and folder drops attach to their files.
M internal/webui/manifest.webmanifest +7 -0
A internal/webui/open.go +139 -0
@@ -0,0 +1,139 @@
1+package webui2+3+import (4+ "encoding/binary"5+ "encoding/hex"6+ "net/http"7+ "net/url"8+ "regexp"9+ "strconv"10+ "strings"11+)12+13+var hexID = regexp.MustCompile(`^[0-9a-f]{64}$`)14+15+// openLink handles web+nostr links registered through the installed app.16+// It decodes the NIP-19 target and redirects to the page that shows it.17+func (a *App) openLink(writer http.ResponseWriter, request *http.Request) {18+ path, ok := nostrTargetPath(request.URL.Query().Get("target"))19+ if !ok {20+ http.Error(writer, "unsupported nostr link", http.StatusBadRequest)21+ return22+ }23+ http.Redirect(writer, request, requestPrefix(request)+path, http.StatusSeeOther)24+}25+26+// nostrTargetPath maps a nostr: or web+nostr: target to a relay page.27+func nostrTargetPath(target string) (string, bool) {28+ value := strings.TrimSpace(target)29+ for _, prefix := range []string{"web+nostr:", "nostr:"} {30+ if strings.HasPrefix(strings.ToLower(value), prefix) {31+ value = value[len(prefix):]32+ break33+ }34+ }35+ value = strings.Trim(value, "/")36+ if hexID.MatchString(value) {37+ return "/e/" + value, true38+ }39+ hrp, data, ok := bech32Decode(strings.ToLower(value))40+ if !ok {41+ return "", false42+ }43+ switch hrp {44+ case "npub":45+ if len(data) != 32 {46+ return "", false47+ }48+ return "/search?author=" + hex.EncodeToString(data), true49+ case "note":50+ if len(data) != 32 {51+ return "", false52+ }53+ return "/e/" + hex.EncodeToString(data), true54+ case "nprofile", "nevent", "naddr":55+ fields := tlvFields(data)56+ switch hrp {57+ case "nprofile":58+ if len(fields[0]) != 32 {59+ return "", false60+ }61+ return "/search?author=" + hex.EncodeToString(fields[0]), true62+ case "nevent":63+ if len(fields[0]) != 32 {64+ return "", false65+ }66+ return "/e/" + hex.EncodeToString(fields[0]), true67+ default:68+ if len(fields[2]) != 32 || len(fields[3]) != 4 {69+ return "", false70+ }71+ kind := binary.BigEndian.Uint32(fields[3])72+ return "/a/" + strconv.FormatUint(uint64(kind), 10) + ":" + hex.EncodeToString(fields[2]) + ":" + url.PathEscape(string(fields[0])), true73+ }74+ }75+ return "", false76+}77+78+// tlvFields returns the first value of each NIP-19 TLV type.79+func tlvFields(data []byte) map[byte][]byte {80+ fields := map[byte][]byte{}81+ for len(data) >= 2 {82+ kind, length := data[0], int(data[1])83+ if len(data) < 2+length {84+ break85+ }86+ if _, seen := fields[kind]; !seen {87+ fields[kind] = data[2 : 2+length]88+ }89+ data = data[2+length:]90+ }91+ return fields92+}93+94+// bech32Decode verifies a bech32 string and returns its 8-bit payload.95+func bech32Decode(value string) (string, []byte, bool) {96+ separator := strings.LastIndexByte(value, '1')97+ if separator < 1 || separator+7 > len(value) || len(value) > 5000 {98+ return "", nil, false99+ }100+ hrp, encoded := value[:separator], value[separator+1:]101+ values := make([]byte, len(encoded))102+ for i, char := range encoded {103+ position := strings.IndexRune(bech32Charset, char)104+ if position < 0 {105+ return "", nil, false106+ }107+ values[i] = byte(position)108+ }109+ if bech32Polymod(append(bech32Expand(hrp), values...)) != 1 {110+ return "", nil, false111+ }112+ data := values[:len(values)-6]113+ accumulator, bits := 0, 0114+ var result []byte115+ for _, v := range data {116+ accumulator = accumulator<<5 | int(v)117+ bits += 5118+ if bits >= 8 {119+ bits -= 8120+ result = append(result, byte(accumulator>>bits&255))121+ }122+ }123+ if bits >= 5 || accumulator<<(8-bits)&255 != 0 {124+ return "", nil, false125+ }126+ return hrp, result, true127+}128+129+func bech32Expand(hrp string) []byte {130+ expanded := make([]byte, 0, len(hrp)*2+1)131+ for _, c := range hrp {132+ expanded = append(expanded, byte(c)>>5)133+ }134+ expanded = append(expanded, 0)135+ for _, c := range hrp {136+ expanded = append(expanded, byte(c)&31)137+ }138+ return expanded139+}
A internal/webui/open_test.go +35 -0
@@ -0,0 +1,35 @@
1+package webui2+3+import (4+ "encoding/binary"5+ "encoding/hex"6+ "testing"7+)8+9+func TestNostrTargetPathDecodesNIP19Links(t *testing.T) {10+ pubkey := "5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369"11+ raw, _ := hex.DecodeString(pubkey)12+ id := "0f8fc45a6e3cd2d022cf68f3ea0f413665a6d92c3d783e3ab6c2bc324432b5af"13+ idRaw, _ := hex.DecodeString(id)14+ kind := make([]byte, 4)15+ binary.BigEndian.PutUint32(kind, 30023)16+ naddr := append(append(append([]byte{0, 5}, []byte("hello")...), append([]byte{2, 32}, raw...)...), append([]byte{3, 4}, kind...)...)17+ cases := map[string]string{18+ "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2": "/search?author=" + pubkey,19+ "nostr:" + bech32("note", idRaw): "/e/" + id,20+ bech32("nevent", append([]byte{0, 32}, idRaw...)): "/e/" + id,21+ "web+nostr://" + bech32("nprofile", append([]byte{0, 32}, raw...)): "/search?author=" + pubkey,22+ bech32("naddr", naddr): "/a/30023:" + pubkey + ":hello",23+ id: "/e/" + id,24+ }25+ for target, want := range cases {26+ if got, ok := nostrTargetPath(target); !ok || got != want {27+ t.Fatalf("%s: got %q ok=%v, want %q", target, got, ok, want)28+ }29+ }30+ for _, bad := range []string{"", "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc3", "nostr:javascript:alert(1)", "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs4g4gaj"} {31+ if _, ok := nostrTargetPath(bad); ok {32+ t.Fatalf("%q accepted", bad)33+ }34+ }35+}
M internal/webui/page.html +2 -1
@@ -114,7 +114,8 @@
114114 {{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}}115115 116116 {{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>117-{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}117+{{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}118+{{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}}118119 119120 {{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>120121 <section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>
M internal/webui/sw.js +38 -1
@@ -10,9 +10,13 @@ self.addEventListener("activate", event => {
1010 });1111 self.addEventListener("fetch", event => {1212 const request = event.request;13- if (request.method !== "GET") return;1413 const url = new URL(request.url);1514 if (url.origin !== location.origin) return;15+ if (request.method === "POST" && /\/share$/.test(url.pathname)) {16+ event.respondWith(receiveShare(request, url));17+ return;18+ }19+ if (request.method !== "GET") return;1620 const keep = request.mode === "navigate" || shellAsset(url);1721 if (!keep) return;1822 event.respondWith(fetch(request).then(response => {
@@ -23,3 +27,36 @@ self.addEventListener("fetch", event => {
2327 return response;2428 }).catch(async () => (await caches.match(request)) || (request.mode === "navigate" ? caches.match(new Request(url.origin + url.pathname.replace(/\/[^/]*$/, "/"))) : undefined)));2529 });30+31+// Shares from other apps arrive as a form post. The files are parked in a32+// cache under a fresh id and the Files page picks them up from its fragment,33+// so nothing is uploaded until the person presses Upload there.34+const receiveShare = async (request, url) => {35+ const form = await request.formData();36+ const id = crypto.randomUUID();37+ const cache = await caches.open("tiny-share");38+ const files = form.getAll("files").filter(item => typeof item === "object" && "arrayBuffer" in item);39+ 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")}}))));40+ 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}));41+ return Response.redirect(url.pathname.replace(/\/share$/, "/files") + "#share=" + id, 303);42+};43+44+// Notifications are opt-in per device; a push only arrives after the person45+// enabled them on this browser. The payload is the relay's short summary.46+self.addEventListener("push", event => {47+ let data = {};48+ try { data = event.data?.json() || {}; } catch { data = {body: event.data?.text() || ""}; }49+ const base = new URL(self.registration.scope);50+ event.waitUntil(self.registration.showNotification(data.title || "tiny", {51+ 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}52+ }));53+});54+self.addEventListener("notificationclick", event => {55+ event.notification.close();56+ const target = event.notification.data?.url || self.registration.scope;57+ event.waitUntil(self.clients.matchAll({type: "window", includeUncontrolled: true}).then(async list => {58+ const open = list.find(client => client.url.startsWith(self.registration.scope));59+ if (open) { const focused = await open.navigate(target); return focused?.focus(); }60+ return self.clients.openWindow(target);61+ }));62+});
M internal/webui/templates.go +3 -0
M internal/webui/webui.go +11 -1
@@ -151,6 +151,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
151151 a.browse(writer, request)152152 return153153 }154+ if request.URL.Path == "/share" {155+ // The service worker receives shares before they reach the network.156+ // Without it, send the person to the Files page to upload directly.157+ http.Redirect(writer, request, requestPrefix(request)+"/files", http.StatusSeeOther)158+ return159+ }154160 if request.Method != http.MethodGet && request.Method != http.MethodHead {155161 http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)156162 return
@@ -199,7 +205,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
199205 _, _ = writer.Write(serviceWorkerJS)200206 return201207 }202- 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 {208+ 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 {203209 writer.Header().Set("content-type", "image/png")204210 writer.Header().Set("cache-control", "public, max-age=86400")205211 _, _ = writer.Write(png)
@@ -214,6 +220,10 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
214220 }215221 return216222 }223+ if request.URL.Path == "/open" {224+ a.openLink(writer, request)225+ return226+ }217227 if request.URL.Path == "/manifest.webmanifest" {218228 a.manifest(writer, request)219229 return