clone

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

fix: preserve login destinations and compact account relay lists

Dami committed e4a66fe
Return extension, phone and bunker sign-ins to the originating tenant page, query and anchor. Keep private URL fragments in the browser and replace the sign-in history entry.

Make relay list setup optional and collapsed on the account page, with compact mobile rows and wrapping controls.

Validation: Web UI tests and race checks, go vet, 181 JavaScript tests, bundle verification, and browser login/layout checks at phone and desktop widths.
commit
e4a66fec2474fbde4f4dc2844faebbd00dc150b0
parent
67e412f92edeeb413231986b2133f84c877ffd07
author
Dami <dami.etoile@gmail.com>
16 files changed, +201 -32
Minternal/webui/approvals_test.go2+-
Minternal/webui/bridge.js43++++++++++++++++++++-
Minternal/webui/browse.html2+-
Minternal/webui/collaboration.html4+-
Minternal/webui/page.html8++--
Minternal/webui/profile_test.go2+-
Minternal/webui/public.html7++-
Minternal/webui/rooms.html8++--
Minternal/webui/rooms_test.go6+-
Ainternal/webui/signin.go32+++++++++++++++
Minternal/webui/signin_test.go36++++++++++++++++-
Minternal/webui/style.css10+++++
Minternal/webui/templates.go1+
Minternal/webui/wiki.html6+-
Minternal/webui/wiki_test.go4+-
Mscripts/signer.test.mjs62+++++++++++++++++++++++++++---
M internal/webui/approvals_test.go +1 -1
M internal/webui/bridge.js +42 -1
@@ -139,11 +139,52 @@
139139 Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});140140 window.tinySignedFetch = signedFetch;141141 142+ const isSignIn = url => url.pathname.replace(/\/+$/, "") === localPath("/signin");143+ const returnPath = value => {144+ if (!value?.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(value)) return null;145+ try {146+ const url = new URL(value, location.origin);147+ const tenant = (url.pathname.match(/^\/r\/[^/]+/) || [""])[0];148+ if (url.origin !== location.origin || tenant !== root || isSignIn(url)) return null;149+ return url.pathname + url.search + url.hash;150+ } catch { return null; }151+ };152+ const signInDestination = () => {153+ const current = new URL(location.href);154+ if (!isSignIn(current)) return returnPath(current.pathname + current.search + current.hash) || localPath("/");155+ const carried = new URLSearchParams(current.hash.slice(1)).get("return");156+ const next = returnPath(carried) || returnPath(current.searchParams.get("next"));157+ if (next) return next;158+ try {159+ const previous = new URL(document.referrer);160+ if (previous.origin === location.origin) return returnPath(previous.pathname + previous.search) || localPath("/");161+ } catch {}162+ return localPath("/");163+ };164+165+ // Keep fragments client-side: file links may carry decryption keys. The166+ // server-rendered next query is still a usable fallback without JavaScript.167+ document.addEventListener("click", event => {168+ const link = event.target?.closest?.("a[href]");169+ if (!link) return;170+ const current = new URL(location.href);171+ let login;172+ try { login = new URL(link.href, location.href); } catch { return; }173+ if (login.origin !== location.origin || !isSignIn(login) || isSignIn(current)) return;174+ const target = returnPath(current.pathname + current.search + current.hash);175+ if (!target) return;176+ login.searchParams.set("next", current.pathname + current.search);177+ login.hash = current.hash ? "return=" + encodeURIComponent(target) : "";178+ link.href = login.href;179+ if (link.hasAttribute("fx-action")) link.setAttribute("fx-action", login.href);180+ }, true);181+142182 const signIn = async () => {183+ const destination = signInDestination();143184 say("Signing in…");144185 await signedSession("/session");145186 say("Signed in.");146- location.assign(localPath("/"));187+ location.replace(destination);147188 };148189 149190 // A remote signer session lives in this tab unless the person chose to
M internal/webui/browse.html +1 -1
M internal/webui/collaboration.html +2 -2
@@ -16,7 +16,7 @@
1616 <label>Title <input name="title" required maxlength="300"></label><label>Labels <input name="labels" placeholder="bug, help wanted"></label>1717 {{if not $issues}}<label>Commit <input name="commit" required pattern="[0-9a-f]{40}"></label><label>Clone URL <input name="clone" type="url" required placeholder="https://example.com/project.git"></label><label>Merge base <input name="merge-base" pattern="[0-9a-f]{40}"></label>{{end}}1818 <label>Description <textarea name="content" required></textarea></label><button>Sign and publish</button>19-</nostr-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript></details>{{else}}<p><a href="/signin">Sign in</a> to start a conversation.</p>{{end}}19+</nostr-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript></details>{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to start a conversation.</p>{{end}}2020 </section>2121 {{else}}{{$item := asMap (index $data "item")}}{{$diff := reviewDiff .Event .Query .Actor}}{{$anchor := reviewAnchor .Query}}<section id="collaboration-detail">2222 <p><a href="{{if $issues}}{{repoURL .Query "issues" ""}}{{else}}{{repoURL .Query "prs" ""}}{{end}}">{{if $issues}}All issues{{else}}All pull requests{{end}}</a></p>
@@ -36,7 +36,7 @@
3636 {{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}3737 {{if .Actor}}<h2>Reply</h2>{{with $anchor}}<p id="reply-anchor">Commenting on <code>{{.File}}</code> line {{.Line}} ({{.Side}} side). <a href="{{repoURL $.Query "pr" (str (index $item "id"))}}">Comment on the whole change instead</a></p>{{end}}<nostr-compose id="reply-compose" kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}">{{with $anchor}}<input type="hidden" name="file" value="{{.File}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}<label>Comment <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose>3838 {{if index $data "can_status"}}<details id="status-controls"><summary>Change status</summary><nostr-compose kind="status" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Status <select name="status"><option value="1630">Reopen</option><option value="1631">{{if $issues}}Resolve{{else}}Mark merged{{end}}</option><option value="1632">Close</option><option value="1633">Draft</option></select></label><label>Comment <textarea name="content"></textarea></label><button>Sign and update</button></nostr-compose></details>{{end}}39-<noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="/signin">Sign in</a> to reply.</p>{{end}}39+<noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>{{end}}4040 </section>{{end}}{{end}}4141 4242 {{/* collaborationProposal is the bar above a proposed issue, pull request or comment: its state for the people who may see it, with the decision for those who may make it. */}}
M internal/webui/page.html +4 -4
@@ -71,7 +71,7 @@ then feature modules are loaded for the current page. */}}
7171 {{end}}</div></td>7272 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>7373 </tr></tbody></table>74-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>74+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="{{signinURL .Base .Path .Query}}">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>7575 <script src="{{scriptURL "tiny.js"}}"></script>7676 <script src="{{scriptURL "bridge.js"}}"></script>7777 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
@@ -89,7 +89,7 @@ then feature modules are loaded for the current page. */}}
8989 9090 {{/* The rail is the sitemap for the current context: the relay, one repository, or management. */}}9191 {{define "rail"}}{{$kind := railKind .Tab}}{{if .Private}}<header><span><a id="mark" href="/"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>92-<nav><a href="/signin" aria-current="page">/signin</a></nav>92+<nav><a href="{{signinURL .Base .Path .Query}}" aria-current="page">/signin</a></nav>9393 <p>Private relay. Sign in with a Nostr signer to continue.</p>9494 {{else if eq $kind "repo"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/repos" title="{{.Query.Get "owner"}}">{{shortID (npub (.Query.Get "owner"))}}</a>/<b>{{.Query.Get "repo"}}</b></span></header>9595 <nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>
@@ -119,7 +119,7 @@ then feature modules are loaded for the current page. */}}
119119 {{end}}{{end}}120120 121121 {{define "panel-relay"}}<section><h4>About</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Identity}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>search</th><td>{{.Policy.Features.Search}}</td></tr>{{if .Policy.Features.Grasp}}<tr><th>git</th><td>GRASP-01</td></tr>{{end}}{{if .Policy.Features.Files}}<tr><th>files</th><td>Blossom</td></tr>{{end}}</table></section>122-<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/profile">profile</a></li>{{else}}<li><a href="/signin">sign in</a></li>{{end}}<li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section>122+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/profile">profile</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}<li><a href="/.well-known/nostr.json">NIP-05 names</a></li>{{if .Policy.Features.Pages}}<li><a href="/feed.xml">Atom feed</a></li>{{end}}<li><a href="/terms">terms</a></li></ul></section>123123 <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}124124 125125 {{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section>
@@ -153,7 +153,7 @@ then feature modules are loaded for the current page. */}}
153153 <section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>154154 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}155155 {{define "panel-rooms"}}{{if eq .Tab "rooms"}}<section><h4>Rooms</h4><table><tr><th>rooms</th><td>{{len .Rooms}}</td></tr><tr><th>protocol</th><td>NIP-29</td></tr></table></section>156-<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/rooms#new-room">new room</a></li>{{else}}<li><a href="/signin">sign in</a></li>{{end}}</ul></section>156+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/rooms#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>157157 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>158158 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>159159 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>
M internal/webui/profile_test.go +1 -1
M internal/webui/public.html +4 -3
@@ -31,8 +31,8 @@
3131 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>3232 <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>3333 <p>Use the sign out control in the footer to end this session.</p>34-<h2>Your relay lists</h2>35-<p>Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>34+<details id="relay-setup"><summary>Your relay lists (optional)</summary>35+<p>Choose where other Nostr clients find your notes, messages and files. Add this relay to any lists you want to use. Existing entries stay.</p>3636 <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">3737 <table id="rows">3838 <tbody>
@@ -44,6 +44,7 @@
4444 </table>4545 <output role="status"></output>4646 </relay-lists>47+</details>4748 {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>4849 {{template "signerControls" .}}4950 {{end}}</section>
@@ -51,7 +52,7 @@
5152 5253 {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>5354 <h2>Phone signer</h2>54-<p>Scan the QR code with Amber and approve the connection and sign-in request. This page will open your relay when you finish.</p>55+<p>Scan the QR code with Amber and approve the connection and sign-in request. You will return to the page you opened.</p>5556 <p><label><input type="checkbox" id="remember-signer"> Remember this device</label> <small>Keeps the phone signer connected after the app closes. Sign out to forget it.</small></p>5657 <p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p>5758 <img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240">
M internal/webui/rooms.html +4 -4
@@ -15,14 +15,14 @@ establishes the initial state. */}}
1515 {{end}}{{if .Actor}}<h2 id="new-room">New room</h2>1616 <room-create><label>Name <input name="name" required maxlength="200" placeholder="build"></label><label>Id <input name="id" maxlength="64" pattern="[a-z0-9_-]{1,64}" placeholder="from the name"></label><label>Access <select name="access"><option value="open">open</option><option value="members">members only</option></select></label><label>About <textarea name="about" maxlength="2000"></textarea></label><button>Create room</button></room-create>1717 <noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>18-{{else}}<p><a href="/signin">Sign in</a> to create a room.</p>18+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to create a room.</p>1919 {{end}}</section>2020 {{end}}2121 2222 {{define "room"}}{{if .Error}}<section id="room">2323 <h1>#{{.View}}</h1>2424 {{template "alert" .Error}}25-{{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}}25+{{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to read this room.</p>{{end}}2626 </section>2727 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room" data-viewer="{{.Actor}}">2828 <h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>
@@ -35,7 +35,7 @@ establishes the initial state. */}}
3535 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>3636 {{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Message" placeholder="Message…" title="Mention someone with @npub or @hex" required></textarea><button type="submit" aria-label="Send" title="Send message">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>3737 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>38-{{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>38+{{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to send messages.</p>3939 {{end}}{{end}}</section>4040 {{end}}{{end}}4141
@@ -54,7 +54,7 @@ establishes the initial state. */}}
5454 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>5555 {{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>5656 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>57-{{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>57+{{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>5858 {{end}}{{end}}</section>5959 {{end}}{{end}}6060
M internal/webui/rooms_test.go +3 -3
@@ -125,7 +125,7 @@ func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
125125 }126126 guestApp, _ := roomsApp(t, "")127127 body = roomsPage(t, guestApp, "/rooms")128- if strings.Contains(body, "<room-create>") || !strings.Contains(body, `<a href="/signin">Sign in</a> to create a room.`) || strings.Contains(body, `href="/rooms/build"`) {128+ if strings.Contains(body, "<room-create>") || !strings.Contains(body, `<a href="/signin?next=%2Frooms">Sign in</a> to create a room.`) || strings.Contains(body, `href="/rooms/build"`) {129129 t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])130130 }131131 // The relay rail lists rooms first in the conversation group.
@@ -179,7 +179,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
179179 }180180 guestApp, _ := roomsApp(t, "")181181 body = roomsPage(t, guestApp, "/rooms/general")182- if strings.Contains(body, "<room-compose") || strings.Contains(body, "<room-action") || !strings.Contains(body, `<a href="/signin">Sign in</a> to send messages.`) || !strings.Contains(body, `id="msg-`+roomHello) {182+ if strings.Contains(body, "<room-compose") || strings.Contains(body, "<room-action") || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fgeneral">Sign in</a> to send messages.`) || !strings.Contains(body, `id="msg-`+roomHello) {183183 t.Fatalf("guest room page: %s", body[strings.Index(body, `id="content"`):])184184 }185185 }
@@ -187,7 +187,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
187187 func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {188188 guestApp, _ := roomsApp(t, "")189189 body := roomsPage(t, guestApp, "/rooms/build")190- if !strings.Contains(body, `<p role="alert">auth-required: this room is members-only</p>`) || strings.Contains(body, "<room-message") || strings.Contains(body, "<room-compose") || !strings.Contains(body, `<a href="/signin">Sign in</a> to read this room.`) {190+ if !strings.Contains(body, `<p role="alert">auth-required: this room is members-only</p>`) || strings.Contains(body, "<room-message") || strings.Contains(body, "<room-compose") || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fbuild">Sign in</a> to read this room.`) {191191 t.Fatalf("guest saw a members-only room: %s", body[strings.Index(body, `id="content"`):])192192 }193193 if !strings.Contains(body, `<h1>#build</h1>`) || strings.Contains(body, "Where Build happens") {
A internal/webui/signin.go +32 -0
M internal/webui/signin_test.go +34 -2
@@ -3,6 +3,7 @@ package webui
33 import (44 "net/http"55 "net/http/httptest"6+ "net/url"67 "strings"78 "testing"89 )
@@ -17,8 +18,14 @@ func TestSignInNavigationAndAccount(t *testing.T) {
1718 response := httptest.NewRecorder()1819 app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))1920 body := response.Body.String()20- if actor == "" && !strings.Contains(body, `href="/signin">Sign in</a>`) {21- t.Fatalf("%s missing sign-in navigation", path)21+ if actor == "" {22+ wantLink := `href="/signin?next=`23+ if path == "/signin" {24+ wantLink = `href="/signin"`25+ }26+ if !strings.Contains(body, wantLink) {27+ t.Fatalf("%s missing sign-in navigation", path)28+ }2229 }2330 if actor != "" && (!strings.Contains(body, `title="`+actor+`"`) || strings.Count(body, `id="session-logout"`) != 1) {2431 t.Fatalf("%s missing account or unique sign-out control", path)
@@ -32,3 +39,28 @@ func TestSignInNavigationAndAccount(t *testing.T) {
3239 }3340 }3441 }42+43+func TestSigninURLPreservesTenantPathAndQuery(t *testing.T) {44+ got := signinURL("/r/work", "/rooms/abc", url.Values{45+ "event": {"deadbeef"},46+ "q": {"hello world"},47+ })48+ want := "/signin?next=%2Fr%2Fwork%2Frooms%2Fabc%3Fevent%3Ddeadbeef%26q%3Dhello%2Bworld"49+ if got != want {50+ t.Fatalf("signinURL() = %q, want %q", got, want)51+ }52+}53+54+func TestSigninURLWithoutTenant(t *testing.T) {55+ if got := signinURL("", "/wiki", url.Values{"d": {"A page"}}); got != "/signin?next=%2Fwiki%3Fd%3DA%2Bpage" {56+ t.Fatalf("signinURL() = %q", got)57+ }58+}59+60+func TestSigninURLDoesNotNestSignin(t *testing.T) {61+ for _, base := range []string{"", "/r/work"} {62+ if got := signinURL(base, "/signin", url.Values{"next": {"/rooms"}}); got != "/signin" {63+ t.Fatalf("signinURL(%q) = %q, want /signin", base, got)64+ }65+ }66+}
M internal/webui/style.css +10 -0
@@ -383,6 +383,16 @@ html,body{overflow:auto}
383383 #signin #remember-signer{width:auto;min-width:0}384384 #signin p{max-width:60ch}385385 #signin #signer-qr:not([hidden]){width:min(200px,100%)}386+/* Relay lists are compact action cards on phones. The desktop column widths387+ otherwise force the fixed table wider than the content lane and let the388+ browser distribute the excess height into each row. */389+relay-lists #rows{table-layout:auto;width:100%}390+relay-lists #rows,relay-lists #rows tbody,relay-lists #rows tr{display:block;width:auto}391+relay-lists #rows tr{padding:10px 0;border-bottom:1px solid var(--line)}392+relay-lists #rows td{display:block;width:auto;padding:0 0 5px;border:0;overflow-wrap:anywhere}393+relay-lists #rows td:first-child,relay-lists #rows td:last-child{width:auto}394+relay-lists #rows td:last-child{padding-top:3px;white-space:normal}395+relay-lists #rows td:last-child button{margin:2px 6px 2px 0}386396 room-message[data-own]{margin-left:12px;padding-right:10px}387397 /* Phones: each wiki row reads as a title line and one muted line of facts. */388398 #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "}
M internal/webui/templates.go +1 -0
M internal/webui/wiki.html +3 -3
@@ -17,7 +17,7 @@
1717 {{define "wikipage"}}{{$d := .Query.Get "d"}}{{if .Error}}<section id="wiki-page">1818 <h1>{{$d}}</h1>1919 {{if hasPrefix .Error "not found:"}}<p>There is no page named <code>{{$d}}</code> yet.</p>20-{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="/signin">Sign in</a> to write it.</p>{{end}}20+{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to write it.</p>{{end}}2121 {{else}}{{template "alert" .Error}}{{end}}</section>2222 {{else}}{{$w := wikiView .Event}}<section id="wiki-page">2323 <h1>{{$w.Title}}</h1>
@@ -73,8 +73,8 @@
7373 <p><a href="{{wikiURL $w.D}}">back to the page</a></p>{{end}}7474 7575 {{define "panel-wiki"}}{{if eq .Tab "wiki"}}<section><h4>Wiki</h4><table><tr><th>protocol</th><td>NIP-54</td></tr><tr><th>markup</th><td>Djot</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr></table></section>76-<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>76+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in to write</a></li>{{end}}</ul></section>7777 {{else}}{{$w := wikiView .Event}}<section><h4>Page</h4><table><tr><th>name</th><td><code>{{or $w.D (.Query.Get "d")}}</code></td></tr><tr><th>versions</th><td>{{$w.Count}}</td></tr>{{if $w.History}}<tr><th>revisions</th><td>{{len $w.History}}</td></tr>{{end}}<tr><th>merge requests</th><td>{{$w.OpenCount}} open</td></tr><tr><th>links</th><td>{{$w.Links}}</td></tr></table></section>7878 {{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}{{$s := wikiProposal . $.Actor $w.CanApprove}}<li{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}}{{if $s.Shown}} | proposed {{$s.State}}{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>7979 {{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>80-{{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}80+{{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}
M internal/webui/wiki_test.go +3 -1
M scripts/signer.test.mjs +56 -6
@@ -7,20 +7,21 @@ import vm from "node:vm";
77 const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");88 const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");99 10-async function page({signedIn = false, bunkerResult = null} = {}) {10+async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = ""} = {}) {1111 const elements = new Map();1212 for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) {1313 elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}});1414 }1515 const saved = new Map();16+ const documentEvents = new Map();1617 const requests = [];17- const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};18+ const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, connect: async () => {}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};1819 const sandbox = {1920 tiny: {},20- document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, dispatchEvent() {}, documentElement: {dataset: {}}},21- crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,21+ document: {getElementById: id => elements.get(id), querySelector: () => null, referrer, addEventListener(name, handler) {documentEvents.set(name, handler);}, dispatchEvent() {}, documentElement: {dataset: {}}},22+ crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer,2223 btoa: value => Buffer.from(value).toString("base64"),23- location: {pathname: "/r/work/signin", href: "https://tiny.example/r/work/signin", origin: "https://tiny.example", assign(url) { sandbox.opened = url; }, reload() { sandbox.reloaded = true; }},24+ location: {pathname: new URL(href).pathname, href, origin: new URL(href).origin, assign(url) { sandbox.opened = url; }, replace(url) { sandbox.opened = url; sandbox.replaced = true; }, reload() { sandbox.reloaded = true; }},2425 navigator: {clipboard: {writeText: async () => {} }},2526 getComputedStyle: () => ({getPropertyValue: () => ""}),2627 matchMedia: () => ({matches: false, addEventListener() {}}),
@@ -37,7 +38,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
3738 sandbox.window = sandbox;3839 vm.runInNewContext(shared + bridge, sandbox);3940 await new Promise(resolve => setTimeout(resolve, 0));40- return {elements, saved, requests, sandbox};41+ return {elements, saved, requests, sandbox, documentEvents};4142 }4243 4344 test("approving a phone signer signs in and opens the tenant homepage", async () => {
@@ -75,3 +76,52 @@ test("sign out revokes the session without requiring a connected phone", async (
7576 assert.equal(requests[0].options.credentials, "same-origin");7677 assert.equal(sandbox.reloaded, true);7778 });79+80+test("sign-in returns to the requested page and replaces the login history entry", async () => {81+ const target = "/r/work/rooms/hermes/thread/" + "d".repeat(64) + "?view=thread#msg-123";82+ const href = "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target.split("#")[0]) + "#return=" + encodeURIComponent(target);83+ for (const mode of ["extension", "phone", "bunker"]) {84+ const {elements, sandbox} = await page({href, bunkerResult: {pubkey: "a".repeat(64), relays: ["wss://relay.test"]}});85+ if (mode === "extension") {86+ sandbox.nostr = {signEvent: async event => event};87+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});88+ } else if (mode === "phone") {89+ await elements.get("nostrconnect").click({currentTarget: elements.get("nostrconnect"), preventDefault() {}});90+ } else {91+ elements.get("bunker-url").value = "bunker://test";92+ await elements.get("bunker").submit({currentTarget: elements.get("bunker"), preventDefault() {}});93+ }94+ assert.equal(sandbox.opened, target, mode);95+ assert.equal(sandbox.replaced, true, mode);96+ }97+});98+99+test("embedded login keeps the protected page, query and fragment", async () => {100+ const target = "/r/work/manage/connect?view=client#setup";101+ const {elements, sandbox} = await page({href: "https://tiny.example" + target});102+ sandbox.nostr = {signEvent: async event => event};103+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});104+ assert.equal(sandbox.opened, target);105+});106+107+test("return targets reject external addresses, tenant changes and sign-in loops", async () => {108+ for (const target of ["https://evil.test", "//evil.test", "/r/elsewhere/rooms", "/r/work/signin", "/r/work/signin?next=/r/work/rooms", "/r/work/../../r/other/", "/\\evil.test", "/r/work/rooms\n"]) {109+ const {elements, sandbox} = await page({href: "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target)});110+ sandbox.nostr = {signEvent: async event => event};111+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});112+ assert.equal(sandbox.opened, "/r/work/", target);113+ }114+});115+116+test("sign-in links carry private fragments only in the browser fragment", async () => {117+ const target = "/r/work/file?hash=abc#key=private-key&name=notes.txt";118+ const {sandbox, documentEvents} = await page({href: "https://tiny.example" + target});119+ const attrs = new Map([["fx-action", "/r/work/signin"]]);120+ const link = {href: "https://tiny.example/r/work/signin", hasAttribute: name => attrs.has(name), setAttribute: (name,value) => attrs.set(name,value)};121+ documentEvents.get("click")({target: {closest: () => link}});122+ const login = new URL(link.href, sandbox.location.href);123+ assert.equal(login.searchParams.get("next"), "/r/work/file?hash=abc");124+ assert.equal(login.search.includes("private-key"), false);125+ assert.equal(new URLSearchParams(login.hash.slice(1)).get("return"), target);126+ assert.equal(attrs.get("fx-action"), link.href);127+});