tinyrelaycommit

Commit 0ace0e427892

browse files at this commit | history

1diff --git a/docs/fixi.md b/docs/fixi.md
2index 74a4441..5296908 100644
3--- a/docs/fixi.md
4+++ b/docs/fixi.md
5@@ -12,7 +12,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
6
7 The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.
8
9-Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.
10+Signed-in browsers read the connection list and the query bridge with their session, so opening a page never asks the signer; signatures are requested only when publishing or decrypting. Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.
11
12 On phones, navigation starts closed and opens from the menu control, with or without JavaScript. Opening the menu leaves content in place. Desktop pages retain the sidebar.
13
14diff --git a/internal/daemon/http.go b/internal/daemon/http.go
15index aaf918e..0af5d8b 100644
16--- a/internal/daemon/http.go
17+++ b/internal/daemon/http.go
18@@ -225,6 +225,26 @@ func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) {
19 return s, nil
20 }
21
22+// cookieReadSession lets a signed-in browser read through the query bridge
23+// without a signature, so pages do not prompt the signer just to show data.
24+// The Origin check keeps another site from reading with a visitor's cookie;
25+// publishing still needs a signed event.
26+func (t *Tenant) cookieReadSession(r *http.Request, s relay.Session) (relay.Session, error) {
27+ base, err := url.Parse(t.requestURL(r))
28+ if err != nil || r.Header.Get("Origin") != base.Scheme+"://"+base.Host {
29+ return s, errors.New("auth-required: sign this request")
30+ }
31+ actor, err := t.cookieActor(r)
32+ if err != nil {
33+ return s, err
34+ }
35+ if actor == "" {
36+ return s, errors.New("auth-required: sign this request")
37+ }
38+ s.PubKeys = []string{actor}
39+ return s, nil
40+}
41+
42 func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {
43 body, err := t.requestBody(w, r)
44 if err != nil {
45@@ -232,6 +252,9 @@ func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {
46 return
47 }
48 s, err := t.session(r, body)
49+ if err != nil && r.URL.Path != "/events" && r.Header.Get("Authorization") == "" {
50+ s, err = t.cookieReadSession(r, s)
51+ }
52 if err != nil {
53 writeJSON(w, 401, map[string]string{"error": err.Error()})
54 return
55diff --git a/internal/daemon/session_test.go b/internal/daemon/session_test.go
56index b0b9e53..0b1d9d7 100644
57--- a/internal/daemon/session_test.go
58+++ b/internal/daemon/session_test.go
59@@ -58,6 +58,31 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) {
60 if err != nil || actor != tenant.Policy().Owner {
61 t.Fatalf("session actor %s %v", actor, err)
62 }
63+ // Reads through the query bridge use the session when the request comes
64+ // from this origin; foreign origins and publishing still need a signature.
65+ query := func(path, origin string) int {
66+ req := httptest.NewRequest("POST", "http://relay.test"+path, strings.NewReader(`[{"kinds":[10318],"limit":1}]`))
67+ req.Header.Set("Content-Type", "application/json")
68+ if origin != "" {
69+ req.Header.Set("Origin", origin)
70+ }
71+ req.AddCookie(cookies[0])
72+ res := httptest.NewRecorder()
73+ a.ServeHTTP(res, req)
74+ return res.Code
75+ }
76+ if code := query("/query", "http://relay.test"); code != 200 {
77+ t.Fatalf("session query %d", code)
78+ }
79+ if code := query("/query", "https://foreign.test"); code != 401 {
80+ t.Fatalf("foreign origin query %d", code)
81+ }
82+ if code := query("/query", ""); code != 401 {
83+ t.Fatalf("query without origin %d", code)
84+ }
85+ if code := query("/events", "http://relay.test"); code != 401 {
86+ t.Fatalf("cookie publish %d", code)
87+ }
88 logout := httptest.NewRequest("POST", "http://relay.test/session/logout", nil)
89 logout.AddCookie(cookies[0])
90 logout.Header.Set("Origin", "https://foreign.test")
91diff --git a/internal/webui/components.js b/internal/webui/components.js
92index a951050..d0cfb0b 100644
93--- a/internal/webui/components.js
94+++ b/internal/webui/components.js
95@@ -607,7 +607,7 @@
96 const select = event.target.closest("select[name=visibility]");
97 if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));
98 });
99- this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));
100+ this.load().catch(err => this.say("error: " + err.message, true));
101 }
102
103 // whenSigned runs once a signer is available; the bridge announces a
104@@ -624,8 +624,23 @@
105 else delete this.output.dataset.error;
106 }
107
108+ // load reads with the browser session first so showing the page never
109+ // asks the signer; a signed call is the fallback without a session.
110 async load() {
111 this.say("loading…");
112+ const response = await fetch(tiny.localPath("/connect.json?catalog=1"), {credentials: "same-origin", headers: {Accept: "application/json"}});
113+ if (response.status === 401) {
114+ this.whenSigned(() => this.loadSigned().catch(err => this.say("error: " + err.message, true)));
115+ return;
116+ }
117+ if (!response.ok) throw Error("Request failed (" + response.status + ")");
118+ const data = await response.json();
119+ this.rows = data.connections;
120+ this.catalog = data.catalog;
121+ this.render();
122+ this.say("");
123+ }
124+ async loadSigned() {
125 [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);
126 this.render();
127 this.say("");
128diff --git a/internal/webui/manage.html b/internal/webui/manage.html
129index 61e2ce7..d9ddef3 100644
130--- a/internal/webui/manage.html
131+++ b/internal/webui/manage.html
132@@ -112,10 +112,10 @@
133 </details>
134 <h3>Publish signed relay records</h3>
135 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>
136-<private-services>
137+<private-services pubkey="{{.Actor}}">
138 <h3>Private Git relay list</h3>
139 <p>GRASP-08 stores private relay URLs in your encrypted kind 10318 list. The relay URLs and any list entries are encrypted to your signer with NIP-44.</p>
140-<form><label>Private relay URLs, one per line <textarea name="lines" placeholder="wss://private-relay.example"></textarea></label><button disabled>Publish encrypted list</button></form>
141+<form><label>Private relay URLs, one per line <textarea name="lines" placeholder="wss://private-relay.example"></textarea></label><button disabled>Publish encrypted list</button> <button type="button" data-decrypt hidden>Decrypt to edit</button></form>
142 <noscript><p>Connect a signer and enable JavaScript to encrypt your private service list.</p></noscript>
143 <output role="status"></output>
144 </private-services>
145diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js
146index 1435b7b..ef3b280 100644
147--- a/internal/webui/private-services.js
148+++ b/internal/webui/private-services.js
149@@ -9,17 +9,33 @@
150 const relay = value => relayURL(value, {strict: true});
151 const mergeRows = (rows, urls) =>
152 rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));
153- const publicKey = async () => {
154+ const publicKey = async element => {
155+ const known = element?.getAttribute("pubkey");
156+ if (/^[0-9a-f]{64}$/.test(known || "")) return known;
157 const active = signer();
158 if (active?.getPublicKey) return active.getPublicKey();
159 if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();
160 throw Error("Connect a signer first.");
161 };
162+ // readList uses the browser session when the page has one, so showing
163+ // the list never asks the signer; a signed query is the fallback.
164 const readList = async pubkey => {
165 const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);
166- const response = await window.tiny.signedFetch("/query", "POST", body, {
167- contentType: "application/json"
168- });
169+ let response = null;
170+ if (typeof fetch === "function" && window.tiny?.localPath) {
171+ response = await fetch(window.tiny.localPath("/query"), {
172+ method: "POST",
173+ credentials: "same-origin",
174+ headers: {"content-type": "application/json"},
175+ body
176+ });
177+ if (response.status === 401) response = null;
178+ }
179+ if (!response) {
180+ response = await window.tiny.signedFetch("/query", "POST", body, {
181+ contentType: "application/json"
182+ });
183+ }
184 if (!response || response.ok === false) throw Error("Could not read the private relay list.");
185 const rows = await response.json();
186 if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");
187@@ -83,13 +99,14 @@
188 this.form.querySelector("button").disabled = false;
189 });
190 });
191- this.whenSigned(() =>
192- this.load()
193- .then(() => {
194- this.form.querySelector("button").disabled = false;
195- })
196- .catch(error => this.say("Error: " + error.message, true))
197- );
198+ this.querySelector("[data-decrypt]")?.addEventListener("click", () => {
199+ this.whenSigned(() => this.reveal().catch(error => this.say("Error: " + error.message, true)));
200+ });
201+ this.load()
202+ .then(() => {
203+ this.form.querySelector("button").disabled = false;
204+ })
205+ .catch(error => this.say("Error: " + error.message, true));
206 }
207
208 // whenSigned runs once a signer is available. The bridge announces a
209@@ -106,10 +123,24 @@
210 else delete this.output.dataset.error;
211 }
212
213+ // load reads the published event without decrypting it. Decryption
214+ // asks the signer, so it waits for the Decrypt to edit control.
215 async load() {
216 this.say("Reading encrypted list…");
217- const pubkey = await publicKey();
218- const event = await readList(pubkey);
219+ const pubkey = await publicKey(this);
220+ this.event = await readList(pubkey);
221+ if (!this.event) {
222+ this.say("No private relay list published yet.");
223+ return;
224+ }
225+ const control = this.querySelector("[data-decrypt]");
226+ if (control) control.hidden = false;
227+ this.say("An encrypted list is published. Decrypt to edit it, or publish new URLs to merge them.");
228+ }
229+
230+ async reveal() {
231+ const pubkey = await publicKey(this);
232+ const event = this.event || (await readList(pubkey));
233 if (!event) {
234 this.say("No private relay list published yet.");
235 return;
236@@ -132,7 +163,7 @@
237 throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");
238 const urls = rawURLs.map(relay);
239 const unique = [...new Set(urls)];
240- const pubkey = await publicKey();
241+ const pubkey = await publicKey(this);
242 const existing = await readList(pubkey);
243 let rows = [];
244 if (existing) {
245diff --git a/internal/webui/webui.go b/internal/webui/webui.go
246index 2092ec9..dd502d7 100644
247--- a/internal/webui/webui.go
248+++ b/internal/webui/webui.go
249@@ -397,7 +397,18 @@ func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) {
250 writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})
251 return
252 }
253- writeJSON(writer, http.StatusOK, result)
254+ if request.URL.Query().Get("catalog") == "" {
255+ writeJSON(writer, http.StatusOK, result)
256+ return
257+ }
258+ // The Connect page reads the list and the card catalog with its session
259+ // so showing the page never asks the signer for a signature.
260+ catalog, err := a.backend.Query(request.Context(), "listconnectiontemplates", nil, actor)
261+ if err != nil {
262+ writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})
263+ return
264+ }
265+ writeJSON(writer, http.StatusOK, map[string]any{"connections": result, "catalog": catalog})
266 }
267
268 func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {

About

ownerc01503e8035f
visibilitypublic
head0ace0e427892

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs