tinyrelaycommit

Commit 67e412f92ede

browse files at this commit | history

1diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
2index df520d1..1259d4c 100644
3--- a/docs/files-and-private-repositories.md
4+++ b/docs/files-and-private-repositories.md
5@@ -1,10 +1,12 @@
6 # Files and private repositories
7
8-Use **Files** to search your uploads, import a public HTTPS URL or Blossom URI, inspect a file, and download it. Owners and moderators can browse the tenant's full inventory. Other users see the files they have uploaded or claimed.
9+Use **Files** to browse uploads by name, open folders, preview images and download files. **My files** shows your uploads. **Sites** groups published assets by site, and **Rooms** groups attachments by rooms you can access. Owners and moderators can open **Storage** for the full inventory.
10
11 ## Uploading
12
13-The **Upload** panel on the Files page stores files or a folder. Choose files, choose a folder, or drop either onto the panel, and check **encrypt** to encrypt in the browser before sending. Plain uploads store each file as it is and refresh the listing. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.
14+The **Upload** panel on the Files page stores files or a folder. Choose files, choose a folder, or drop either onto the panel, and check **encrypt** to encrypt in the browser before sending. Plain uploads preserve filenames and selected folder paths. Uploading while inside a folder adds the selection there. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.
15+
16+Blossom identifies file contents by hash. Tiny keeps plain upload names and paths with your upload record; those labels do not change the file's hash or travel automatically to another Blossom server. Older uploads use names from site manifests or room attachments where available. Otherwise, they show a file type and short hash.
17
18 ## Encrypted files
19
20diff --git a/internal/blob/http.go b/internal/blob/http.go
21index 0ad5772..a4cbf86 100644
22--- a/internal/blob/http.go
23+++ b/internal/blob/http.go
24@@ -78,12 +78,17 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) {
25 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex"))
26 return
27 }
28- blob, created, err := s.putValidated(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, func(actual string) error {
29+ name, metadataPath, err := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))
30+ if err != nil {
31+ s.fail(w, http.StatusBadRequest, err)
32+ return
33+ }
34+ blob, created, err := s.putValidatedWithMetadata(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, name, metadataPath, func(actual string) error {
35 if s.config.ValidateUpload != nil {
36 return s.config.ValidateUpload(r, actual)
37 }
38 return nil
39- })
40+ }, nil)
41 if err != nil {
42 s.fail(w, statusFor(err), err)
43 return
44@@ -252,10 +257,15 @@ func (s *Service) list(w http.ResponseWriter, r *http.Request, pubkey string) {
45 s.fail(w, http.StatusBadRequest, errors.New("invalid: bad pubkey"))
46 return
47 }
48- if _, err := s.authorize(r, ActionList); err != nil {
49+ viewer, err := s.authorize(r, ActionList)
50+ if err != nil {
51 s.fail(w, http.StatusUnauthorized, err)
52 return
53 }
54+ if viewer != pubkey && !s.isOwner(viewer) {
55+ s.fail(w, http.StatusForbidden, errors.New("restricted: cannot list another uploader's blobs"))
56+ return
57+ }
58 limit, err := parseListLimit(r.URL.Query().Get("limit"))
59 if err != nil {
60 s.fail(w, http.StatusBadRequest, err)
61diff --git a/internal/blob/metadata.go b/internal/blob/metadata.go
62new file mode 100644
63index 0000000..714f742
64--- /dev/null
65+++ b/internal/blob/metadata.go
66@@ -0,0 +1,112 @@
67+package blob
68+
69+import (
70+ "context"
71+ "database/sql"
72+ "errors"
73+ "fmt"
74+ "path"
75+ "strings"
76+ "time"
77+ "unicode"
78+ "unicode/utf8"
79+)
80+
81+const maxClaimMetadataBytes = 4096
82+
83+var (
84+ errInvalidClaimName = errors.New("invalid: filename must be a single path component")
85+ errInvalidClaimPath = errors.New("invalid: upload path must be a safe relative path")
86+ errMismatchedClaimName = errors.New("invalid: filename must match the upload path basename")
87+)
88+
89+func validateClaimName(name string) error {
90+ if name == "" {
91+ return nil
92+ }
93+ if !utf8.ValidString(name) || len(name) > maxClaimMetadataBytes || strings.ContainsAny(name, "/\\") || name == "." || name == ".." || hasControl(name) {
94+ return errInvalidClaimName
95+ }
96+ return nil
97+}
98+
99+func validateClaimPath(raw string) (string, error) {
100+ if raw == "" {
101+ return "", nil
102+ }
103+ if !utf8.ValidString(raw) || len(raw) > maxClaimMetadataBytes || strings.Contains(raw, "\\") || strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") || hasControl(raw) {
104+ return "", errInvalidClaimPath
105+ }
106+ clean := path.Clean(raw)
107+ if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != raw {
108+ return "", errInvalidClaimPath
109+ }
110+ for _, part := range strings.Split(raw, "/") {
111+ if part == "" || part == "." || part == ".." {
112+ return "", errInvalidClaimPath
113+ }
114+ }
115+ return clean, nil
116+}
117+
118+func hasControl(value string) bool {
119+ for _, r := range value {
120+ if unicode.IsControl(r) {
121+ return true
122+ }
123+ }
124+ return false
125+}
126+
127+func claimMetadata(name, rawPath string) (string, string, error) {
128+ if err := validateClaimName(name); err != nil {
129+ return "", "", err
130+ }
131+ cleanPath, err := validateClaimPath(rawPath)
132+ if err != nil {
133+ return "", "", err
134+ }
135+ if name == "" && cleanPath != "" {
136+ name = path.Base(cleanPath)
137+ }
138+ if cleanPath == "" {
139+ cleanPath = name
140+ } else if name != "" && path.Base(cleanPath) != name {
141+ return "", "", errMismatchedClaimName
142+ }
143+ return name, cleanPath, nil
144+}
145+
146+func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, uploader, name, metadataPath string) error {
147+ if uploader == "" || (name == "" && metadataPath == "") {
148+ return nil
149+ }
150+ _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,updated_at)
151+ VALUES(?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`,
152+ sha, uploader, name, metadataPath, nowUnix())
153+ if err != nil {
154+ return fmt.Errorf("record blob claim metadata: %w", err)
155+ }
156+ return nil
157+}
158+
159+func (s *Service) ListClaimMetadata(ctx context.Context, uploader string) ([]BlobClaimMetadata, error) {
160+ rows, err := s.store.DB().QueryContext(ctx, `SELECT c.sha256,c.uploader,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.updated_at,c.claimed_at)
161+ FROM blob_claims c LEFT JOIN blob_claim_metadata m ON m.sha256=c.sha256 AND m.uploader=c.uploader
162+ JOIN blobs b ON b.sha256=c.sha256 WHERE c.uploader=? ORDER BY COALESCE(m.updated_at,c.claimed_at) DESC,c.sha256 DESC`, uploader)
163+ if err != nil {
164+ return nil, fmt.Errorf("list blob claim metadata: %w", err)
165+ }
166+ defer rows.Close()
167+ entries := make([]BlobClaimMetadata, 0)
168+ for rows.Next() {
169+ var entry BlobClaimMetadata
170+ if err := rows.Scan(&entry.SHA256, &entry.Uploader, &entry.Name, &entry.Path, &entry.UpdatedAt); err != nil {
171+ return nil, fmt.Errorf("scan blob claim metadata: %w", err)
172+ }
173+ entries = append(entries, entry)
174+ }
175+ return entries, rows.Err()
176+}
177+
178+func nowUnix() int64 { return time.Now().UTC().Unix() }
179diff --git a/internal/blob/metadata_test.go b/internal/blob/metadata_test.go
180new file mode 100644
181index 0000000..a95064b
182--- /dev/null
183+++ b/internal/blob/metadata_test.go
184@@ -0,0 +1,74 @@
185+package blob
186+
187+import (
188+ "context"
189+ "errors"
190+ "net/http"
191+ "net/http/httptest"
192+ "strings"
193+ "testing"
194+)
195+
196+func TestUploadStoresClaimScopedNameAndPath(t *testing.T) {
197+ s := testService(t)
198+ body := "same bytes"
199+ request := httptest.NewRequest(http.MethodPut, "https://relay.test/upload?filename=note.txt&path=docs%2Fnote.txt", strings.NewReader(body))
200+ request.Header.Set("Content-Type", "text/plain")
201+ if response := record(s.Handler(), request); response.Code != http.StatusOK {
202+ t.Fatalf("upload status = %d: %s", response.Code, response.Body.String())
203+ }
204+ entries, err := s.ListClaimMetadata(context.Background(), "uploader")
205+ if err != nil || len(entries) != 1 || entries[0].Name != "note.txt" || entries[0].Path != "docs/note.txt" {
206+ t.Fatalf("metadata = %+v, %v", entries, err)
207+ }
208+ other := strings.Repeat("b", 64)
209+ if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain", Uploader: other, Name: "other.txt", Path: "other.txt"}); err != nil {
210+ t.Fatal(err)
211+ }
212+ entries, err = s.ListClaimMetadata(context.Background(), other)
213+ if err != nil || len(entries) != 1 || entries[0].Name != "other.txt" {
214+ t.Fatalf("deduplicated metadata = %+v, %v", entries, err)
215+ }
216+}
217+
218+func TestValidateClaimPath(t *testing.T) {
219+ for _, path := range []string{"", "file.txt", "dir/file.txt"} {
220+ if _, err := validateClaimPath(path); err != nil {
221+ t.Errorf("validateClaimPath(%q): %v", path, err)
222+ }
223+ }
224+ for _, path := range []string{"/file.txt", "../file.txt", "dir/../../file.txt", "dir/../secret.txt", "dir/./file.txt", "dir//file.txt", "dir\\file.txt", "dir/", "a\x00b"} {
225+ if _, err := validateClaimPath(path); err == nil {
226+ t.Errorf("validateClaimPath(%q) succeeded", path)
227+ }
228+ }
229+ if !errors.Is(validateClaimName("dir/file.txt"), errInvalidClaimName) {
230+ t.Fatal("path was accepted as a filename")
231+ }
232+ if _, _, err := claimMetadata("other.txt", "dir/file.txt"); !errors.Is(err, errMismatchedClaimName) {
233+ t.Fatalf("mismatched filename accepted: %v", err)
234+ }
235+ if name, cleanPath, err := claimMetadata("", "dir/file.txt"); err != nil || name != "file.txt" || cleanPath != "dir/file.txt" {
236+ t.Fatalf("path basename metadata = %q, %q, %v", name, cleanPath, err)
237+ }
238+ if err := validateClaimName(string([]byte{0xff})); !errors.Is(err, errInvalidClaimName) {
239+ t.Fatalf("invalid UTF-8 filename accepted: %v", err)
240+ }
241+}
242+
243+func TestIdenticalFilesPreserveDistinctFolderPaths(t *testing.T) {
244+ s := testService(t)
245+ ctx := context.Background()
246+ for _, filePath := range []string{"one/note.txt", "two/note.txt", "one/note.txt"} {
247+ if _, err := s.Put(ctx, PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: "uploader", Name: "note.txt", Path: filePath}); err != nil {
248+ t.Fatal(err)
249+ }
250+ }
251+ entries, err := s.ListClaimMetadata(ctx, "uploader")
252+ if err != nil || len(entries) != 2 {
253+ t.Fatalf("distinct paths lost or repeated: %#v, %v", entries, err)
254+ }
255+ if entries[0].SHA256 != entries[1].SHA256 || entries[0].Path == entries[1].Path {
256+ t.Fatalf("same content should retain both paths: %#v", entries)
257+ }
258+}
259diff --git a/internal/blob/multipart.go b/internal/blob/multipart.go
260index 398da74..c4817f1 100644
261--- a/internal/blob/multipart.go
262+++ b/internal/blob/multipart.go
263@@ -29,8 +29,8 @@ const (
264 )
265
266 type multipartRequest struct {
267- id, hash, uploader, typ string
268- length, offset, count int64
269+ id, hash, uploader, typ, name, metadataPath string
270+ length, offset, count int64
271 }
272
273 type multipartError struct {
274@@ -85,6 +85,11 @@ func (s *Service) multipart(w http.ResponseWriter, r *http.Request, hash string)
275
276 func parseMultipart(r *http.Request, hash, uploader string) (multipartRequest, error) {
277 part := multipartRequest{id: multipartID(hash, uploader), hash: hash, uploader: uploader, count: r.ContentLength}
278+ name, metadataPath, metadataErr := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))
279+ if metadataErr != nil {
280+ return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())
281+ }
282+ part.name, part.metadataPath = name, metadataPath
283 if r.ContentLength < 0 {
284 return part, multipartFailure(411, "Content-Length is required")
285 }
286@@ -169,8 +174,8 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
287 return s.checkMultipartQuota(ctx, part, limits)
288 }
289 var length int64
290- var typ string
291- err := s.store.DB().QueryRowContext(ctx, "SELECT length,type FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ)
292+ var typ, name, metadataPath string
293+ err := s.store.DB().QueryRowContext(ctx, "SELECT length,type,name,path FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ, &name, &metadataPath)
294 if err != nil && !errors.Is(err, sql.ErrNoRows) {
295 return err
296 }
297@@ -183,7 +188,10 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
298 if errors.Is(err, sql.ErrNoRows) {
299 return s.createMultipart(ctx, part)
300 }
301- _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=? WHERE id=?", time.Now().Unix(), part.id)
302+ if part.name == "" && part.metadataPath == "" {
303+ part.name, part.metadataPath = name, metadataPath
304+ }
305+ _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=?,name=?,path=? WHERE id=?", time.Now().Unix(), part.name, part.metadataPath, part.id)
306 return err
307 }
308
309@@ -222,7 +230,7 @@ func (s *Service) createMultipart(ctx context.Context, part multipartRequest) er
310 return err
311 }
312 now := time.Now().Unix()
313- _, err = s.store.DB().ExecContext(ctx, "INSERT INTO multipart_uploads(id,sha256,uploader,length,type,created,last_seen) VALUES(?,?,?,?,?,?,?)", part.id, part.hash, part.uploader, part.length, part.typ, now, now)
314+ _, err = s.store.DB().ExecContext(ctx, "INSERT INTO multipart_uploads(id,sha256,uploader,length,type,created,last_seen,name,path) VALUES(?,?,?,?,?,?,?,?,?)", part.id, part.hash, part.uploader, part.length, part.typ, now, now, part.name, part.metadataPath)
315 if err != nil {
316 _ = os.Remove(path)
317 }
318@@ -298,7 +306,14 @@ func (s *Service) commitMultipart(ctx context.Context, part multipartRequest, ch
319 s.multipartMu.Unlock()
320 return Blob{}, false, false, admissionErr
321 }
322- candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, reservation: part.id}
323+ name, metadataPath := part.name, part.metadataPath
324+ if name == "" && metadataPath == "" {
325+ if err := s.store.DB().QueryRowContext(ctx, "SELECT name,path FROM multipart_uploads WHERE id=?", part.id).Scan(&name, &metadataPath); err != nil && !errors.Is(err, sql.ErrNoRows) {
326+ s.multipartMu.Unlock()
327+ return Blob{}, false, false, err
328+ }
329+ }
330+ candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, name: name, metadataPath: metadataPath, reservation: part.id}
331 if existing, err := s.lookup(ctx, part.hash); err == nil {
332 if existing.Size != part.length {
333 s.multipartMu.Unlock()
334diff --git a/internal/blob/multipart_test.go b/internal/blob/multipart_test.go
335index 6ad4f95..adf52fd 100644
336--- a/internal/blob/multipart_test.go
337+++ b/internal/blob/multipart_test.go
338@@ -141,6 +141,31 @@ func TestMultipartResumesAfterServiceRestart(t *testing.T) {
339 }
340 }
341
342+func TestMultipartPreservesClaimMetadataAcrossChunks(t *testing.T) {
343+ s := testService(t)
344+ body := "resumable metadata"
345+ digest := sha256.Sum256([]byte(body))
346+ hash := hex.EncodeToString(digest[:])
347+ patch := func(offset int, chunk string, query string) int {
348+ r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+hash+query, strings.NewReader(chunk))
349+ r.Header.Set("Content-Type", "application/octet-stream")
350+ r.Header.Set("Upload-Type", "text/plain")
351+ r.Header.Set("Upload-Length", strconv.Itoa(len(body)))
352+ r.Header.Set("Upload-Offset", strconv.Itoa(offset))
353+ return record(s.Handler(), r).Code
354+ }
355+ if got := patch(0, body[:9], "?filename=message.txt&path=notes%2Fmessage.txt"); got != http.StatusNoContent {
356+ t.Fatalf("partial status = %d", got)
357+ }
358+ if got := patch(9, body[9:], ""); got != http.StatusCreated {
359+ t.Fatalf("complete status = %d", got)
360+ }
361+ entries, err := s.ListClaimMetadata(context.Background(), "uploader")
362+ if err != nil || len(entries) != 1 || entries[0].Name != "message.txt" || entries[0].Path != "notes/message.txt" {
363+ t.Fatalf("metadata = %+v, %v", entries, err)
364+ }
365+}
366+
367 func TestMultipartAcceptsEmptyFile(t *testing.T) {
368 service := testService(t)
369 digest := sha256.Sum256(nil)
370diff --git a/internal/blob/service.go b/internal/blob/service.go
371index e973b46..53bfc42 100644
372--- a/internal/blob/service.go
373+++ b/internal/blob/service.go
374@@ -98,12 +98,24 @@ type Blob struct {
375 Uploaded int64 `json:"uploaded"`
376 }
377
378+// BlobClaimMetadata names a blob for one uploader. Names are claim-scoped so
379+// deduplicated blobs can have different names for different uploaders.
380+type BlobClaimMetadata struct {
381+ SHA256 string `json:"sha256"`
382+ Uploader string `json:"uploader"`
383+ Name string `json:"name"`
384+ Path string `json:"path"`
385+ UpdatedAt int64 `json:"updated_at"`
386+}
387+
388 // PutOptions describes a blob upload and its optional metadata transaction.
389 type PutOptions struct {
390 Reader io.Reader
391 Type string
392 Uploader string
393 Hash string
394+ Name string
395+ Path string
396 // Commit records ownership metadata in the blob transaction. Its isNew
397 // argument reports whether the blob was newly installed. An error rolls
398 // back all claims.
399@@ -171,6 +183,12 @@ func New(ctx context.Context, config Config) (*Service, error) {
400 PRIMARY KEY (sha256, uploader), FOREIGN KEY (sha256) REFERENCES blobs(sha256) ON DELETE CASCADE
401 ); CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);
402 CREATE INDEX IF NOT EXISTS blob_claims_uploader ON blob_claims(uploader, claimed_at DESC, sha256 DESC);
403+ CREATE TABLE IF NOT EXISTS blob_claim_metadata (
404+ sha256 TEXT NOT NULL, uploader TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL,
405+ updated_at INTEGER NOT NULL, PRIMARY KEY (sha256,uploader,path),
406+ FOREIGN KEY (sha256,uploader) REFERENCES blob_claims(sha256,uploader) ON DELETE CASCADE
407+ );
408+ CREATE INDEX IF NOT EXISTS blob_claim_metadata_uploader ON blob_claim_metadata(uploader,updated_at DESC,sha256 DESC);
409 CREATE TABLE IF NOT EXISTS blob_reports (
410 id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL DEFAULT '',
411 target_blob TEXT NOT NULL, type TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL,
412@@ -181,7 +199,7 @@ func New(ctx context.Context, config Config) (*Service, error) {
413 WHEN NEW.uploader != '' BEGIN INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) VALUES(NEW.sha256,NEW.uploader,NEW.uploaded); END;
414 CREATE TABLE IF NOT EXISTS blob_blocks (sha256 TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', blocked_at INTEGER NOT NULL DEFAULT 0);
415 CREATE TABLE IF NOT EXISTS blob_tombstones (sha256 TEXT PRIMARY KEY, deleted_at INTEGER NOT NULL);
416- CREATE TABLE IF NOT EXISTS multipart_uploads (id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, uploader TEXT NOT NULL, length INTEGER NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_seen INTEGER NOT NULL);
417+ CREATE TABLE IF NOT EXISTS multipart_uploads (id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, uploader TEXT NOT NULL, length INTEGER NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_seen INTEGER NOT NULL, name TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '');
418 CREATE TABLE IF NOT EXISTS multipart_parts (upload_id TEXT NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY(upload_id, offset), FOREIGN KEY(upload_id) REFERENCES multipart_uploads(id) ON DELETE CASCADE);
419 INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at)
420 SELECT sha256,uploader,uploaded FROM blobs WHERE uploader != ''`); err != nil {
421@@ -192,6 +210,11 @@ func New(ctx context.Context, config Config) (*Service, error) {
422 if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blob_claims ADD COLUMN expires INTEGER NOT NULL DEFAULT 0"); err != nil && !strings.Contains(err.Error(), "duplicate column") {
423 return nil, fmt.Errorf("add blob claim expiry: %w", err)
424 }
425+ for _, column := range []string{"name TEXT NOT NULL DEFAULT ''", "path TEXT NOT NULL DEFAULT ''"} {
426+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN "+column); err != nil && !strings.Contains(err.Error(), "duplicate column") {
427+ return nil, fmt.Errorf("add multipart metadata: %w", err)
428+ }
429+ }
430 if _, err := config.Store.DB().ExecContext(ctx, "CREATE INDEX IF NOT EXISTS blob_claims_expires ON blob_claims(expires) WHERE expires>0"); err != nil {
431 return nil, fmt.Errorf("index blob claim expiry: %w", err)
432 }
433diff --git a/internal/blob/storage.go b/internal/blob/storage.go
434index 811e54b..7f3a05b 100644
435--- a/internal/blob/storage.go
436+++ b/internal/blob/storage.go
437@@ -27,7 +27,7 @@ func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) {
438 }
439
440 func (s *Service) putOptions(ctx context.Context, options PutOptions) (Blob, bool, error) {
441- return s.putValidatedWithCommit(ctx, options.Reader, options.Type, options.Uploader, options.Hash, nil, options.Commit)
442+ return s.putValidatedWithMetadata(ctx, options.Reader, options.Type, options.Uploader, options.Hash, options.Name, options.Path, nil, options.Commit)
443 }
444
445 // Get opens a verified metadata-backed blob for streaming to another service.
446@@ -128,6 +128,9 @@ func (s *Service) Delete(ctx context.Context, sha string) error {
447 if _, err := tx.ExecContext(ctx, "DELETE FROM blobs WHERE sha256=?", sha); err != nil {
448 return fmt.Errorf("delete blob metadata: %w", err)
449 }
450+ if _, err := tx.ExecContext(ctx, "DELETE FROM blob_claim_metadata WHERE sha256=?", sha); err != nil {
451+ return fmt.Errorf("delete blob claim metadata: %w", err)
452+ }
453 return nil
454 }); err != nil {
455 return err
456@@ -158,6 +161,9 @@ func (s *Service) DeleteForUploader(ctx context.Context, sha, uploader string) e
457 if changed == 0 {
458 return sql.ErrNoRows
459 }
460+ if _, err := tx.ExecContext(ctx, "DELETE FROM blob_claim_metadata WHERE sha256=? AND uploader=?", sha, uploader); err != nil {
461+ return fmt.Errorf("delete blob claim metadata: %w", err)
462+ }
463 var remaining int
464 if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM blob_claims WHERE sha256=?", sha).Scan(&remaining); err != nil {
465 return fmt.Errorf("count blob claims: %w", err)
466@@ -199,10 +205,19 @@ func (s *Service) put(ctx context.Context, source io.Reader, typ, uploader, clai
467 }
468
469 func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error) (Blob, bool, error) {
470- return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, validate, nil)
471+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, nil)
472 }
473
474 func (s *Service) putValidatedWithCommit(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error, commit func(context.Context, *sql.Tx, Blob, bool) error) (Blob, bool, error) {
475+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, commit)
476+}
477+
478+func (s *Service) putValidatedWithMetadata(ctx context.Context, source io.Reader, typ, uploader, claimed, name, path string, validate func(string) error, commit func(context.Context, *sql.Tx, Blob, bool) error) (Blob, bool, error) {
479+ var err error
480+ name, path, err = claimMetadata(name, path)
481+ if err != nil {
482+ return Blob{}, false, err
483+ }
484 limits := s.currentLimits()
485 streamLimit := limits.MaxFileBytes
486 quotaStream := false
487@@ -249,13 +264,13 @@ func (s *Service) putValidatedWithCommit(ctx context.Context, source io.Reader,
488 return Blob{}, false, err
489 }
490 }
491- return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, commit: commit})
492+ return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, name: name, metadataPath: path, commit: commit})
493 }
494
495 type uploadCandidate struct {
496- path, hash, typ, uploader, reservation string
497- size int64
498- commit func(context.Context, *sql.Tx, Blob, bool) error
499+ path, hash, typ, uploader, reservation, name, metadataPath string
500+ size int64
501+ commit func(context.Context, *sql.Tx, Blob, bool) error
502 }
503
504 func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate) (Blob, bool, error) {
505@@ -304,6 +319,9 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
506 if _, err := tx.ExecContext(ctx, claimSQL, sha, uploader, time.Now().Unix(), terms.ExpiresAt); err != nil {
507 return err
508 }
509+ if err := s.saveClaimMetadata(ctx, tx, sha, uploader, candidate.name, candidate.metadataPath); err != nil {
510+ return err
511+ }
512 }
513 if candidate.reservation != "" {
514 if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
515@@ -374,6 +392,9 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
516 if _, err := tx.ExecContext(ctx, claimSQL, entry.SHA256, entry.Uploader, entry.Uploaded, terms.ExpiresAt); err != nil {
517 return fmt.Errorf("record blob claim: %w", err)
518 }
519+ if err := s.saveClaimMetadata(ctx, tx, entry.SHA256, entry.Uploader, candidate.name, candidate.metadataPath); err != nil {
520+ return err
521+ }
522 }
523 if candidate.reservation != "" {
524 if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
525diff --git a/internal/daemon/approvals.go b/internal/daemon/approvals.go
526index 0b4cf80..7ac0d3b 100644
527--- a/internal/daemon/approvals.go
528+++ b/internal/daemon/approvals.go
529@@ -8,6 +8,7 @@ package daemon
530
531 import (
532 "context"
533+ "sort"
534 "strconv"
535 "strings"
536 "time"
537@@ -28,6 +29,11 @@ const (
538 var approvalTypes = []string{"approve", "decide", "question"}
539 var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot}
540
541+const (
542+ approvalWikiProposal = "wiki-proposal"
543+ approvalWikiMerge = "wiki-merge"
544+)
545+
546 // approvalItem is one request as the asked person sees it.
547 type approvalItem struct {
548 ID string `json:"id"`
549@@ -225,7 +231,22 @@ func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storag
550 for i := range items {
551 approvalSettle(&items[i], answers[items[i].ID], now)
552 }
553- return items, page.More, nil
554+ wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)
555+ if err != nil {
556+ return nil, false, err
557+ }
558+ items = append(items, wikiItems...)
559+ sort.SliceStable(items, func(i, j int) bool {
560+ if items[i].CreatedAt != items[j].CreatedAt {
561+ return items[i].CreatedAt > items[j].CreatedAt
562+ }
563+ return items[i].ID < items[j].ID
564+ })
565+ more := page.More || wikiMore || len(items) > limit
566+ if len(items) > limit {
567+ items = items[:limit]
568+ }
569+ return items, more, nil
570 }
571
572 // openApprovals lists the unanswered, unexpired requests addressed to the
573diff --git a/internal/daemon/approvals_browse.go b/internal/daemon/approvals_browse.go
574index d83659d..1bf34b3 100644
575--- a/internal/daemon/approvals_browse.go
576+++ b/internal/daemon/approvals_browse.go
577@@ -123,6 +123,40 @@ func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, err
578 if len(id) != 64 {
579 return nil, errors.New("invalid: approval id")
580 }
581+ if item, ok, err := t.wikiApprovalByID(ctx, actor, id); err != nil {
582+ return nil, err
583+ } else if ok {
584+ answers, err := t.approvalAnswers(ctx, []approvalItem{item}, time.Now().Unix())
585+ if err != nil {
586+ return nil, err
587+ }
588+ approvalSettle(&item, answers[id], time.Now().Unix())
589+ if item.Type == approvalWikiProposal {
590+ if err := t.settleWikiProposal(ctx, &item, t.wikiRoles()); err != nil {
591+ return nil, err
592+ }
593+ } else if item.Type == approvalWikiMerge {
594+ status, err := t.wikiMergeStatus(ctx, item.ID, actor)
595+ if err != nil {
596+ return nil, err
597+ }
598+ if status.Status == wikiMergeOpen {
599+ item.State = "open"
600+ item.Answer = nil
601+ } else {
602+ item.State = "answered"
603+ item.Answer, err = t.wikiReactionAnswer(ctx, status.Reaction)
604+ if err != nil {
605+ return nil, err
606+ }
607+ }
608+ }
609+ history := answers[id]
610+ if history == nil {
611+ history = []event.Event{}
612+ }
613+ return map[string]any{"item": item, "answers": history}, nil
614+ }
615 session := browseSession(t, actor)
616 rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: approvalKinds, Limit: intPtr(1)}}, session)
617 if err != nil {
618diff --git a/internal/daemon/approvals_test.go b/internal/daemon/approvals_test.go
619index ce6d633..8d73e80 100644
620--- a/internal/daemon/approvals_test.go
621+++ b/internal/daemon/approvals_test.go
622@@ -175,6 +175,62 @@ func TestBrowseApprovalsStatesAndAccess(t *testing.T) {
623 }
624 }
625
626+func TestBrowseApprovalsIncludesWikiProposalsAndMerges(t *testing.T) {
627+ _, tenant, owner, _, moderator, member := wikiProposalTenant(t, "propose")
628+ now := time.Now().Unix()
629+ proposal := wikiArticle(t, tenant, testAgentSecret, "Approvals", now-200)
630+ wikiArticle(t, tenant, wikiOwnerSecret, "Approvals", now-300)
631+ merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-100, "Please take this change.", []string{"a", "30818:" + owner + ":approvals"}, []string{"p", owner}, []string{"e", proposal.ID, "", "source"})
632+ result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})
633+ if err != nil {
634+ t.Fatal(err)
635+ }
636+ items := wikiItems(result, "items")
637+ if approvalByID(items, proposal.ID) == nil || approvalByID(items, merge.ID) == nil {
638+ t.Fatalf("wiki approvals missing: %v", items)
639+ }
640+ proposalItem := approvalByID(items, proposal.ID)
641+ if proposalItem["type"] != approvalWikiProposal || proposalItem["state"] != "open" || proposalItem["about"].(map[string]any)["url"] != "http://relay.test/wiki/approvals?version="+proposal.ID {
642+ t.Fatalf("proposal approval %v", proposalItem)
643+ }
644+ mergeItem := approvalByID(items, merge.ID)
645+ if mergeItem["type"] != approvalWikiMerge || mergeItem["state"] != "open" || !strings.Contains(mergeItem["about"].(map[string]any)["url"].(string), "?merge="+merge.ID) {
646+ t.Fatalf("merge approval %v", mergeItem)
647+ }
648+ if got, err := approvalCall(t, tenant, member, "browseapprovals", map[string]any{}); err != nil || len(wikiItems(got, "items")) != 0 {
649+ t.Fatalf("member saw wiki approvals: %v %v", got, err)
650+ }
651+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "+", []string{"e", proposal.ID})
652+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-40, "-", []string{"e", merge.ID})
653+ result, err = approvalCall(t, tenant, moderator, "browseapprovals", map[string]any{})
654+ if err != nil {
655+ t.Fatal(err)
656+ }
657+ if approvalByID(wikiItems(result, "items"), proposal.ID) == nil {
658+ t.Fatal("moderator cannot see proposal")
659+ }
660+ if approvalByID(wikiItems(result, "items"), proposal.ID)["state"] != "answered" {
661+ t.Fatalf("proposal state %v", result)
662+ }
663+ detail, err := approvalCall(t, tenant, moderator, "browseapproval", map[string]any{"id": proposal.ID})
664+ if err != nil || detail["item"].(map[string]any)["state"] != "answered" {
665+ t.Fatalf("proposal detail %v %v", detail, err)
666+ }
667+ if _, err := approvalCall(t, tenant, member, "browseapproval", map[string]any{"id": proposal.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
668+ t.Fatalf("proposal detail leaked to member: %v", err)
669+ }
670+ detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": merge.ID})
671+ if err != nil || detail["item"].(map[string]any)["state"] != "answered" {
672+ t.Fatalf("merge detail %v %v", detail, err)
673+ }
674+ proposal2 := wikiArticle(t, tenant, testAgentSecret, "Reply stays open", now-20)
675+ wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-10, "A note", []string{"e", proposal2.ID})
676+ detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": proposal2.ID})
677+ if err != nil || detail["item"].(map[string]any)["state"] != "open" {
678+ t.Fatalf("wiki reply settled proposal: %v %v", detail, err)
679+ }
680+}
681+
682 func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {
683 _, tenant := testTenant(t)
684 ctx := context.Background()
685diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
686index db2a880..a2d80b7 100644
687--- a/internal/daemon/client_browse.go
688+++ b/internal/daemon/client_browse.go
689@@ -7,7 +7,6 @@ import (
690 "errors"
691 "fmt"
692 "io"
693- "net/url"
694 "sort"
695 "strings"
696 "time"
697@@ -237,112 +236,6 @@ func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.
698 return result, nil
699 }
700
701-func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
702- if !t.Policy().Features.Files {
703- return nil, errors.New("not found: files are disabled")
704- }
705- if actor == "" {
706- return nil, errors.New("auth-required: sign in to browse your files")
707- }
708- role, err := t.community.Role(ctx, actor)
709- if err != nil {
710- return nil, err
711- }
712- var entries []blob.Blob
713- var next string
714- if role == "owner" || role == "moderator" {
715- entries, next, err = t.browseAllFiles(ctx, q)
716- } else if strings.TrimSpace(q.Query) != "" {
717- // ListPage is uploader scoped and cannot filter server-side. Walk its
718- // cursor until the requested number of matches is collected.
719- cursor := q.Cursor
720- for len(entries) < q.Limit {
721- var page []blob.Blob
722- page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)
723- if err != nil {
724- break
725- }
726- for _, entry := range page {
727- if blobMatchesQuery(entry, q.Query) {
728- entries = append(entries, entry)
729- if len(entries) == q.Limit {
730- break
731- }
732- }
733- }
734- if next == "" {
735- break
736- }
737- cursor = next
738- }
739- } else {
740- entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)
741- }
742- if err != nil {
743- return nil, err
744- }
745- items := []map[string]any{}
746- for _, entry := range entries {
747- if q.Query != "" && !blobMatchesQuery(entry, q.Query) {
748- continue
749- }
750- items = append(items, t.browseBlobMetadata(entry))
751- }
752- return map[string]any{"items": items, "next_cursor": next}, nil
753-}
754-
755-func (t *Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {
756- query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"
757- args := []any{q.Cursor}
758- if q.Query != "" {
759- query += " AND (instr(lower(sha256), ?) > 0 OR instr(lower(type), ?) > 0 OR instr(lower(uploader), ?) > 0)"
760- needle := strings.ToLower(strings.TrimSpace(q.Query))
761- args = append(args, needle, needle, needle)
762- }
763- query += " ORDER BY sha256 LIMIT ?"
764- args = append(args, q.Limit+1)
765- rows, err := t.store.DB().QueryContext(ctx, query, args...)
766- if err != nil {
767- return nil, "", err
768- }
769- defer rows.Close()
770- entries := []blob.Blob{}
771- for rows.Next() {
772- var entry blob.Blob
773- if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {
774- return nil, "", err
775- }
776- entries = append(entries, entry)
777- }
778- if err := rows.Err(); err != nil {
779- return nil, "", err
780- }
781- next := ""
782- if len(entries) > q.Limit {
783- entries = entries[:q.Limit]
784- next = entries[len(entries)-1].SHA256
785- }
786- return entries, next, nil
787-}
788-
789-func blobMatchesQuery(entry blob.Blob, query string) bool {
790- needle := strings.ToLower(strings.TrimSpace(query))
791- if needle == "" {
792- return true
793- }
794- return strings.Contains(strings.ToLower(entry.SHA256), needle) ||
795- strings.Contains(strings.ToLower(entry.Type), needle) ||
796- strings.Contains(strings.ToLower(entry.Uploader), needle)
797-}
798-
799-func (t *Tenant) browseBlobMetadata(entry blob.Blob) map[string]any {
800- portable := blob.BlossomURI{Hash: entry.SHA256, Extension: "bin", Author: entry.Uploader, Size: entry.Size}
801- if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {
802- portable.Servers = []string{origin.Scheme + "://" + origin.Host}
803- }
804- return map[string]any{"sha256": entry.SHA256, "size": entry.Size, "type": entry.Type, "uploader": entry.Uploader, "uploaded": entry.Uploaded, "url": strings.TrimRight(t.publicURL, "/") + "/" + entry.SHA256, "download_url": strings.TrimRight(t.publicURL, "/") + "/files/raw?hash=" + url.QueryEscape(entry.SHA256), "blossom_uri": portable.String()}
805-}
806-
807 func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error) {
808 if !t.Policy().Features.Files {
809 return nil, errors.New("not found: files are disabled")
810@@ -365,6 +258,17 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
811 }
812 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)
813 result := t.browseBlobMetadata(entry)
814+ if actor != "" {
815+ var name, filePath string
816+ if err := t.store.DB().QueryRowContext(ctx, `SELECT name,path FROM blob_claim_metadata WHERE sha256=? AND uploader=? ORDER BY updated_at DESC,path LIMIT 1`, entry.SHA256, actor).Scan(&name, &filePath); err == nil {
817+ if name != "" {
818+ result["name"] = name
819+ }
820+ if filePath != "" {
821+ result["path"] = filePath
822+ }
823+ }
824+ }
825 result["binary"], result["truncated"] = binary, truncated
826 if entry.Type == "application/octet-stream" {
827 if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {
828diff --git a/internal/daemon/client_files.go b/internal/daemon/client_files.go
829new file mode 100644
830index 0000000..bae3fbf
831--- /dev/null
832+++ b/internal/daemon/client_files.go
833@@ -0,0 +1,507 @@
834+package daemon
835+
836+// Contextual file browsing. The legacy (empty View) response remains the
837+// Blossom inventory used by MCP clients; the views below are for the web UI.
838+import (
839+ "context"
840+ "encoding/base64"
841+ "encoding/json"
842+ "errors"
843+ "net/url"
844+ "path"
845+ "sort"
846+ "strings"
847+ "time"
848+
849+ "github.com/FelineStateMachine/tinyrelay/internal/blob"
850+ "github.com/FelineStateMachine/tinyrelay/internal/event"
851+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
852+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
853+)
854+
855+func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
856+ if q.View == "" {
857+ return t.browseFilesLegacy(ctx, actor, q)
858+ }
859+ if !t.Policy().Features.Files {
860+ return nil, errors.New("not found: files are disabled")
861+ }
862+ if actor == "" {
863+ return nil, errors.New("auth-required: sign in to browse your files")
864+ }
865+ role, err := t.community.Role(ctx, actor)
866+ if err != nil {
867+ return nil, err
868+ }
869+ canManage := role == "owner" || role == "moderator"
870+ var result any
871+ switch q.View {
872+ case "library":
873+ result, err = t.browseLibrary(ctx, actor, q)
874+ case "sites":
875+ result, err = t.browseSiteFiles(ctx, actor, q)
876+ case "rooms":
877+ result, err = t.browseRoomFiles(ctx, actor, q)
878+ case "storage":
879+ if !canManage {
880+ return nil, errors.New("restricted: storage inventory requires owner or moderator")
881+ }
882+ result, err = t.browseStorage(ctx, actor, q)
883+ default:
884+ return nil, errors.New("invalid: file view")
885+ }
886+ if err != nil {
887+ return nil, err
888+ }
889+ if row, ok := result.(map[string]any); ok {
890+ row["can_manage"] = canManage
891+ }
892+ return result, nil
893+}
894+
895+func (t Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
896+ if actor == "" {
897+ return nil, errors.New("auth-required: sign in to browse your files")
898+ }
899+ role, err := t.community.Role(ctx, actor)
900+ if err != nil {
901+ return nil, err
902+ }
903+ var entries []blob.Blob
904+ var next string
905+ if role == "owner" || role == "moderator" {
906+ entries, next, err = t.browseAllFiles(ctx, q)
907+ } else if q.Query != "" {
908+ cursor := q.Cursor
909+ for len(entries) < q.Limit {
910+ var page []blob.Blob
911+ page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)
912+ if err != nil {
913+ break
914+ }
915+ for _, e := range page {
916+ if blobMatchesQuery(e, q.Query) {
917+ entries = append(entries, e)
918+ }
919+ }
920+ if next == "" {
921+ break
922+ }
923+ cursor = next
924+ }
925+ } else {
926+ entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)
927+ }
928+ if err != nil {
929+ return nil, err
930+ }
931+ items := []map[string]any{}
932+ for _, e := range entries {
933+ if q.Query == "" || blobMatchesQuery(e, q.Query) {
934+ items = append(items, t.browseBlobMetadata(e))
935+ }
936+ }
937+ return map[string]any{"items": items, "next_cursor": next}, nil
938+}
939+
940+func (t Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {
941+ query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"
942+ args := []any{q.Cursor}
943+ needle := strings.ToLower(strings.TrimSpace(q.Query))
944+ if needle != "" {
945+ query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"
946+ args = append(args, needle, needle, needle)
947+ }
948+ query += " ORDER BY sha256 LIMIT ?"
949+ args = append(args, q.Limit+1)
950+ rows, err := t.store.DB().QueryContext(ctx, query, args...)
951+ if err != nil {
952+ return nil, "", err
953+ }
954+ defer rows.Close()
955+ out := []blob.Blob{}
956+ for rows.Next() {
957+ var e blob.Blob
958+ if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded); err != nil {
959+ return nil, "", err
960+ }
961+ out = append(out, e)
962+ }
963+ if err := rows.Err(); err != nil {
964+ return nil, "", err
965+ }
966+ next := ""
967+ if len(out) > q.Limit {
968+ next = out[q.Limit-1].SHA256
969+ out = out[:q.Limit]
970+ }
971+ return out, next, nil
972+}
973+func blobMatchesQuery(e blob.Blob, q string) bool {
974+ n := strings.ToLower(strings.TrimSpace(q))
975+ return n == "" || strings.Contains(strings.ToLower(e.SHA256), n) || strings.Contains(strings.ToLower(e.Type), n) || strings.Contains(strings.ToLower(e.Uploader), n)
976+}
977+func (t Tenant) browseBlobMetadata(e blob.Blob) map[string]any {
978+ portable := blob.BlossomURI{Hash: e.SHA256, Extension: "bin", Author: e.Uploader, Size: e.Size}
979+ if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {
980+ portable.Servers = []string{origin.Scheme + "://" + origin.Host}
981+ }
982+ return map[string]any{"sha256": e.SHA256, "size": e.Size, "type": e.Type, "uploader": e.Uploader, "uploaded": e.Uploaded, "url": strings.TrimRight(t.publicURL, "/") + "/" + e.SHA256, "download_url": strings.TrimRight(t.publicURL, "/") + "/files/raw?hash=" + url.QueryEscape(e.SHA256), "blossom_uri": portable.String()}
983+}
984+
985+func (t Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[string]any, error) {
986+ q := `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded,COALESCE(m.name,''),COALESCE(m.path,'') FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 LEFT JOIN blob_claim_metadata m ON m.sha256=b.sha256 AND m.uploader=c.uploader`
987+ args := []any{}
988+ if !all {
989+ q += " WHERE c.uploader=?"
990+ args = append(args, actor)
991+ }
992+ q += " ORDER BY b.uploaded DESC,b.sha256 DESC"
993+ rows, err := t.store.DB().QueryContext(ctx, q, args...)
994+ if err != nil {
995+ return nil, err
996+ }
997+ defer rows.Close()
998+ out := []map[string]any{}
999+ for rows.Next() {
1000+ var e blob.Blob
1001+ var name, p string
1002+ if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded, &name, &p); err != nil {
1003+ return nil, err
1004+ }
1005+ m := t.browseBlobMetadata(e)
1006+ m["name"] = name
1007+ m["path"] = p
1008+ out = append(out, m)
1009+ }
1010+ return out, rows.Err()
1011+}
1012+
1013+func (t Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
1014+ rows, err := t.claimRows(ctx, actor, false)
1015+ if err != nil {
1016+ return nil, err
1017+ }
1018+ excluded := map[string]bool{}
1019+ paths, rooms, err := t.contextHashes(ctx, actor)
1020+ if err != nil {
1021+ return nil, err
1022+ }
1023+ for h := range paths {
1024+ excluded[h] = true
1025+ }
1026+ for h := range rooms {
1027+ excluded[h] = true
1028+ }
1029+ return t.folderItems(rows, excluded, q, "library", false), nil
1030+}
1031+func (t Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q clientBrowseRequest, view string, includeExcluded bool) map[string]any {
1032+ prefix := strings.Trim(q.Path, "/")
1033+ if prefix != "" {
1034+ prefix += "/"
1035+ }
1036+ folders := map[string]bool{}
1037+ files := []map[string]any{}
1038+ needle := strings.ToLower(strings.TrimSpace(q.Query))
1039+ for _, m := range rows {
1040+ if !includeExcluded && excluded[m["sha256"].(string)] && m["name"] == "" {
1041+ continue
1042+ }
1043+ p := m["path"].(string)
1044+ name := m["name"].(string)
1045+ if p == "" {
1046+ if name != "" {
1047+ p = name
1048+ } else {
1049+ short := m["sha256"].(string)
1050+ if len(short) > 12 {
1051+ short = short[:12]
1052+ }
1053+ p = "(" + strings.ReplaceAll(m["type"].(string), "/", "-") + ") " + short
1054+ }
1055+ }
1056+ if needle != "" && !strings.Contains(strings.ToLower(p+" "+m["type"].(string)+" "+m["sha256"].(string)), needle) {
1057+ continue
1058+ }
1059+ if !strings.HasPrefix(p, prefix) {
1060+ continue
1061+ }
1062+ rest := strings.TrimPrefix(p, prefix)
1063+ if rest == "" {
1064+ continue
1065+ }
1066+ if i := strings.IndexByte(rest, '/'); i >= 0 {
1067+ folders[rest[:i]] = true
1068+ continue
1069+ }
1070+ if p != "" {
1071+ m["path"] = p
1072+ m["name"] = path.Base(p)
1073+ }
1074+ m["kind"] = "file"
1075+ m["open"] = "/file?hash=" + url.QueryEscape(m["sha256"].(string))
1076+ if contextName, ok := m["context"].(string); ok {
1077+ m["context_url"] = "/files?view=" + contextName + "s&path=" + url.QueryEscape(path.Dir(p))
1078+ }
1079+ files = append(files, m)
1080+ }
1081+ items := []map[string]any{}
1082+ for n := range folders {
1083+ items = append(items, map[string]any{"kind": "folder", "name": n, "path": strings.Trim(prefix+n, "/"), "open": "/files?view=" + url.QueryEscape(view) + "&path=" + url.QueryEscape(strings.Trim(prefix+n, "/"))})
1084+ }
1085+ items = append(items, files...)
1086+ sort.Slice(items, func(i, j int) bool { return fileItemKey(items[i]) < fileItemKey(items[j]) })
1087+ if q.Cursor != "" {
1088+ cursor := q.Cursor
1089+ if decoded, err := base64.RawURLEncoding.DecodeString(cursor); err == nil {
1090+ cursor = string(decoded)
1091+ }
1092+ position := sort.Search(len(items), func(i int) bool { return fileItemKey(items[i]) > cursor })
1093+ items = items[position:]
1094+ }
1095+ next := ""
1096+ if len(items) > q.Limit {
1097+ next = base64.RawURLEncoding.EncodeToString([]byte(fileItemKey(items[q.Limit-1])))
1098+ items = items[:q.Limit]
1099+ }
1100+ return map[string]any{"items": items, "next_cursor": next, "breadcrumbs": breadcrumbs(view, prefix), "view": view, "path": strings.Trim(prefix, "/"), "can_manage": false}
1101+}
1102+func fileItemKey(item map[string]any) string {
1103+ if item["kind"] == "folder" {
1104+ return "0\x00" + item["path"].(string)
1105+ }
1106+ return "1\x00" + item["path"].(string) + "\x00" + item["sha256"].(string)
1107+}
1108+func breadcrumbs(view, p string) []map[string]string {
1109+ label := map[string]string{"library": "My files", "sites": "Sites", "rooms": "Rooms", "storage": "Storage"}[view]
1110+ out := []map[string]string{{"name": label, "open": "/files?view=" + view}}
1111+ parts := strings.Split(strings.Trim(p, "/"), "/")
1112+ cur := ""
1113+ for _, v := range parts {
1114+ if v == "" {
1115+ continue
1116+ }
1117+ if cur != "" {
1118+ cur += "/"
1119+ }
1120+ cur += v
1121+ out = append(out, map[string]string{"name": v, "open": "/files?view=" + view + "&path=" + url.QueryEscape(cur)})
1122+ }
1123+ return out
1124+}
1125+
1126+func (t Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {
1127+ sitesH := map[string][]string{}
1128+ roomsH := map[string][]string{}
1129+ var before *storage.EventCursor
1130+ for {
1131+ r, err := t.store.Query(ctx, event.Filter{Kinds: []int{sites.KindSite, sites.KindNamedSite, sites.KindSiteSnapshot}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(&t, actor).PubKeys}, Limit: 100, Before: before})
1132+ if err != nil {
1133+ return nil, nil, err
1134+ }
1135+ for _, e := range r.Events {
1136+ label := sites.SiteLabel(e)
1137+ for _, p := range sites.SitePaths(e) {
1138+ if len(p) == 3 {
1139+ sitesH[p[2]] = append(sitesH[p[2]], label+"/"+strings.TrimPrefix(p[1], "/"))
1140+ }
1141+ }
1142+ }
1143+ if !r.More || len(r.Events) == 0 {
1144+ break
1145+ }
1146+ last := r.Events[len(r.Events)-1]
1147+ before = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}
1148+ }
1149+ rs, err := t.community.Rooms(ctx)
1150+ if err != nil {
1151+ return nil, nil, err
1152+ }
1153+ for _, rm := range rs {
1154+ if _, _, e := t.roomFor(ctx, actor, rm.ID); e != nil {
1155+ continue
1156+ }
1157+ rows, e := t.store.DB().QueryContext(ctx, "SELECT sha256,filename FROM room_attachments WHERE room_id=? AND room_event_id=?", rm.ID, rm.EventID)
1158+ if e != nil {
1159+ return nil, nil, e
1160+ }
1161+ for rows.Next() {
1162+ var h, filename string
1163+ if e := rows.Scan(&h, &filename); e != nil {
1164+ rows.Close()
1165+ return nil, nil, e
1166+ }
1167+ roomsH[h] = append(roomsH[h], rm.ID+"/"+roomAttachmentFilename(filename))
1168+ }
1169+ if err := rows.Err(); err != nil {
1170+ rows.Close()
1171+ return nil, nil, err
1172+ }
1173+ rows.Close()
1174+ }
1175+ return sitesH, roomsH, nil
1176+}
1177+
1178+func (t Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
1179+ query := `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE 1=1`
1180+ args := []any{}
1181+ if q.Cursor != "" {
1182+ var cursor struct {
1183+ Uploaded int64
1184+ Hash string
1185+ }
1186+ decoded, err := base64.RawURLEncoding.DecodeString(q.Cursor)
1187+ if err != nil || json.Unmarshal(decoded, &cursor) != nil || len(cursor.Hash) != 64 {
1188+ return nil, errors.New("invalid: storage cursor")
1189+ }
1190+ query += " AND (uploaded<? OR (uploaded=? AND sha256<?))"
1191+ args = append(args, cursor.Uploaded, cursor.Uploaded, cursor.Hash)
1192+ }
1193+ if needle := strings.ToLower(strings.TrimSpace(q.Query)); needle != "" {
1194+ query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"
1195+ args = append(args, needle, needle, needle)
1196+ }
1197+ query += " ORDER BY uploaded DESC,sha256 DESC LIMIT ?"
1198+ args = append(args, q.Limit+1)
1199+ rows, err := t.store.DB().QueryContext(ctx, query, args...)
1200+ if err != nil {
1201+ return nil, err
1202+ }
1203+ defer rows.Close()
1204+ items := []map[string]any{}
1205+ for rows.Next() {
1206+ var entry blob.Blob
1207+ if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {
1208+ return nil, err
1209+ }
1210+ item := t.browseBlobMetadata(entry)
1211+ item["kind"], item["name"], item["path"] = "file", entry.SHA256[:12], ""
1212+ items = append(items, item)
1213+ }
1214+ if err := rows.Err(); err != nil {
1215+ return nil, err
1216+ }
1217+ next := ""
1218+ if len(items) > q.Limit {
1219+ last := items[q.Limit-1]
1220+ encoded, _ := json.Marshal(struct {
1221+ Uploaded int64
1222+ Hash string
1223+ }{last["uploaded"].(int64), last["sha256"].(string)})
1224+ next = base64.RawURLEncoding.EncodeToString(encoded)
1225+ items = items[:q.Limit]
1226+ }
1227+ return map[string]any{"items": items, "view": "storage", "path": "", "breadcrumbs": breadcrumbs("storage", ""), "next_cursor": next}, nil
1228+}
1229+func (t Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
1230+ _, rooms, err := t.contextHashes(ctx, actor)
1231+ if err != nil {
1232+ return nil, err
1233+ }
1234+ rows, err := t.contextFileRows(ctx, rooms, "room")
1235+ if err != nil {
1236+ return nil, err
1237+ }
1238+ result := t.folderItems(rows, map[string]bool{}, q, "rooms", true)
1239+ allRooms, err := t.community.Rooms(ctx)
1240+ if err != nil {
1241+ return nil, err
1242+ }
1243+ labels := map[string]string{}
1244+ for _, room := range allRooms {
1245+ labels[room.ID] = room.Name
1246+ }
1247+ for _, item := range result["items"].([]map[string]any) {
1248+ root := strings.SplitN(item["path"].(string), "/", 2)[0]
1249+ if q.Path == "" {
1250+ item["kind"] = "room"
1251+ if labels[root] != "" {
1252+ item["name"] = labels[root]
1253+ }
1254+ }
1255+ item["context"], item["context_url"] = labels[root], "/rooms/"+url.PathEscape(root)
1256+ }
1257+ crumbs := result["breadcrumbs"].([]map[string]string)
1258+ if len(crumbs) > 1 && labels[crumbs[1]["name"]] != "" {
1259+ crumbs[1]["name"] = labels[crumbs[1]["name"]]
1260+ }
1261+ return result, nil
1262+}
1263+func (t Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
1264+ sitesH, _, err := t.contextHashes(ctx, actor)
1265+ if err != nil {
1266+ return nil, err
1267+ }
1268+ rows, err := t.contextFileRows(ctx, sitesH, "site")
1269+ if err != nil {
1270+ return nil, err
1271+ }
1272+ result := t.folderItems(rows, map[string]bool{}, q, "sites", true)
1273+ if q.Path == "" {
1274+ for _, item := range result["items"].([]map[string]any) {
1275+ item["kind"] = "site"
1276+ }
1277+ }
1278+ return result, nil
1279+}
1280+
1281+// Read only the referenced blobs. Claim labels belong to uploaders and must
1282+// not become names for files viewed through someone else's site or room.
1283+func (t Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {
1284+ hashes := make([]string, 0, len(refs))
1285+ for hash := range refs {
1286+ hashes = append(hashes, hash)
1287+ }
1288+ items := []map[string]any{}
1289+ for start := 0; start < len(hashes); start += 250 {
1290+ encoded, _ := json.Marshal(hashes[start:min(start+250, len(hashes))])
1291+ rows, err := t.store.DB().QueryContext(ctx, `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256 IN (SELECT value FROM json_each(?))`, string(encoded))
1292+ if err != nil {
1293+ return nil, err
1294+ }
1295+ for rows.Next() {
1296+ var entry blob.Blob
1297+ if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {
1298+ rows.Close()
1299+ return nil, err
1300+ }
1301+ item := t.browseBlobMetadata(entry)
1302+ delete(item, "uploader")
1303+ items = append(items, item)
1304+ }
1305+ if err := rows.Err(); err != nil {
1306+ rows.Close()
1307+ return nil, err
1308+ }
1309+ rows.Close()
1310+ }
1311+ return contextualRows(items, refs, contextName), nil
1312+}
1313+
1314+func contextualRows(rows []map[string]any, refs map[string][]string, contextName string) []map[string]any {
1315+ out := make([]map[string]any, 0, len(refs))
1316+ seen := map[string]bool{}
1317+ for _, row := range rows {
1318+ hash, _ := row["sha256"].(string)
1319+ paths, ok := refs[hash]
1320+ if !ok {
1321+ continue
1322+ }
1323+ for _, ref := range paths {
1324+ key := contextName + "\x00" + ref + "\x00" + hash
1325+ if seen[key] {
1326+ continue
1327+ }
1328+ seen[key] = true
1329+ copy := make(map[string]any, len(row)+3)
1330+ for k, v := range row {
1331+ copy[k] = v
1332+ }
1333+ copy["path"] = ref
1334+ copy["name"] = path.Base(ref)
1335+ copy["context"] = contextName
1336+ out = append(out, copy)
1337+ }
1338+ }
1339+ return out
1340+}
1341diff --git a/internal/daemon/client_files_test.go b/internal/daemon/client_files_test.go
1342new file mode 100644
1343index 0000000..7895dd2
1344--- /dev/null
1345+++ b/internal/daemon/client_files_test.go
1346@@ -0,0 +1,235 @@
1347+package daemon
1348+
1349+import (
1350+ "context"
1351+ "encoding/json"
1352+ "fmt"
1353+ "strings"
1354+ "testing"
1355+ "time"
1356+
1357+ "github.com/FelineStateMachine/tinyrelay/internal/blob"
1358+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1359+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1360+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
1361+ "github.com/FelineStateMachine/tinyrelay/internal/sites"
1362+)
1363+
1364+func fileItems(t *testing.T, value any) []map[string]any {
1365+ t.Helper()
1366+ row, ok := value.(map[string]any)
1367+ if !ok {
1368+ t.Fatalf("files result = %T", value)
1369+ }
1370+ items, ok := row["items"].([]map[string]any)
1371+ if !ok {
1372+ t.Fatalf("files items = %T", row["items"])
1373+ }
1374+ return items
1375+}
1376+
1377+func TestFolderItemsKeepsFoldersAndFilesPageable(t *testing.T) {
1378+ rows := []map[string]any{
1379+ {"sha256": "aaaaaaaa", "type": "text/plain", "uploader": "alice", "uploaded": int64(2), "name": "readme.txt", "path": "docs/readme.txt"},
1380+ {"sha256": "bbbbbbbb", "type": "image/png", "uploader": "alice", "uploaded": int64(1), "name": "cover.png", "path": "cover.png"},
1381+ }
1382+ first := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1)}, "library", true)
1383+ items := first["items"].([]map[string]any)
1384+ if len(items) != 1 || items[0]["kind"] != "folder" || first["next_cursor"] == "" {
1385+ t.Fatalf("first page = %#v", first)
1386+ }
1387+ second := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1), Cursor: first["next_cursor"].(string)}, "library", true)
1388+ items = second["items"].([]map[string]any)
1389+ if len(items) != 1 || items[0]["kind"] != "file" || items[0]["name"] != "cover.png" {
1390+ t.Fatalf("second page = %#v", second)
1391+ }
1392+}
1393+
1394+func browseRequestForTest(limit int) gitrelay.BrowseRequest {
1395+ return gitrelay.BrowseRequest{Limit: limit}
1396+}
1397+
1398+func TestContextualRowsRetainsDistinctPathsForSharedHash(t *testing.T) {
1399+ rows := []map[string]any{{"sha256": "same", "name": "old", "path": "old"}}
1400+ out := contextualRows(rows, map[string][]string{"same": {"site-a/index.html", "site-b/index.html"}}, "site")
1401+ if len(out) != 2 || out[0]["path"] == out[1]["path"] {
1402+ t.Fatalf("contextual rows = %#v", out)
1403+ }
1404+}
1405+
1406+func TestContextualFilesRespectClaimNamesAndPaginateLibrary(t *testing.T) {
1407+ _, tenant := testTenant(t)
1408+ ctx := context.Background()
1409+ owner := tenant.Policy().Owner
1410+ other := strings.Repeat("b", 64)
1411+ shared, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("shared"), Type: "text/plain", Uploader: owner, Name: "owner.txt", Path: "docs/owner.txt"})
1412+ if err != nil {
1413+ t.Fatal(err)
1414+ }
1415+ if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("shared"), Type: "text/plain", Uploader: other, Name: "private.txt", Path: "private/private.txt"}); err != nil {
1416+ t.Fatal(err)
1417+ }
1418+ for i := 0; i < 60; i++ {
1419+ if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(fmt.Sprintf("library-%d", i)), Type: "text/plain", Uploader: owner, Name: fmt.Sprintf("file-%02d.txt", i), Path: fmt.Sprintf("folder-%02d/file-%02d.txt", i, i)}); err != nil {
1420+ t.Fatal(err)
1421+ }
1422+ }
1423+ firstValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50})})
1424+ if err != nil {
1425+ t.Fatal(err)
1426+ }
1427+ first := fileItems(t, firstValue)
1428+ firstRow := firstValue.(map[string]any)
1429+ if len(first) != 50 || firstRow["next_cursor"] == "" {
1430+ t.Fatalf("library first page = %d, next=%v", len(first), firstRow["next_cursor"])
1431+ }
1432+ secondValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50, "cursor": firstRow["next_cursor"]})})
1433+ if err != nil {
1434+ t.Fatal(err)
1435+ }
1436+ second := fileItems(t, secondValue)
1437+ if len(second) != 11 {
1438+ t.Fatalf("library second page = %d, want 11", len(second))
1439+ }
1440+ seen := map[string]bool{}
1441+ for _, item := range append(first, second...) {
1442+ key := item["path"].(string)
1443+ if seen[key] {
1444+ t.Fatalf("duplicate library path %q", key)
1445+ }
1446+ seen[key] = true
1447+ }
1448+ ownerFile, err := tenant.Execute(ctx, owner, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})
1449+ if err != nil {
1450+ t.Fatal(err)
1451+ }
1452+ if ownerFile.(map[string]any)["name"] != "owner.txt" || ownerFile.(map[string]any)["path"] != "docs/owner.txt" {
1453+ t.Fatalf("owner file metadata = %#v", ownerFile)
1454+ }
1455+ otherFile, err := tenant.Execute(ctx, other, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})
1456+ if err != nil {
1457+ t.Fatal(err)
1458+ }
1459+ if otherFile.(map[string]any)["name"] == "owner.txt" || otherFile.(map[string]any)["path"] == "docs/owner.txt" {
1460+ t.Fatalf("other uploader saw owner's name: %#v", otherFile)
1461+ }
1462+}
1463+
1464+func TestStorageViewDeduplicatesSharedBlobClaims(t *testing.T) {
1465+ _, tenant := testTenant(t)
1466+ ctx := context.Background()
1467+ owner := tenant.Policy().Owner
1468+ other := strings.Repeat("c", 64)
1469+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: owner, Name: "owner.txt"})
1470+ if err != nil {
1471+ t.Fatal(err)
1472+ }
1473+ if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: other, Name: "other.txt"}); err != nil {
1474+ t.Fatal(err)
1475+ }
1476+ value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "storage", "limit": 50})})
1477+ if err != nil {
1478+ t.Fatal(err)
1479+ }
1480+ items := fileItems(t, value)
1481+ count := 0
1482+ for _, item := range items {
1483+ if item["sha256"] == entry.SHA256 {
1484+ count++
1485+ }
1486+ }
1487+ if count != 1 {
1488+ t.Fatalf("shared blob appears %d times in storage view: %#v", count, items)
1489+ }
1490+}
1491+
1492+func TestSiteFilesRetainSharedHashPaths(t *testing.T) {
1493+ app, tenant := testTenant(t)
1494+ defer app.Close(context.Background())
1495+ ctx := context.Background()
1496+ ownerSecret := strings.Repeat("0", 63) + "1"
1497+ owner := tenant.Policy().Owner
1498+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("site bytes"), Type: "text/html", Uploader: owner})
1499+ if err != nil {
1500+ t.Fatal(err)
1501+ }
1502+ manifest := event.Event{Kind: sites.KindSite, CreatedAt: time.Now().Unix(), Tags: [][]string{{"path", "/index.html", entry.SHA256}, {"path", "/mirror.html", entry.SHA256}}}
1503+ if err := event.Sign(&manifest, ownerSecret); err != nil {
1504+ t.Fatal(err)
1505+ }
1506+ if _, err := tenant.Publish(ctx, manifest, relay.Session{PubKeys: []string{owner}}); err != nil {
1507+ t.Fatal(err)
1508+ }
1509+ value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "limit": 50})})
1510+ if err != nil {
1511+ t.Fatal(err)
1512+ }
1513+ paths := map[string]bool{}
1514+ for _, folder := range fileItems(t, value) {
1515+ if folder["kind"] != "folder" && folder["kind"] != "site" {
1516+ continue
1517+ }
1518+ child, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "path": folder["path"], "limit": 50})})
1519+ if err != nil {
1520+ t.Fatal(err)
1521+ }
1522+ for _, item := range fileItems(t, child) {
1523+ if item["sha256"] == entry.SHA256 {
1524+ paths[item["path"].(string)] = true
1525+ }
1526+ }
1527+ }
1528+ if len(paths) != 2 {
1529+ t.Fatalf("site paths for shared hash = %#v", paths)
1530+ }
1531+}
1532+
1533+func TestRoomFilesRespectPrivateAccessAndSameNameRooms(t *testing.T) {
1534+ h := newRoomHarness(t)
1535+ ctx := h.ctx
1536+ owner := h.keys["owner"]
1537+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-one"}, {"name", "Twin"}, {"closed"}}, "")
1538+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-two"}, {"name", "Twin"}, {"closed"}}, "")
1539+ for i, roomID := range []string{"private-one", "private-two"} {
1540+ room, err := h.tenant.community.Room(ctx, roomID)
1541+ if err != nil {
1542+ t.Fatal(err)
1543+ }
1544+ entry, err := h.tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(fmt.Sprintf("private room %d", i)), Type: "text/plain", Uploader: h.keys["alice"]})
1545+ if err != nil {
1546+ t.Fatal(err)
1547+ }
1548+ if _, err := h.tenant.store.DB().ExecContext(ctx, "INSERT INTO room_attachments(room_id,room_event_id,sha256,filename,created_at) VALUES(?,?,?,?,unixepoch())", roomID, room.EventID, entry.SHA256, fmt.Sprintf("note-%d.txt", i)); err != nil {
1549+ t.Fatal(err)
1550+ }
1551+ }
1552+ ownerValue, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})
1553+ if err != nil {
1554+ t.Fatal(err)
1555+ }
1556+ paths := map[string]bool{}
1557+ for _, folder := range fileItems(t, ownerValue) {
1558+ if folder["kind"] != "folder" && folder["kind"] != "room" {
1559+ continue
1560+ }
1561+ child, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "path": folder["path"], "limit": 50})})
1562+ if err != nil {
1563+ t.Fatal(err)
1564+ }
1565+ for _, item := range fileItems(t, child) {
1566+ if item["kind"] == "file" {
1567+ paths[item["path"].(string)] = true
1568+ }
1569+ }
1570+ }
1571+ if len(paths) != 2 {
1572+ t.Fatalf("same-name room paths = %#v", paths)
1573+ }
1574+ bobValue, err := h.tenant.Execute(ctx, h.keys["bob"], "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})
1575+ if err != nil {
1576+ t.Fatal(err)
1577+ }
1578+ if got := len(fileItems(t, bobValue)); got != 0 {
1579+ t.Fatalf("private room files visible to outsider: %d", got)
1580+ }
1581+}
1582diff --git a/internal/daemon/wiki_approval_items.go b/internal/daemon/wiki_approval_items.go
1583new file mode 100644
1584index 0000000..07019db
1585--- /dev/null
1586+++ b/internal/daemon/wiki_approval_items.go
1587@@ -0,0 +1,213 @@
1588+package daemon
1589+
1590+import (
1591+ "context"
1592+ "net/url"
1593+ "strings"
1594+ "time"
1595+
1596+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1597+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
1598+)
1599+
1600+// wikiApprovalItems adapts wiki proposals and merge requests to the common
1601+// approvals shape. A proposal is addressed to the owner and moderators; a
1602+// merge request is addressed only to its destination author.
1603+func (t *Tenant) wikiApprovalItems(ctx context.Context, actor string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {
1604+ roles := t.wikiRoles()
1605+ decides, err := roles.decides(ctx, actor)
1606+ if err != nil {
1607+ return nil, false, err
1608+ }
1609+ items, more, err := t.wikiProposalApprovalItems(ctx, actor, decides, roles, before, limit)
1610+ if err != nil {
1611+ return nil, false, err
1612+ }
1613+ merges, mergeMore, err := t.wikiMergeApprovalItems(ctx, actor, before, limit)
1614+ if err != nil {
1615+ return nil, false, err
1616+ }
1617+ items = append(items, merges...)
1618+ return items, more || mergeMore, nil
1619+}
1620+
1621+func (t *Tenant) wikiProposalApprovalItems(ctx context.Context, actor string, decides bool, roles *wikiRoles, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {
1622+ if !decides {
1623+ return nil, false, nil
1624+ }
1625+ asked := t.wikiApprovalAudience(ctx)
1626+ items := make([]approvalItem, 0, limit)
1627+ more := false
1628+ for {
1629+ page, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}}, storage.QueryOptions{Now: nowUnix(), Access: storage.Access{All: true}, Limit: limit, Before: before})
1630+ if err != nil {
1631+ return nil, false, err
1632+ }
1633+ for i, row := range page.Events {
1634+ before = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}
1635+ proposer, err := roles.proposer(ctx, row.PubKey)
1636+ if err != nil {
1637+ return nil, false, err
1638+ }
1639+ if !proposer {
1640+ continue
1641+ }
1642+ version := wikiVersionFrom(row, false)
1643+ item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))
1644+ item.Asked = asked
1645+ item.Subject = version.Title
1646+ item.About = &approvalAbout{Coordinate: version.Coordinate, Event: row.ID, Kind: "30818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(version.D) + "?version=" + url.QueryEscape(row.ID)}
1647+ items = append(items, item)
1648+ if len(items) == limit {
1649+ more = page.More || i < len(page.Events)-1
1650+ break
1651+ }
1652+ }
1653+ if len(items) == limit || !page.More || len(page.Events) == 0 {
1654+ break
1655+ }
1656+ }
1657+ answers, err := t.approvalAnswers(ctx, items, nowUnix())
1658+ if err != nil {
1659+ return nil, false, err
1660+ }
1661+ for i := range items {
1662+ approvalSettle(&items[i], answers[items[i].ID], nowUnix())
1663+ if err := t.settleWikiProposal(ctx, &items[i], roles); err != nil {
1664+ return nil, false, err
1665+ }
1666+ }
1667+ return items, more, nil
1668+}
1669+
1670+func (t *Tenant) wikiMergeApprovalItems(ctx context.Context, actor string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {
1671+ page, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiMerge}, Tags: map[string][]string{"p": {actor}}}, storage.QueryOptions{Now: nowUnix(), Access: storage.Access{All: true}, Limit: limit, Before: before})
1672+ if err != nil {
1673+ return nil, false, err
1674+ }
1675+ items := make([]approvalItem, 0, len(page.Events))
1676+ for _, row := range page.Events {
1677+ merge := wikiMergeFrom(row)
1678+ if merge.Destination != actor {
1679+ continue
1680+ }
1681+ item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))
1682+ item.Asked = []string{actor}
1683+ item.Subject = "Wiki merge request"
1684+ item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(merge.TargetD) + "?merge=" + row.ID}
1685+ items = append(items, item)
1686+ }
1687+ answers, err := t.approvalAnswers(ctx, items, nowUnix())
1688+ if err != nil {
1689+ return nil, false, err
1690+ }
1691+ for i := range items {
1692+ approvalSettle(&items[i], answers[items[i].ID], nowUnix())
1693+ status, err := t.wikiMergeStatus(ctx, items[i].ID, actor)
1694+ if err != nil {
1695+ return nil, false, err
1696+ }
1697+ if status.Status == wikiMergeOpen {
1698+ items[i].State = "open"
1699+ items[i].Answer = nil
1700+ } else {
1701+ items[i].State = "answered"
1702+ answer, err := t.wikiReactionAnswer(ctx, status.Reaction)
1703+ if err != nil {
1704+ return nil, false, err
1705+ }
1706+ items[i].Answer = answer
1707+ }
1708+ }
1709+ return items, page.More, nil
1710+}
1711+
1712+func (t *Tenant) settleWikiProposal(ctx context.Context, item *approvalItem, roles *wikiRoles) error {
1713+ version := wikiVersionFrom(item.Event, false)
1714+ versions := []wikiVersion{version}
1715+ if err := t.wikiProposals(ctx, roles, versions); err != nil {
1716+ return err
1717+ }
1718+ switch versions[0].Approval {
1719+ case wikiProposalApproved, wikiProposalRejected:
1720+ item.State = "answered"
1721+ answer, err := t.wikiReactionAnswer(ctx, versions[0].ApprovalEvent)
1722+ if err != nil {
1723+ return err
1724+ }
1725+ item.Answer = answer
1726+ default:
1727+ item.State = "open"
1728+ item.Answer = nil
1729+ }
1730+ return nil
1731+}
1732+
1733+func (t *Tenant) wikiReactionAnswer(ctx context.Context, id string) (*approvalAnswer, error) {
1734+ if id == "" {
1735+ return nil, nil
1736+ }
1737+ rows, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Kinds: []int{kindReaction}, Limit: intPtr(1)}, storage.QueryOptions{Now: nowUnix(), Access: storage.Access{All: true}})
1738+ if err != nil || len(rows.Events) == 0 {
1739+ return nil, err
1740+ }
1741+ reaction := rows.Events[0]
1742+ decision := approvalDecision(reaction.Content)
1743+ if decision == "" {
1744+ return nil, nil
1745+ }
1746+ return &approvalAnswer{ID: reaction.ID, Kind: reaction.Kind, Author: reaction.PubKey, Decision: decision, Content: reaction.Content, CreatedAt: reaction.CreatedAt}, nil
1747+}
1748+
1749+// wikiApprovalByID returns a wiki request only when the caller is one of the
1750+// people allowed to decide it, or the author of the proposed version. This
1751+// keeps the detail endpoint consistent with the approvals listing while
1752+// allowing a proposer to inspect their own pending request.
1753+func (t *Tenant) wikiApprovalByID(ctx context.Context, actor, id string) (approvalItem, bool, error) {
1754+ rows, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Kinds: []int{kindWikiArticle, kindWikiMerge}, Limit: intPtr(1)}, storage.QueryOptions{Now: nowUnix(), Access: storage.Access{All: true}})
1755+ if err != nil || len(rows.Events) == 0 {
1756+ return approvalItem{}, false, err
1757+ }
1758+ row := rows.Events[0]
1759+ roles := t.wikiRoles()
1760+ if row.Kind == kindWikiArticle {
1761+ proposer, err := roles.proposer(ctx, row.PubKey)
1762+ if err != nil {
1763+ return approvalItem{}, false, err
1764+ }
1765+ decides, err := roles.decides(ctx, actor)
1766+ if err != nil {
1767+ return approvalItem{}, false, err
1768+ }
1769+ if !proposer || (!decides && row.PubKey != actor) {
1770+ return approvalItem{}, false, nil
1771+ }
1772+ version := wikiVersionFrom(row, false)
1773+ item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))
1774+ item.Asked = t.wikiApprovalAudience(ctx)
1775+ item.Subject = version.Title
1776+ item.About = &approvalAbout{Coordinate: version.Coordinate, Event: row.ID, Kind: "30818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(version.D) + "?version=" + url.QueryEscape(row.ID)}
1777+ return item, true, nil
1778+ }
1779+ merge := wikiMergeFrom(row)
1780+ if merge.Destination == "" || merge.Destination != actor {
1781+ return approvalItem{}, false, nil
1782+ }
1783+ item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))
1784+ item.Asked = []string{actor}
1785+ item.Subject = "Wiki merge request"
1786+ item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(merge.TargetD) + "?merge=" + url.QueryEscape(row.ID)}
1787+ return item, true, nil
1788+}
1789+
1790+func (t *Tenant) wikiApprovalAudience(ctx context.Context) []string {
1791+ moderators, err := t.community.Moderators(ctx)
1792+ if err != nil {
1793+ return []string{t.Policy().Owner}
1794+ }
1795+ return uniqueStrings(append([]string{t.Policy().Owner}, moderators...))
1796+}
1797+
1798+func nowUnix() int64 {
1799+ return time.Now().Unix()
1800+}
1801diff --git a/internal/webui/browse.html b/internal/webui/browse.html
1802index 304a9de..fd53502 100644
1803--- a/internal/webui/browse.html
1804+++ b/internal/webui/browse.html
1805@@ -12,24 +12,27 @@
1806 {{end}}</section>
1807 {{end}}
1808
1809-{{define "files"}}<section>
1810+{{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library">
1811 <h1>Files</h1>
1812-<form method="get" action="/files" id="file-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="hash, type or uploader" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/files">clear</a>{{end}}</form>
1813+<nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files?view=sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files?view=rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files?view=storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>
1814+<p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{else}}Your uploads and folders. Site assets and room attachments have their own collections.{{end}}</p>
1815+<form method="get" action="/files" id="file-search"><input type="hidden" name="view" value="{{$view}}">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL "/files" .Query "q" ""}}">Clear</a>{{end}}</form>
1816+{{with index $data "breadcrumbs"}}<nav id="file-breadcrumbs" aria-label="Folder path">{{range $i, $raw := .}}{{$crumb := asMap $raw}}{{if $i}} / {{end}}<a href="{{str (index $crumb "open")}}">{{str (index $crumb "name")}}</a>{{end}}</nav>{{end}}
1817 {{if .Error}}{{template "alert" .Error}}
1818 {{else if .Feed}}<table id="rows">
1819-<thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead>
1820+<thead><tr><th scope="col">Name</th><th scope="col">Details</th><th scope="col">Added</th></tr></thead>
1821 <tbody>
1822-{{range .Feed}}{{$f := asMap .}}<tr><td><a href="/file?sha={{urlquery (str (index $f "sha256"))}}">{{shortID (index $f "sha256")}}</a></td><td>{{str (index $f "type")}} <small>{{str (index $f "size")}} B</small></td><td>{{when (index $f "uploaded")}}</td></tr>
1823+{{range .Feed}}{{$f := asMap .}}{{$kind := str (index $f "kind")}}{{$hash := str (index $f "sha256")}}{{$name := or (str (index $f "name")) (shortID $hash)}}{{$open := or (str (index $f "open")) (printf "/file?hash=%s" (urlquery $hash))}}<tr data-kind="{{$kind}}"><td><a href="{{$open}}">{{if and $hash (hasPrefix (str (index $f "type")) "image/")}}<img loading="lazy" src="/files/raw?hash={{urlquery $hash}}" alt="">{{else}}<span data-file-icon aria-hidden="true">{{if or (eq $kind "folder") (eq $kind "site") (eq $kind "room")}}▱{{else}}▤{{end}}</span>{{end}}<span>{{$name}}</span></a>{{with index $f "context_url"}}<small><a href="{{.}}">{{str (index $f "context")}}</a></small>{{end}}</td><td>{{if eq $kind "folder"}}Folder{{else if eq $kind "site"}}Site{{else if eq $kind "room"}}Room{{else}}{{fileSize (index $f "size")}}<small>{{str (index $f "type")}}</small>{{end}}</td><td>{{with index $f "uploaded"}}<time datetime="{{datetime .}}">{{when .}}</time>{{end}}</td></tr>
1824 {{end}}</tbody>
1825 </table>
1826-{{else}}<p>No files were reported.</p>
1827+{{else}}<p id="file-empty">{{if .Query.Get "q"}}No files match this search.{{else if eq $view "sites"}}No site files here yet.{{else if eq $view "rooms"}}No room attachments here yet.{{else if .Query.Get "path"}}This folder is empty.{{else}}No files here yet. Use Upload to add files or a folder.{{end}}</p>
1828 {{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/files" $.Query "cursor" .}}">next page</a></p>
1829 {{end}}</section>
1830 {{end}}
1831
1832 {{define "file"}}{{if .Error}}<section><h1>File</h1>{{template "alert" .Error}}</section>
1833 {{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section id="file-view">
1834-<h1>File</h1>
1835+<h1>{{or (str (index $file "name")) "File"}}</h1>
1836 <table id="facts">
1837 <tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>
1838 <tr><th>type</th><td>{{str (index $file "type")}}</td></tr>
1839@@ -39,7 +42,7 @@
1840 </table>
1841 <file-workspace-root hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" hidden></file-workspace-root>
1842 <noscript><p>Enable JavaScript to decrypt files and browse folder manifests locally. Raw downloads remain available above.</p></noscript>
1843-<file-tools hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" size="{{str (index $file "size")}}" name="{{shortID (index $file "sha256")}}"></file-tools>
1844+<file-tools hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" size="{{str (index $file "size")}}" name="{{or (str (index $file "name")) (shortID (index $file "sha256"))}}"></file-tools>
1845 {{$type := str (index $file "type")}}{{$raw := printf "/files/raw?hash=%s" (urlquery (str (index $file "sha256")))}}{{if hasPrefix $type "image/"}}<div id="media"><img src="{{$raw}}" alt=""></div>
1846 {{else if hasPrefix $type "video/"}}<div id="media"><video controls src="{{$raw}}"></video></div>
1847 {{else if hasPrefix $type "audio/"}}<div id="media"><audio controls src="{{$raw}}"></audio></div>
1848@@ -58,7 +61,7 @@
1849 {{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}}">
1850 <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>
1851 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}
1852-<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">Approve</button> <button name="reaction" value="-">Deny</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
1853+<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
1854 </approval-item>
1855 {{end}}{{else}}<p>Nothing waits for your decision.</p>
1856 {{end}}{{if $done}}<h3>Answered</h3>
1857diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
1858index 3af6ef4..a7a57f1 100644
1859--- a/internal/webui/file-workspace.js
1860+++ b/internal/webui/file-workspace.js
1861@@ -186,19 +186,21 @@
1862 }
1863 // storeBackground hands one blob to the browser's Background Fetch with
1864 // every request signed up front, then waits for the parked descriptor.
1865- async storeBackground(bytes, {hash, type, name, fragment}) {
1866+ async storeBackground(bytes, {hash, type, name, fragment, metadata}) {
1867 const registration = await navigator.serviceWorker.ready;
1868 const base = new URL(tiny.localPath("/"), location.href).href;
1869 const authorize = (target, method, body) => tiny.authorization(target, method, body);
1870 let requests;
1871 const probe = await fetch(base, {method: "OPTIONS"}).catch(() => null);
1872 if (probe && /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(probe.headers.get("allow") || "")) {
1873- const planned = await upload().plan(bytes, {url: base, hash, type, authorize});
1874- requests = planned.requests.map(
1875- item => new Request(item.url, {method: item.method, headers: item.headers, body: item.body})
1876- );
1877+ const planned = await upload().plan(bytes, {url: base, hash, type});
1878+ requests = await Promise.all(planned.requests.map(async item => {
1879+ const target = this.uploadTarget(item.url, metadata);
1880+ const headers = {...item.headers, authorization: await authorize(target, item.method, item.body)};
1881+ return new Request(target, {method: item.method, headers, body: item.body});
1882+ }));
1883 } else {
1884- const target = base.replace(/\/$/, "") + "/upload";
1885+ const target = this.uploadTarget(base.replace(/\/$/, "") + "/upload", metadata);
1886 requests = [
1887 new Request(target, {
1888 method: "PUT",
1889@@ -429,18 +431,25 @@
1890 }
1891 }
1892 async storePlain(files) {
1893+ const query = new URL(location.href).searchParams;
1894+ const parent = (!query.get("view") || query.get("view") === "library") ? query.get("path") || "" : "";
1895 for (const [index, file] of files.entries()) {
1896 checkAbort(this.controller.signal);
1897 this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…");
1898 const bytes = new Uint8Array(await file.arrayBuffer());
1899 const type = file.type || "application/octet-stream";
1900+ const selectedPath = file.webkitRelativePath || file.name;
1901+ const metadata = {filename: file.name, path: parent ? parent + "/" + selectedPath : selectedPath};
1902 if (files.length === 1 && bytes.byteLength >= BACKGROUND_BYTES && backgroundAvailable()) {
1903- await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name});
1904+ await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name, metadata});
1905 continue;
1906 }
1907- await tiny.signedFetch("/upload", "PUT", bytes, {contentType: type, signal: this.controller.signal});
1908+ await tiny.signedFetch(this.uploadTarget("/upload", metadata), "PUT", bytes, {contentType: type, signal: this.controller.signal});
1909 }
1910 }
1911+ uploadTarget(target, metadata) {
1912+ return metadata ? target + "?" + new URLSearchParams(metadata) : target;
1913+ }
1914 // storeSealed encrypts one file with a fresh AES-GCM key. The ciphertext
1915 // and key stay in memory so a retry resumes without re-encrypting.
1916 async storeSealed(file) {
1917diff --git a/internal/webui/files.go b/internal/webui/files.go
1918new file mode 100644
1919index 0000000..b829ee0
1920--- /dev/null
1921+++ b/internal/webui/files.go
1922@@ -0,0 +1,24 @@
1923+package webui
1924+
1925+import (
1926+ "fmt"
1927+ "strconv"
1928+)
1929+
1930+func fileSize(value any) string {
1931+ size, err := strconv.ParseFloat(plainString(value), 64)
1932+ if err != nil || size < 0 {
1933+ return ""
1934+ }
1935+ if size < 1024 {
1936+ return fmt.Sprintf("%.0f B", size)
1937+ }
1938+ units := []string{"KiB", "MiB", "GiB", "TiB"}
1939+ for i, unit := range units {
1940+ size /= 1024
1941+ if size < 1024 || i == len(units)-1 {
1942+ return fmt.Sprintf("%.1f %s", size, unit)
1943+ }
1944+ }
1945+ return ""
1946+}
1947diff --git a/internal/webui/files_test.go b/internal/webui/files_test.go
1948new file mode 100644
1949index 0000000..35e883f
1950--- /dev/null
1951+++ b/internal/webui/files_test.go
1952@@ -0,0 +1,65 @@
1953+package webui
1954+
1955+import (
1956+ "context"
1957+ "encoding/json"
1958+ "net/http"
1959+ "net/http/httptest"
1960+ "strings"
1961+ "testing"
1962+
1963+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1964+)
1965+
1966+type fileLibraryBackend struct{ fakeBackend }
1967+
1968+func (b *fileLibraryBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
1969+ if method != "browsefiles" {
1970+ return nil, nil
1971+ }
1972+ b.call, b.params = method+":"+actor, params
1973+ var query map[string]any
1974+ if err := json.Unmarshal(params[0], &query); err != nil {
1975+ return nil, err
1976+ }
1977+ return map[string]any{
1978+ "view": query["view"], "path": query["path"], "can_manage": true, "next_cursor": "next-file",
1979+ "breadcrumbs": []map[string]string{{"name": "My files", "open": "/files"}, {"name": "Trip", "open": "/files?path=Trip"}},
1980+ "items": []map[string]any{
1981+ {"kind": "folder", "name": "Photos", "path": "Trip/Photos", "open": "/files?path=Trip%2FPhotos"},
1982+ {"kind": "file", "name": "Snow & sun.jpg", "sha256": strings.Repeat("f", 64), "type": "image/jpeg", "size": 16384, "uploaded": 1789066800},
1983+ },
1984+ }, nil
1985+}
1986+
1987+func TestFilesRenderNamedFoldersAndPreserveBrowseQuery(t *testing.T) {
1988+ backend := &fileLibraryBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
1989+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
1990+ if err != nil {
1991+ t.Fatal(err)
1992+ }
1993+ for _, target := range []string{"/files", "/files?view=sites&path=Trip&q=snow&cursor=old"} {
1994+ response := httptest.NewRecorder()
1995+ app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))
1996+ if response.Code != http.StatusOK {
1997+ t.Fatalf("%s: status %d", target, response.Code)
1998+ }
1999+ var query map[string]any
2000+ if err := json.Unmarshal(backend.params[0], &query); err != nil {
2001+ t.Fatal(err)
2002+ }
2003+ body := response.Body.String()
2004+ for _, want := range []string{`id="file-library"`, `aria-label="File collections"`, `Snow &amp; sun.jpg`, `Photos`, `16.0 KiB`, `loading="lazy"`, `next-file`} {
2005+ if !strings.Contains(body, want) {
2006+ t.Errorf("%s missing %q", target, want)
2007+ }
2008+ }
2009+ if target == "/files" {
2010+ if query["view"] != "library" || !strings.Contains(body, `href="/files" aria-current="page"`) {
2011+ t.Fatalf("default view was not library: %#v", query)
2012+ }
2013+ } else if query["view"] != "sites" || query["path"] != "Trip" || query["q"] != "snow" || query["cursor"] != "old" || !strings.Contains(body, `path=Trip`) || !strings.Contains(body, `q=snow`) {
2014+ t.Fatalf("browse query was lost: %#v", query)
2015+ }
2016+ }
2017+}
2018diff --git a/internal/webui/public.html b/internal/webui/public.html
2019index 9809789..70714aa 100644
2020--- a/internal/webui/public.html
2021+++ b/internal/webui/public.html
2022@@ -25,7 +25,7 @@
2023 </section>
2024 {{end}}
2025
2026-{{define "signin"}}<section>
2027+{{define "signin"}}<section id="signin">
2028 <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>
2029 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}
2030 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
2031diff --git a/internal/webui/style.css b/internal/webui/style.css
2032index 2d5ef63..0b25f6c 100644
2033--- a/internal/webui/style.css
2034+++ b/internal/webui/style.css
2035@@ -82,6 +82,17 @@ share-link{display:inline-block}share-link:empty{display:none}
2036 #rows th{text-align:left;font:12px var(--mono);color:var(--muted);font-weight:normal;padding:0 10px;height:28px;border-bottom:1px solid var(--line)}
2037 #rows th:last-child{text-align:right}
2038 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}
2039+#file-views{border-bottom:1px solid var(--line);margin:0 0 14px}
2040+#file-views a{display:inline-block;padding:10px 14px;color:var(--muted);border-bottom:2px solid transparent}
2041+#file-views a:first-child{padding-left:0}#file-views a[aria-current=page]{color:var(--accent);border-color:var(--accent)}
2042+#file-context,#file-empty{color:var(--muted)}#file-breadcrumbs{margin:16px 0;font:13px var(--mono);overflow-wrap:anywhere}
2043+#file-library #rows{table-layout:fixed}#file-library #rows td{height:auto;padding:12px 10px;white-space:normal;overflow-wrap:anywhere}
2044+#file-library #rows td:first-child{width:auto;font:14px var(--sans)}#file-library #rows th:nth-child(2){width:24%}#file-library #rows th:last-child{width:170px}
2045+#file-library #rows td:first-child>a{display:table;width:100%}#file-library #rows td:first-child>a>span:last-child{display:table-cell;vertical-align:middle;width:100%;padding-left:12px}
2046+#file-library #rows img,#file-library [data-file-icon]{display:block;width:40px;height:40px;border-radius:5px;object-fit:cover;background:var(--rail)}
2047+#file-library [data-file-icon]{text-align:center;font:28px/40px var(--sans);color:var(--muted)}#file-library tr[data-kind=folder] [data-file-icon],#file-library tr[data-kind=site] [data-file-icon],#file-library tr[data-kind=room] [data-file-icon]{color:var(--accent);background:var(--soft)}
2048+#file-library #rows small{display:block;margin-top:3px;color:var(--muted);font:12px var(--mono)}
2049+@media(max-width:700px){#file-library #rows thead,#file-library #rows td:last-child{display:none}#file-library #rows,#file-library #rows tbody,#file-library #rows tr{display:block;width:auto}#file-library #rows tr{padding:12px 0;border-bottom:1px solid var(--line)}#file-library #rows td{display:block;width:auto;padding:0;border:0}#file-library #rows td:first-child{width:auto}#file-library #rows td:nth-child(2){padding:3px 0 0 40px;color:var(--muted);font-size:12px}#file-library #rows td:nth-child(2) small{display:inline;margin-left:8px;overflow-wrap:anywhere}#file-library #rows img,#file-library [data-file-icon]{width:32px;height:32px;line-height:32px}#file-library #rows td:first-child>a>span:last-child{padding-left:8px}#file-views a{padding:10px}#file-search label{max-width:100%}#file-search input{max-width:100%;box-sizing:border-box}}
2050 #pages{font:12px var(--mono)}
2051 #collaboration-filter,#file-search,#wiki-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}
2052 #collaboration-body{max-width:720px}
2053@@ -275,7 +286,8 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi
2054 #gridhead{font:12px var(--mono);color:var(--muted);margin:0 0 6px}
2055
2056 /* Sign in. */
2057-#signer-qr:not([hidden]){display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}
2058+#signin{max-width:720px}
2059+#signer-qr:not([hidden]){display:block;box-sizing:border-box;width:min(240px,100%);height:auto;aspect-ratio:1;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}
2060
2061 /* Rooms: the rail lists rooms, messages read oldest first, and the compose bar stays at the bottom of the column. */
2062 #rail h4{margin:16px 0 4px;padding:0 2ch 0 2.5ch;font:11px var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
2063@@ -365,6 +377,12 @@ html,body{overflow:auto}
2064 #tree{border-right:0;border-bottom:1px solid var(--line)}
2065 #rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}#rows:has(td[data-state]) td:nth-child(3),#rows:has(td[data-state]) th:nth-child(3){display:none}#rows:has(td[data-state]) td:last-child{width:80px}connect-list #rows td:nth-child(2),connect-list #rows th:nth-child(2){display:none}connect-list #rows td:nth-child(3),connect-list #rows td:last-child{width:auto}connect-list #rows td:last-child{white-space:normal}connect-list #rows td:last-child button{margin:2px 0}
2066 #grid,#gridhead,h2:has(+#gridhead){display:none}
2067+#signin #bunker label{display:block}
2068+#signin #bunker-url{display:block;width:100%;min-width:0;margin-top:2px}
2069+#signin #bunker button{display:block;margin-top:8px}
2070+#signin #remember-signer{width:auto;min-width:0}
2071+#signin p{max-width:60ch}
2072+#signin #signer-qr:not([hidden]){width:min(200px,100%)}
2073 room-message[data-own]{margin-left:12px;padding-right:10px}
2074 /* Phones: each wiki row reads as a title line and one muted line of facts. */
2075 #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:" | "}
2076diff --git a/internal/webui/templates.go b/internal/webui/templates.go
2077index 1fc42f0..c620bfd 100644
2078--- a/internal/webui/templates.go
2079+++ b/internal/webui/templates.go
2080@@ -262,6 +262,7 @@ func parseTemplates(a *App) (*template.Template, error) {
2081 "str": plainString,
2082 "datetime": datetime,
2083 "when": when,
2084+ "fileSize": fileSize,
2085 "markdown": a.markdown,
2086 "chatMarkdown": a.chatMarkdown,
2087 "hasPrefix": strings.HasPrefix,
2088diff --git a/internal/webui/webui.go b/internal/webui/webui.go
2089index c024b02..263ba53 100644
2090--- a/internal/webui/webui.go
2091+++ b/internal/webui/webui.go
2092@@ -530,6 +530,13 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
2093 }
2094 }
2095 switch method {
2096+ case "browsefiles":
2097+ view := request.URL.Query().Get("view")
2098+ if view == "" {
2099+ view = "library"
2100+ }
2101+ query["view"] = view
2102+ query["path"] = request.URL.Query().Get("path")
2103 case "browserepo":
2104 view := request.URL.Query().Get("view")
2105 if view == "" || view == "home" {
2106diff --git a/scripts/file-workspace.test.mjs b/scripts/file-workspace.test.mjs
2107index 9714975..61c102c 100644
2108--- a/scripts/file-workspace.test.mjs
2109+++ b/scripts/file-workspace.test.mjs
2110@@ -149,11 +149,33 @@ test("nested folder components upload and decrypt large files without exposing k
2111 const key = new URLSearchParams(new URL(url).hash.slice(1)).get("key");
2112 for (const {url: target, init} of server.requests) {
2113 assert.equal(target.href.includes(key), false);
2114+ assert.equal(target.searchParams.has("filename"), false);
2115+ assert.equal(target.searchParams.has("path"), false);
2116 if (init.body) assert.equal(new TextDecoder().decode(init.body).includes("héllo"), false);
2117 }
2118 assert.ok(server.blobs.size >= 8);
2119 });
2120
2121+test("plain uploads retain names and folder paths within the current library folder", async () => {
2122+ const {window, FileUpload} = load(true);
2123+ window.location.href = "https://relay.test/files?view=library&path=Projects";
2124+ const requests = [];
2125+ window.tiny.signedFetch = async (target, method, bytes) => requests.push({target, method, bytes});
2126+ const upload = new FileUpload();
2127+ upload.connectedCallback();
2128+ upload.controller = new AbortController();
2129+ await upload.storePlain([folderFile("image", "Trip/photos/snow & sun.jpg"), new File(["notes"], "notes.txt")]);
2130+ const first = new URL(requests[0].target, window.location.href);
2131+ assert.equal(first.pathname, "/upload");
2132+ assert.equal(first.searchParams.get("filename"), "snow & sun.jpg");
2133+ assert.equal(first.searchParams.get("path"), "Projects/Trip/photos/snow & sun.jpg");
2134+ assert.equal(new URL(requests[1].target, window.location.href).searchParams.get("path"), "Projects/notes.txt");
2135+ assert.equal(new TextDecoder().decode(requests[0].bytes), "image");
2136+ window.location.href = "https://relay.test/files?view=sites&path=weather";
2137+ await upload.storePlain([new File(["file"], "new.txt")]);
2138+ assert.equal(new URL(requests[2].target, window.location.href).searchParams.get("path"), "new.txt");
2139+});
2140+
2141 test("directory fanout stays invisible while 175 entries remain browsable", async t => {
2142 const server = workspaceServer(t);
2143 const files = Array.from({length: 175}, (_, index) => folderFile("content " + index, "wide/file-" + String(index).padStart(3, "0")));
2144@@ -189,7 +211,7 @@ test("plain uploads store every chosen file and refresh the listing", async () =
2145 const form = upload.querySelector("form");
2146 form.elements.namedItem("file").files = [new File(["one"], "a.txt", {type: "text/plain"}), new File(["two"], "b.bin")];
2147 assert.equal(await upload.run(form), undefined);
2148- assert.deepEqual(sent, [{path: "/upload", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload", method: "PUT", size: 3, type: "application/octet-stream"}]);
2149+ assert.deepEqual(sent, [{path: "/upload?filename=a.txt&path=a.txt", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload?filename=b.bin&path=b.bin", method: "PUT", size: 3, type: "application/octet-stream"}]);
2150 assert.equal(refreshed, 1);
2151 assert.equal(upload.out.textContent, "Stored 2 files.");
2152 });
2153@@ -317,6 +339,8 @@ test("large uploads hand signed requests to Background Fetch and read the parked
2154 assert.ok(started, "background fetch was not started");
2155 assert.equal(started.requests.length, 2);
2156 assert.equal(started.requests[0].method, "PATCH");
2157+ assert.equal(new URL(started.requests[0].url).searchParams.get("filename"), "big.bin");
2158+ assert.equal(new URL(started.requests[1].url).searchParams.get("path"), "big.bin");
2159 assert.equal(started.requests[1].headers.get("upload-offset"), String(5 * 1024 * 1024));
2160 assert.match(started.requests[0].headers.get("authorization"), /^Nostr PATCH:/);
2161 assert.equal(started.options.uploadTotal, bytes.byteLength);

About

ownerc01503e8035f
visibilitypublic
head67e412f92ede

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs