| 1 | diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go |
| 2 | index 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\"") { |
| 14 | diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js |
| 15 | index 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 |
| 72 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 73 | index 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> |
| 85 | diff --git a/internal/webui/collaboration.html b/internal/webui/collaboration.html |
| 86 | index 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. */}} |
| 107 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 108 | index 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> » <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> » <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> |
| 147 | diff --git a/internal/webui/profile_test.go b/internal/webui/profile_test.go |
| 148 | index 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 | } |
| 160 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 161 | index 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"> |
| 192 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 193 | index 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 | |
| 231 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 232 | index 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") { |
| 262 | diff --git a/internal/webui/signin.go b/internal/webui/signin.go |
| 263 | new file mode 100644 |
| 264 | index 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 | +} |
| 300 | diff --git a/internal/webui/signin_test.go b/internal/webui/signin_test.go |
| 301 | index 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 | +} |
| 358 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 359 | index 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:" | "} |
| 379 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 380 | index 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 }, |
| 391 | diff --git a/internal/webui/wiki.html b/internal/webui/wiki.html |
| 392 | index 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}} |
| 415 | diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go |
| 416 | index 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 | |
| 430 | diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs |
| 431 | index 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 | +}); |