tinyrelaycommit

Commit e4a66fec2474

browse files at this commit | history

1diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go
2index cab81cc..62883bf 100644
3--- a/internal/webui/approvals_test.go
4+++ b/internal/webui/approvals_test.go
5@@ -114,7 +114,7 @@ func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {
6 recorder := httptest.NewRecorder()
7 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))
8 body := recorder.Body.String()
9- if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin">Sign in</a> to see the requests addressed to your key.`) {
10+ if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin?next=%2Fapprovals">Sign in</a> to see the requests addressed to your key.`) {
11 t.Fatalf("guest page %d:\n%s", recorder.Code, body)
12 }
13 if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {
14diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
15index 09d0325..ce199b9 100644
16--- a/internal/webui/bridge.js
17+++ b/internal/webui/bridge.js
18@@ -139,11 +139,52 @@
19 Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});
20 window.tinySignedFetch = signedFetch;
21
22+ const isSignIn = url => url.pathname.replace(/\/+$/, "") === localPath("/signin");
23+ const returnPath = value => {
24+ if (!value?.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(value)) return null;
25+ try {
26+ const url = new URL(value, location.origin);
27+ const tenant = (url.pathname.match(/^\/r\/[^/]+/) || [""])[0];
28+ if (url.origin !== location.origin || tenant !== root || isSignIn(url)) return null;
29+ return url.pathname + url.search + url.hash;
30+ } catch { return null; }
31+ };
32+ const signInDestination = () => {
33+ const current = new URL(location.href);
34+ if (!isSignIn(current)) return returnPath(current.pathname + current.search + current.hash) || localPath("/");
35+ const carried = new URLSearchParams(current.hash.slice(1)).get("return");
36+ const next = returnPath(carried) || returnPath(current.searchParams.get("next"));
37+ if (next) return next;
38+ try {
39+ const previous = new URL(document.referrer);
40+ if (previous.origin === location.origin) return returnPath(previous.pathname + previous.search) || localPath("/");
41+ } catch {}
42+ return localPath("/");
43+ };
44+
45+ // Keep fragments client-side: file links may carry decryption keys. The
46+ // server-rendered next query is still a usable fallback without JavaScript.
47+ document.addEventListener("click", event => {
48+ const link = event.target?.closest?.("a[href]");
49+ if (!link) return;
50+ const current = new URL(location.href);
51+ let login;
52+ try { login = new URL(link.href, location.href); } catch { return; }
53+ if (login.origin !== location.origin || !isSignIn(login) || isSignIn(current)) return;
54+ const target = returnPath(current.pathname + current.search + current.hash);
55+ if (!target) return;
56+ login.searchParams.set("next", current.pathname + current.search);
57+ login.hash = current.hash ? "return=" + encodeURIComponent(target) : "";
58+ link.href = login.href;
59+ if (link.hasAttribute("fx-action")) link.setAttribute("fx-action", login.href);
60+ }, true);
61+
62 const signIn = async () => {
63+ const destination = signInDestination();
64 say("Signing in…");
65 await signedSession("/session");
66 say("Signed in.");
67- location.assign(localPath("/"));
68+ location.replace(destination);
69 };
70
71 // A remote signer session lives in this tab unless the person chose to
72diff --git a/internal/webui/browse.html b/internal/webui/browse.html
73index fd53502..36b2781 100644
74--- a/internal/webui/browse.html
75+++ b/internal/webui/browse.html
76@@ -56,7 +56,7 @@
77 {{define "approvals"}}<section>
78 <h1>Approvals</h1>
79 <p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p>
80-{{if not .Actor}}<p><a href="/signin">Sign in</a> to see the requests addressed to your key.</p>
81+{{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to see the requests addressed to your key.</p>
82 {{else if .Error}}{{template "alert" .Error}}
83 {{else}}{{$open := approvalViews .Event .URL "open"}}{{$done := approvalViews .Event .URL "answered" "expired"}}{{if $open}}{{range $item := $open}}<approval-item id="approval-{{$item.ID}}" data-type="{{$item.Type}}" data-state="{{$item.State}}">
84 <header>{{template "nostrKey" $item.Asker}} in {{$item.Where}} | <time datetime="{{datetime $item.CreatedAt}}">{{when $item.CreatedAt}}</time>{{if $item.Expires}} | expires <time datetime="{{datetime $item.Expires}}">{{when $item.Expires}}</time>{{end}}</header>
85diff --git a/internal/webui/collaboration.html b/internal/webui/collaboration.html
86index 1647cd9..b9ce885 100644
87--- a/internal/webui/collaboration.html
88+++ b/internal/webui/collaboration.html
89@@ -16,7 +16,7 @@
90 <label>Title <input name="title" required maxlength="300"></label><label>Labels <input name="labels" placeholder="bug, help wanted"></label>
91 {{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}}
92 <label>Description <textarea name="content" required></textarea></label><button>Sign and publish</button>
93-</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}}
94+</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}}
95 </section>
96 {{else}}{{$item := asMap (index $data "item")}}{{$diff := reviewDiff .Event .Query .Actor}}{{$anchor := reviewAnchor .Query}}<section id="collaboration-detail">
97 <p><a href="{{if $issues}}{{repoURL .Query "issues" ""}}{{else}}{{repoURL .Query "prs" ""}}{{end}}">{{if $issues}}All issues{{else}}All pull requests{{end}}</a></p>
98@@ -36,7 +36,7 @@
99 {{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}
100 {{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>
101 {{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}}
102-<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}}
103+<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}}
104 </section>{{end}}{{end}}
105
106 {{/* 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. */}}
107diff --git a/internal/webui/page.html b/internal/webui/page.html
108index 04e0c7e..0ff1aea 100644
109--- a/internal/webui/page.html
110+++ b/internal/webui/page.html
111@@ -71,7 +71,7 @@ then feature modules are loaded for the current page. */}}
112 {{end}}</div></td>
113 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
114 </tr></tbody></table>
115-<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>
116+<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>
117 <script src="{{scriptURL "tiny.js"}}"></script>
118 <script src="{{scriptURL "bridge.js"}}"></script>
119 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
120@@ -89,7 +89,7 @@ then feature modules are loaded for the current page. */}}
121
122 {{/* The rail is the sitemap for the current context: the relay, one repository, or management. */}}
123 {{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>
124-<nav><a href="/signin" aria-current="page">/signin</a></nav>
125+<nav><a href="{{signinURL .Base .Path .Query}}" aria-current="page">/signin</a></nav>
126 <p>Private relay. Sign in with a Nostr signer to continue.</p>
127 {{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>
128 <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>
129@@ -119,7 +119,7 @@ then feature modules are loaded for the current page. */}}
130 {{end}}{{end}}
131
132 {{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>
133-<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>
134+<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>
135 <section><h4>Relay</h4><table><tr><th>url</th><td><code>{{.URL}}</code></td></tr></table></section>{{end}}
136
137 {{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>
138@@ -153,7 +153,7 @@ then feature modules are loaded for the current page. */}}
139 <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>
140 <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}}
141 {{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>
142-<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>
143+<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>
144 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>
145 {{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>
146 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>
147diff --git a/internal/webui/profile_test.go b/internal/webui/profile_test.go
148index f445dcd..6f26041 100644
149--- a/internal/webui/profile_test.go
150+++ b/internal/webui/profile_test.go
151@@ -79,7 +79,7 @@ func TestProfilePageAsksGuestsToSignIn(t *testing.T) {
152 }
153 recorder = httptest.NewRecorder()
154 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
155- if home := recorder.Body.String(); !strings.Contains(home, `<li><a href="/signin">sign in</a></li>`) || strings.Contains(home, `href="/profile"`) {
156+ if home := recorder.Body.String(); !strings.Contains(home, `<li><a href="/signin?next=%2F">sign in</a></li>`) || strings.Contains(home, `href="/profile"`) {
157 t.Fatal("guest home panel should offer sign in, not the profile")
158 }
159 }
160diff --git a/internal/webui/public.html b/internal/webui/public.html
161index 70714aa..48b27e3 100644
162--- a/internal/webui/public.html
163+++ b/internal/webui/public.html
164@@ -31,8 +31,8 @@
165 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
166 <p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/inbox">Inbox</a></p>
167 <p>Use the sign out control in the footer to end this session.</p>
168-<h2>Your relay lists</h2>
169-<p>Clients find your relays through these lists. Add this relay to each so they use it. What is already listed stays.</p>
170+<details id="relay-setup"><summary>Your relay lists (optional)</summary>
171+<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>
172 <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">
173 <table id="rows">
174 <tbody>
175@@ -44,6 +44,7 @@
176 </table>
177 <output role="status"></output>
178 </relay-lists>
179+</details>
180 {{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>
181 {{template "signerControls" .}}
182 {{end}}</section>
183@@ -51,7 +52,7 @@
184
185 {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>
186 <h2>Phone signer</h2>
187-<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>
188+<p>Scan the QR code with Amber and approve the connection and sign-in request. You will return to the page you opened.</p>
189 <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>
190 <p><button id="nostrconnect" type="button">Generate QR code</button> <a id="nostrconnect-open" hidden>Open in signer</a></p>
191 <img id="signer-qr" alt="Nostr Connect QR code" hidden width="240" height="240">
192diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
193index 79d4c38..fc57789 100644
194--- a/internal/webui/rooms.html
195+++ b/internal/webui/rooms.html
196@@ -15,14 +15,14 @@ establishes the initial state. */}}
197 {{end}}{{if .Actor}}<h2 id="new-room">New room</h2>
198 <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>
199 <noscript><p>Creating a room needs JavaScript and a connected signer.</p></noscript>
200-{{else}}<p><a href="/signin">Sign in</a> to create a room.</p>
201+{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to create a room.</p>
202 {{end}}</section>
203 {{end}}
204
205 {{define "room"}}{{if .Error}}<section id="room">
206 <h1>#{{.View}}</h1>
207 {{template "alert" .Error}}
208-{{if not .Actor}}<p><a href="/signin">Sign in</a> to read this room.</p>{{end}}
209+{{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to read this room.</p>{{end}}
210 </section>
211 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}<section id="room" data-viewer="{{.Actor}}">
212 <h1>{{or (str (index $room "name")) $id}} <small>{{if eq (str (index $room "access")) "members"}}members only{{else}}open room{{end}}</small></h1>
213@@ -35,7 +35,7 @@ establishes the initial state. */}}
214 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>
215 {{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>
216 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>
217-{{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>
218+{{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to send messages.</p>
219 {{end}}{{end}}</section>
220 {{end}}{{end}}
221
222@@ -54,7 +54,7 @@ establishes the initial state. */}}
223 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>
224 {{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>
225 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>
226-{{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>
227+{{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>
228 {{end}}{{end}}</section>
229 {{end}}{{end}}
230
231diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
232index c744345..26cc24a 100644
233--- a/internal/webui/rooms_test.go
234+++ b/internal/webui/rooms_test.go
235@@ -125,7 +125,7 @@ func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) {
236 }
237 guestApp, _ := roomsApp(t, "")
238 body = roomsPage(t, guestApp, "/rooms")
239- 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"`) {
240+ 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"`) {
241 t.Fatalf("guest rooms list: %s", body[strings.Index(body, `id="content"`):])
242 }
243 // The relay rail lists rooms first in the conversation group.
244@@ -179,7 +179,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
245 }
246 guestApp, _ := roomsApp(t, "")
247 body = roomsPage(t, guestApp, "/rooms/general")
248- 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) {
249+ 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) {
250 t.Fatalf("guest room page: %s", body[strings.Index(body, `id="content"`):])
251 }
252 }
253@@ -187,7 +187,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
254 func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {
255 guestApp, _ := roomsApp(t, "")
256 body := roomsPage(t, guestApp, "/rooms/build")
257- 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.`) {
258+ 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.`) {
259 t.Fatalf("guest saw a members-only room: %s", body[strings.Index(body, `id="content"`):])
260 }
261 if !strings.Contains(body, `<h1>#build</h1>`) || strings.Contains(body, "Where Build happens") {
262diff --git a/internal/webui/signin.go b/internal/webui/signin.go
263new file mode 100644
264index 0000000..6f0094a
265--- /dev/null
266+++ b/internal/webui/signin.go
267@@ -0,0 +1,32 @@
268+package webui
269+
270+import (
271+ "net/url"
272+ "strings"
273+)
274+
275+// signinURL returns the sign-in route, carrying the current tenant-local
276+// address so the signer can return the viewer to the page they opened.
277+// Fragments are intentionally absent here because browsers do not send them
278+// in HTTP requests; client-side navigation may preserve one when present.
279+func signinURL(base, path string, query url.Values) string {
280+ path = "/" + strings.TrimPrefix(path, "/")
281+ if path == "/signin" {
282+ return path
283+ }
284+ if base != "" && base != "/" {
285+ path = joinTenantPath(base, path)
286+ }
287+ if encoded := query.Encode(); encoded != "" {
288+ path += "?" + encoded
289+ }
290+ return "/signin?next=" + url.QueryEscape(path)
291+}
292+
293+func joinTenantPath(base, path string) string {
294+ base = strings.TrimSuffix("/"+strings.Trim(base, "/"), "/")
295+ if base == "/" {
296+ return "/" + strings.TrimPrefix(path, "/")
297+ }
298+ return base + "/" + strings.TrimPrefix(path, "/")
299+}
300diff --git a/internal/webui/signin_test.go b/internal/webui/signin_test.go
301index 2c96447..e02cce2 100644
302--- a/internal/webui/signin_test.go
303+++ b/internal/webui/signin_test.go
304@@ -3,6 +3,7 @@ package webui
305 import (
306 "net/http"
307 "net/http/httptest"
308+ "net/url"
309 "strings"
310 "testing"
311 )
312@@ -17,8 +18,14 @@ func TestSignInNavigationAndAccount(t *testing.T) {
313 response := httptest.NewRecorder()
314 app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
315 body := response.Body.String()
316- if actor == "" && !strings.Contains(body, `href="/signin">Sign in</a>`) {
317- t.Fatalf("%s missing sign-in navigation", path)
318+ if actor == "" {
319+ wantLink := `href="/signin?next=`
320+ if path == "/signin" {
321+ wantLink = `href="/signin"`
322+ }
323+ if !strings.Contains(body, wantLink) {
324+ t.Fatalf("%s missing sign-in navigation", path)
325+ }
326 }
327 if actor != "" && (!strings.Contains(body, `title="`+actor+`"`) || strings.Count(body, `id="session-logout"`) != 1) {
328 t.Fatalf("%s missing account or unique sign-out control", path)
329@@ -32,3 +39,28 @@ func TestSignInNavigationAndAccount(t *testing.T) {
330 }
331 }
332 }
333+
334+func TestSigninURLPreservesTenantPathAndQuery(t *testing.T) {
335+ got := signinURL("/r/work", "/rooms/abc", url.Values{
336+ "event": {"deadbeef"},
337+ "q": {"hello world"},
338+ })
339+ want := "/signin?next=%2Fr%2Fwork%2Frooms%2Fabc%3Fevent%3Ddeadbeef%26q%3Dhello%2Bworld"
340+ if got != want {
341+ t.Fatalf("signinURL() = %q, want %q", got, want)
342+ }
343+}
344+
345+func TestSigninURLWithoutTenant(t *testing.T) {
346+ if got := signinURL("", "/wiki", url.Values{"d": {"A page"}}); got != "/signin?next=%2Fwiki%3Fd%3DA%2Bpage" {
347+ t.Fatalf("signinURL() = %q", got)
348+ }
349+}
350+
351+func TestSigninURLDoesNotNestSignin(t *testing.T) {
352+ for _, base := range []string{"", "/r/work"} {
353+ if got := signinURL(base, "/signin", url.Values{"next": {"/rooms"}}); got != "/signin" {
354+ t.Fatalf("signinURL(%q) = %q, want /signin", base, got)
355+ }
356+ }
357+}
358diff --git a/internal/webui/style.css b/internal/webui/style.css
359index 0b25f6c..99efb90 100644
360--- a/internal/webui/style.css
361+++ b/internal/webui/style.css
362@@ -383,6 +383,16 @@ html,body{overflow:auto}
363 #signin #remember-signer{width:auto;min-width:0}
364 #signin p{max-width:60ch}
365 #signin #signer-qr:not([hidden]){width:min(200px,100%)}
366+/* Relay lists are compact action cards on phones. The desktop column widths
367+ otherwise force the fixed table wider than the content lane and let the
368+ browser distribute the excess height into each row. */
369+relay-lists #rows{table-layout:auto;width:100%}
370+relay-lists #rows,relay-lists #rows tbody,relay-lists #rows tr{display:block;width:auto}
371+relay-lists #rows tr{padding:10px 0;border-bottom:1px solid var(--line)}
372+relay-lists #rows td{display:block;width:auto;padding:0 0 5px;border:0;overflow-wrap:anywhere}
373+relay-lists #rows td:first-child,relay-lists #rows td:last-child{width:auto}
374+relay-lists #rows td:last-child{padding-top:3px;white-space:normal}
375+relay-lists #rows td:last-child button{margin:2px 6px 2px 0}
376 room-message[data-own]{margin-left:12px;padding-right:10px}
377 /* Phones: each wiki row reads as a title line and one muted line of facts. */
378 #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:" | "}
379diff --git a/internal/webui/templates.go b/internal/webui/templates.go
380index c620bfd..2c7dbe0 100644
381--- a/internal/webui/templates.go
382+++ b/internal/webui/templates.go
383@@ -271,6 +271,7 @@ func parseTemplates(a *App) (*template.Template, error) {
384 "short": short,
385 "prompt": promptPath,
386 "pageAddress": pageAddress,
387+ "signinURL": signinURL,
388 "railKind": railKind,
389 "repoView": repoView,
390 "relayItems": func() []navItem { return relayNav },
391diff --git a/internal/webui/wiki.html b/internal/webui/wiki.html
392index d567c17..746b487 100644
393--- a/internal/webui/wiki.html
394+++ b/internal/webui/wiki.html
395@@ -17,7 +17,7 @@
396 {{define "wikipage"}}{{$d := .Query.Get "d"}}{{if .Error}}<section id="wiki-page">
397 <h1>{{$d}}</h1>
398 {{if hasPrefix .Error "not found:"}}<p>There is no page named <code>{{$d}}</code> yet.</p>
399-{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="/signin">Sign in</a> to write it.</p>{{end}}
400+{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to write it.</p>{{end}}
401 {{else}}{{template "alert" .Error}}{{end}}</section>
402 {{else}}{{$w := wikiView .Event}}<section id="wiki-page">
403 <h1>{{$w.Title}}</h1>
404@@ -73,8 +73,8 @@
405 <p><a href="{{wikiURL $w.D}}">back to the page</a></p>{{end}}
406
407 {{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>
408-<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>
409+<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>
410 {{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>
411 {{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>
412 {{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>
413-{{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}}
414+{{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}}
415diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go
416index 12e5da2..c8747d2 100644
417--- a/internal/webui/wiki_test.go
418+++ b/internal/webui/wiki_test.go
419@@ -252,7 +252,9 @@ func TestWikiMissingPageOffersTheEditorToMembers(t *testing.T) {
420 wantAll(t, path, body, `<h1>whats-up</h1>`, `There is no page named <code>whats-up</code> yet.`, `<wiki-compose name="whats-up"`, `<th>name</th><td><code>whats-up</code></td>`)
421 wantNone(t, path, body, `role="alert"`)
422 body = wikiGet(t, wikiApp(t, b, ""), path)
423- wantAll(t, path, body, `<a href="/signin">Sign in</a> to write it.`)
424+ if !strings.Contains(body, `<a href="/signin?next=`) || !strings.Contains(body, `">Sign in</a> to write it.`) {
425+ t.Fatalf("%s missing return-preserving sign-in link", path)
426+ }
427 wantNone(t, path, body, "<wiki-compose")
428 }
429
430diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs
431index b895b90..d7a47b2 100644
432--- a/scripts/signer.test.mjs
433+++ b/scripts/signer.test.mjs
434@@ -7,20 +7,21 @@ import vm from "node:vm";
435 const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");
436 const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
437
438-async function page({signedIn = false, bunkerResult = null} = {}) {
439+async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin", referrer = ""} = {}) {
440 const elements = new Map();
441 for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) {
442 elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}});
443 }
444 const saved = new Map();
445+ const documentEvents = new Map();
446 const requests = [];
447- 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"})};
448+ 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"})};
449 const sandbox = {
450 tiny: {},
451- document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, dispatchEvent() {}, documentElement: {dataset: {}}},
452- crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,
453+ document: {getElementById: id => elements.get(id), querySelector: () => null, referrer, addEventListener(name, handler) {documentEvents.set(name, handler);}, dispatchEvent() {}, documentElement: {dataset: {}}},
454+ crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer,
455 btoa: value => Buffer.from(value).toString("base64"),
456- 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; }},
457+ 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; }},
458 navigator: {clipboard: {writeText: async () => {} }},
459 getComputedStyle: () => ({getPropertyValue: () => ""}),
460 matchMedia: () => ({matches: false, addEventListener() {}}),
461@@ -37,7 +38,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
462 sandbox.window = sandbox;
463 vm.runInNewContext(shared + bridge, sandbox);
464 await new Promise(resolve => setTimeout(resolve, 0));
465- return {elements, saved, requests, sandbox};
466+ return {elements, saved, requests, sandbox, documentEvents};
467 }
468
469 test("approving a phone signer signs in and opens the tenant homepage", async () => {
470@@ -75,3 +76,52 @@ test("sign out revokes the session without requiring a connected phone", async (
471 assert.equal(requests[0].options.credentials, "same-origin");
472 assert.equal(sandbox.reloaded, true);
473 });
474+
475+test("sign-in returns to the requested page and replaces the login history entry", async () => {
476+ const target = "/r/work/rooms/hermes/thread/" + "d".repeat(64) + "?view=thread#msg-123";
477+ const href = "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target.split("#")[0]) + "#return=" + encodeURIComponent(target);
478+ for (const mode of ["extension", "phone", "bunker"]) {
479+ const {elements, sandbox} = await page({href, bunkerResult: {pubkey: "a".repeat(64), relays: ["wss://relay.test"]}});
480+ if (mode === "extension") {
481+ sandbox.nostr = {signEvent: async event => event};
482+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});
483+ } else if (mode === "phone") {
484+ await elements.get("nostrconnect").click({currentTarget: elements.get("nostrconnect"), preventDefault() {}});
485+ } else {
486+ elements.get("bunker-url").value = "bunker://test";
487+ await elements.get("bunker").submit({currentTarget: elements.get("bunker"), preventDefault() {}});
488+ }
489+ assert.equal(sandbox.opened, target, mode);
490+ assert.equal(sandbox.replaced, true, mode);
491+ }
492+});
493+
494+test("embedded login keeps the protected page, query and fragment", async () => {
495+ const target = "/r/work/manage/connect?view=client#setup";
496+ const {elements, sandbox} = await page({href: "https://tiny.example" + target});
497+ sandbox.nostr = {signEvent: async event => event};
498+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});
499+ assert.equal(sandbox.opened, target);
500+});
501+
502+test("return targets reject external addresses, tenant changes and sign-in loops", async () => {
503+ 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"]) {
504+ const {elements, sandbox} = await page({href: "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target)});
505+ sandbox.nostr = {signEvent: async event => event};
506+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});
507+ assert.equal(sandbox.opened, "/r/work/", target);
508+ }
509+});
510+
511+test("sign-in links carry private fragments only in the browser fragment", async () => {
512+ const target = "/r/work/file?hash=abc#key=private-key&name=notes.txt";
513+ const {sandbox, documentEvents} = await page({href: "https://tiny.example" + target});
514+ const attrs = new Map([["fx-action", "/r/work/signin"]]);
515+ const link = {href: "https://tiny.example/r/work/signin", hasAttribute: name => attrs.has(name), setAttribute: (name,value) => attrs.set(name,value)};
516+ documentEvents.get("click")({target: {closest: () => link}});
517+ const login = new URL(link.href, sandbox.location.href);
518+ assert.equal(login.searchParams.get("next"), "/r/work/file?hash=abc");
519+ assert.equal(login.search.includes("private-key"), false);
520+ assert.equal(new URLSearchParams(login.hash.slice(1)).get("return"), target);
521+ assert.equal(attrs.get("fx-action"), link.href);
522+});

About

ownerc01503e8035f
visibilitypublic
heade4a66fec2474

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs