clone

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

feat: organize files and surface wiki approvals

Dami committed 67e412f
Preserve plain upload names and folder paths, including resumable uploads and identical files at multiple paths. Add contextual Files collections, previews, pagination and an owner storage inventory.

Include wiki proposals and merge requests in Approvals with eligible decisions and exact revision links. Fix mobile sign-in form and QR sizing.

Validation: go test ./..., 177 JavaScript tests, generated bundle verification, and browser layout checks.
commit
67e412f92edeeb413231986b2133f84c877ffd07
parent
f8cb2a513e47930a287142ccbae74fcd5c0758d1
author
Dami <dami.etoile@gmail.com>
24 files changed, +1549 -146
Mdocs/files-and-private-repositories.md6+-
Minternal/blob/http.go16+-
Ainternal/blob/metadata.go112+++++++
Ainternal/blob/metadata_test.go74++++
Minternal/blob/multipart.go29+-
Minternal/blob/multipart_test.go25+
Minternal/blob/service.go25+-
Minternal/blob/storage.go33++-
Minternal/daemon/approvals.go23+-
Minternal/daemon/approvals_browse.go34++
Minternal/daemon/approvals_test.go56+++
Minternal/daemon/client_browse.go118+------
Ainternal/daemon/client_files.go507++++++++++++++++++++++++++++++
Ainternal/daemon/client_files_test.go235++++++++++++++
Ainternal/daemon/wiki_approval_items.go213+++++++++++++
Minternal/webui/browse.html19+-
Minternal/webui/file-workspace.js25+-
Ainternal/webui/files.go24+
Ainternal/webui/files_test.go65++++
Minternal/webui/public.html2+-
Minternal/webui/style.css20+-
Minternal/webui/templates.go1+
Minternal/webui/webui.go7+
Mscripts/file-workspace.test.mjs26+-
M docs/files-and-private-repositories.md +4 -2
@@ -1,10 +1,12 @@
11 # Files and private repositories22 3-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.3+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.44 55 ## Uploading66 7-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.7+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.8+9+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.810 911 ## Encrypted files1012
M internal/blob/http.go +13 -3
@@ -78,12 +78,17 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) {
7878 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex"))7979 return8080 }81- blob, created, err := s.putValidated(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, func(actual string) error {81+ name, metadataPath, err := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))82+ if err != nil {83+ s.fail(w, http.StatusBadRequest, err)84+ return85+ }86+ blob, created, err := s.putValidatedWithMetadata(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, name, metadataPath, func(actual string) error {8287 if s.config.ValidateUpload != nil {8388 return s.config.ValidateUpload(r, actual)8489 }8590 return nil86- })91+ }, nil)8792 if err != nil {8893 s.fail(w, statusFor(err), err)8994 return
@@ -252,10 +257,15 @@ func (s *Service) list(w http.ResponseWriter, r *http.Request, pubkey string) {
252257 s.fail(w, http.StatusBadRequest, errors.New("invalid: bad pubkey"))253258 return254259 }255- if _, err := s.authorize(r, ActionList); err != nil {260+ viewer, err := s.authorize(r, ActionList)261+ if err != nil {256262 s.fail(w, http.StatusUnauthorized, err)257263 return258264 }265+ if viewer != pubkey && !s.isOwner(viewer) {266+ s.fail(w, http.StatusForbidden, errors.New("restricted: cannot list another uploader's blobs"))267+ return268+ }259269 limit, err := parseListLimit(r.URL.Query().Get("limit"))260270 if err != nil {261271 s.fail(w, http.StatusBadRequest, err)
A internal/blob/metadata.go +112 -0
@@ -0,0 +1,112 @@
1+package blob2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "fmt"8+ "path"9+ "strings"10+ "time"11+ "unicode"12+ "unicode/utf8"13+)14+15+const maxClaimMetadataBytes = 409616+17+var (18+ errInvalidClaimName = errors.New("invalid: filename must be a single path component")19+ errInvalidClaimPath = errors.New("invalid: upload path must be a safe relative path")20+ errMismatchedClaimName = errors.New("invalid: filename must match the upload path basename")21+)22+23+func validateClaimName(name string) error {24+ if name == "" {25+ return nil26+ }27+ if !utf8.ValidString(name) || len(name) > maxClaimMetadataBytes || strings.ContainsAny(name, "/\\") || name == "." || name == ".." || hasControl(name) {28+ return errInvalidClaimName29+ }30+ return nil31+}32+33+func validateClaimPath(raw string) (string, error) {34+ if raw == "" {35+ return "", nil36+ }37+ if !utf8.ValidString(raw) || len(raw) > maxClaimMetadataBytes || strings.Contains(raw, "\\") || strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") || hasControl(raw) {38+ return "", errInvalidClaimPath39+ }40+ clean := path.Clean(raw)41+ if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != raw {42+ return "", errInvalidClaimPath43+ }44+ for _, part := range strings.Split(raw, "/") {45+ if part == "" || part == "." || part == ".." {46+ return "", errInvalidClaimPath47+ }48+ }49+ return clean, nil50+}51+52+func hasControl(value string) bool {53+ for _, r := range value {54+ if unicode.IsControl(r) {55+ return true56+ }57+ }58+ return false59+}60+61+func claimMetadata(name, rawPath string) (string, string, error) {62+ if err := validateClaimName(name); err != nil {63+ return "", "", err64+ }65+ cleanPath, err := validateClaimPath(rawPath)66+ if err != nil {67+ return "", "", err68+ }69+ if name == "" && cleanPath != "" {70+ name = path.Base(cleanPath)71+ }72+ if cleanPath == "" {73+ cleanPath = name74+ } else if name != "" && path.Base(cleanPath) != name {75+ return "", "", errMismatchedClaimName76+ }77+ return name, cleanPath, nil78+}79+80+func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, uploader, name, metadataPath string) error {81+ if uploader == "" || (name == "" && metadataPath == "") {82+ return nil83+ }84+ _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,updated_at)85+ VALUES(?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`,86+ sha, uploader, name, metadataPath, nowUnix())87+ if err != nil {88+ return fmt.Errorf("record blob claim metadata: %w", err)89+ }90+ return nil91+}92+93+func (s *Service) ListClaimMetadata(ctx context.Context, uploader string) ([]BlobClaimMetadata, error) {94+ 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)95+ FROM blob_claims c LEFT JOIN blob_claim_metadata m ON m.sha256=c.sha256 AND m.uploader=c.uploader96+ 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)97+ if err != nil {98+ return nil, fmt.Errorf("list blob claim metadata: %w", err)99+ }100+ defer rows.Close()101+ entries := make([]BlobClaimMetadata, 0)102+ for rows.Next() {103+ var entry BlobClaimMetadata104+ if err := rows.Scan(&entry.SHA256, &entry.Uploader, &entry.Name, &entry.Path, &entry.UpdatedAt); err != nil {105+ return nil, fmt.Errorf("scan blob claim metadata: %w", err)106+ }107+ entries = append(entries, entry)108+ }109+ return entries, rows.Err()110+}111+112+func nowUnix() int64 { return time.Now().UTC().Unix() }
A internal/blob/metadata_test.go +74 -0
@@ -0,0 +1,74 @@
1+package blob2+3+import (4+ "context"5+ "errors"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+)11+12+func TestUploadStoresClaimScopedNameAndPath(t *testing.T) {13+ s := testService(t)14+ body := "same bytes"15+ request := httptest.NewRequest(http.MethodPut, "https://relay.test/upload?filename=note.txt&path=docs%2Fnote.txt", strings.NewReader(body))16+ request.Header.Set("Content-Type", "text/plain")17+ if response := record(s.Handler(), request); response.Code != http.StatusOK {18+ t.Fatalf("upload status = %d: %s", response.Code, response.Body.String())19+ }20+ entries, err := s.ListClaimMetadata(context.Background(), "uploader")21+ if err != nil || len(entries) != 1 || entries[0].Name != "note.txt" || entries[0].Path != "docs/note.txt" {22+ t.Fatalf("metadata = %+v, %v", entries, err)23+ }24+ other := strings.Repeat("b", 64)25+ if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain", Uploader: other, Name: "other.txt", Path: "other.txt"}); err != nil {26+ t.Fatal(err)27+ }28+ entries, err = s.ListClaimMetadata(context.Background(), other)29+ if err != nil || len(entries) != 1 || entries[0].Name != "other.txt" {30+ t.Fatalf("deduplicated metadata = %+v, %v", entries, err)31+ }32+}33+34+func TestValidateClaimPath(t *testing.T) {35+ for _, path := range []string{"", "file.txt", "dir/file.txt"} {36+ if _, err := validateClaimPath(path); err != nil {37+ t.Errorf("validateClaimPath(%q): %v", path, err)38+ }39+ }40+ 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"} {41+ if _, err := validateClaimPath(path); err == nil {42+ t.Errorf("validateClaimPath(%q) succeeded", path)43+ }44+ }45+ if !errors.Is(validateClaimName("dir/file.txt"), errInvalidClaimName) {46+ t.Fatal("path was accepted as a filename")47+ }48+ if _, _, err := claimMetadata("other.txt", "dir/file.txt"); !errors.Is(err, errMismatchedClaimName) {49+ t.Fatalf("mismatched filename accepted: %v", err)50+ }51+ if name, cleanPath, err := claimMetadata("", "dir/file.txt"); err != nil || name != "file.txt" || cleanPath != "dir/file.txt" {52+ t.Fatalf("path basename metadata = %q, %q, %v", name, cleanPath, err)53+ }54+ if err := validateClaimName(string([]byte{0xff})); !errors.Is(err, errInvalidClaimName) {55+ t.Fatalf("invalid UTF-8 filename accepted: %v", err)56+ }57+}58+59+func TestIdenticalFilesPreserveDistinctFolderPaths(t *testing.T) {60+ s := testService(t)61+ ctx := context.Background()62+ for _, filePath := range []string{"one/note.txt", "two/note.txt", "one/note.txt"} {63+ if _, err := s.Put(ctx, PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: "uploader", Name: "note.txt", Path: filePath}); err != nil {64+ t.Fatal(err)65+ }66+ }67+ entries, err := s.ListClaimMetadata(ctx, "uploader")68+ if err != nil || len(entries) != 2 {69+ t.Fatalf("distinct paths lost or repeated: %#v, %v", entries, err)70+ }71+ if entries[0].SHA256 != entries[1].SHA256 || entries[0].Path == entries[1].Path {72+ t.Fatalf("same content should retain both paths: %#v", entries)73+ }74+}
M internal/blob/multipart.go +22 -7
@@ -29,8 +29,8 @@ const (
2929 )3030 3131 type multipartRequest struct {32- id, hash, uploader, typ string33- length, offset, count int6432+ id, hash, uploader, typ, name, metadataPath string33+ length, offset, count int643434 }3535 3636 type multipartError struct {
@@ -85,6 +85,11 @@ func (s *Service) multipart(w http.ResponseWriter, r *http.Request, hash string)
8585 8686 func parseMultipart(r *http.Request, hash, uploader string) (multipartRequest, error) {8787 part := multipartRequest{id: multipartID(hash, uploader), hash: hash, uploader: uploader, count: r.ContentLength}88+ name, metadataPath, metadataErr := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))89+ if metadataErr != nil {90+ return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())91+ }92+ part.name, part.metadataPath = name, metadataPath8893 if r.ContentLength < 0 {8994 return part, multipartFailure(411, "Content-Length is required")9095 }
@@ -169,8 +174,8 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
169174 return s.checkMultipartQuota(ctx, part, limits)170175 }171176 var length int64172- var typ string173- err := s.store.DB().QueryRowContext(ctx, "SELECT length,type FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ)177+ var typ, name, metadataPath string178+ err := s.store.DB().QueryRowContext(ctx, "SELECT length,type,name,path FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ, &name, &metadataPath)174179 if err != nil && !errors.Is(err, sql.ErrNoRows) {175180 return err176181 }
@@ -183,7 +188,10 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
183188 if errors.Is(err, sql.ErrNoRows) {184189 return s.createMultipart(ctx, part)185190 }186- _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=? WHERE id=?", time.Now().Unix(), part.id)191+ if part.name == "" && part.metadataPath == "" {192+ part.name, part.metadataPath = name, metadataPath193+ }194+ _, 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)187195 return err188196 }189197
@@ -222,7 +230,7 @@ func (s *Service) createMultipart(ctx context.Context, part multipartRequest) er
222230 return err223231 }224232 now := time.Now().Unix()225- _, 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)233+ _, 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)226234 if err != nil {227235 _ = os.Remove(path)228236 }
@@ -298,7 +306,14 @@ func (s *Service) commitMultipart(ctx context.Context, part multipartRequest, ch
298306 s.multipartMu.Unlock()299307 return Blob{}, false, false, admissionErr300308 }301- candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, reservation: part.id}309+ name, metadataPath := part.name, part.metadataPath310+ if name == "" && metadataPath == "" {311+ 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) {312+ s.multipartMu.Unlock()313+ return Blob{}, false, false, err314+ }315+ }316+ candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, name: name, metadataPath: metadataPath, reservation: part.id}302317 if existing, err := s.lookup(ctx, part.hash); err == nil {303318 if existing.Size != part.length {304319 s.multipartMu.Unlock()
M internal/blob/multipart_test.go +25 -0
M internal/blob/service.go +24 -1
@@ -98,12 +98,24 @@ type Blob struct {
9898 Uploaded int64 `json:"uploaded"`9999 }100100 101+// BlobClaimMetadata names a blob for one uploader. Names are claim-scoped so102+// deduplicated blobs can have different names for different uploaders.103+type BlobClaimMetadata struct {104+ SHA256 string `json:"sha256"`105+ Uploader string `json:"uploader"`106+ Name string `json:"name"`107+ Path string `json:"path"`108+ UpdatedAt int64 `json:"updated_at"`109+}110+101111 // PutOptions describes a blob upload and its optional metadata transaction.102112 type PutOptions struct {103113 Reader io.Reader104114 Type string105115 Uploader string106116 Hash string117+ Name string118+ Path string107119 // Commit records ownership metadata in the blob transaction. Its isNew108120 // argument reports whether the blob was newly installed. An error rolls109121 // back all claims.
@@ -171,6 +183,12 @@ func New(ctx context.Context, config Config) (*Service, error) {
171183 PRIMARY KEY (sha256, uploader), FOREIGN KEY (sha256) REFERENCES blobs(sha256) ON DELETE CASCADE172184 ); CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC);173185 CREATE INDEX IF NOT EXISTS blob_claims_uploader ON blob_claims(uploader, claimed_at DESC, sha256 DESC);186+ CREATE TABLE IF NOT EXISTS blob_claim_metadata (187+ sha256 TEXT NOT NULL, uploader TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL,188+ updated_at INTEGER NOT NULL, PRIMARY KEY (sha256,uploader,path),189+ FOREIGN KEY (sha256,uploader) REFERENCES blob_claims(sha256,uploader) ON DELETE CASCADE190+ );191+ CREATE INDEX IF NOT EXISTS blob_claim_metadata_uploader ON blob_claim_metadata(uploader,updated_at DESC,sha256 DESC);174192 CREATE TABLE IF NOT EXISTS blob_reports (175193 id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL DEFAULT '',176194 target_blob TEXT NOT NULL, type TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL,
@@ -181,7 +199,7 @@ func New(ctx context.Context, config Config) (*Service, error) {
181199 WHEN NEW.uploader != '' BEGIN INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) VALUES(NEW.sha256,NEW.uploader,NEW.uploaded); END;182200 CREATE TABLE IF NOT EXISTS blob_blocks (sha256 TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', blocked_at INTEGER NOT NULL DEFAULT 0);183201 CREATE TABLE IF NOT EXISTS blob_tombstones (sha256 TEXT PRIMARY KEY, deleted_at INTEGER NOT NULL);184- 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);202+ 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 '');185203 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);186204 INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at)187205 SELECT sha256,uploader,uploaded FROM blobs WHERE uploader != ''`); err != nil {
@@ -192,6 +210,11 @@ func New(ctx context.Context, config Config) (*Service, error) {
192210 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") {193211 return nil, fmt.Errorf("add blob claim expiry: %w", err)194212 }213+ for _, column := range []string{"name TEXT NOT NULL DEFAULT ''", "path TEXT NOT NULL DEFAULT ''"} {214+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN "+column); err != nil && !strings.Contains(err.Error(), "duplicate column") {215+ return nil, fmt.Errorf("add multipart metadata: %w", err)216+ }217+ }195218 if _, err := config.Store.DB().ExecContext(ctx, "CREATE INDEX IF NOT EXISTS blob_claims_expires ON blob_claims(expires) WHERE expires>0"); err != nil {196219 return nil, fmt.Errorf("index blob claim expiry: %w", err)197220 }
M internal/blob/storage.go +27 -6
@@ -27,7 +27,7 @@ func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) {
2727 }2828 2929 func (s *Service) putOptions(ctx context.Context, options PutOptions) (Blob, bool, error) {30- return s.putValidatedWithCommit(ctx, options.Reader, options.Type, options.Uploader, options.Hash, nil, options.Commit)30+ return s.putValidatedWithMetadata(ctx, options.Reader, options.Type, options.Uploader, options.Hash, options.Name, options.Path, nil, options.Commit)3131 }3232 3333 // Get opens a verified metadata-backed blob for streaming to another service.
@@ -128,6 +128,9 @@ func (s *Service) Delete(ctx context.Context, sha string) error {
128128 if _, err := tx.ExecContext(ctx, "DELETE FROM blobs WHERE sha256=?", sha); err != nil {129129 return fmt.Errorf("delete blob metadata: %w", err)130130 }131+ if _, err := tx.ExecContext(ctx, "DELETE FROM blob_claim_metadata WHERE sha256=?", sha); err != nil {132+ return fmt.Errorf("delete blob claim metadata: %w", err)133+ }131134 return nil132135 }); err != nil {133136 return err
@@ -158,6 +161,9 @@ func (s *Service) DeleteForUploader(ctx context.Context, sha, uploader string) e
158161 if changed == 0 {159162 return sql.ErrNoRows160163 }164+ if _, err := tx.ExecContext(ctx, "DELETE FROM blob_claim_metadata WHERE sha256=? AND uploader=?", sha, uploader); err != nil {165+ return fmt.Errorf("delete blob claim metadata: %w", err)166+ }161167 var remaining int162168 if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM blob_claims WHERE sha256=?", sha).Scan(&remaining); err != nil {163169 return fmt.Errorf("count blob claims: %w", err)
@@ -199,10 +205,19 @@ func (s *Service) put(ctx context.Context, source io.Reader, typ, uploader, clai
199205 }200206 201207 func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error) (Blob, bool, error) {202- return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, validate, nil)208+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, nil)203209 }204210 205211 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) {212+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, commit)213+}214+215+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) {216+ var err error217+ name, path, err = claimMetadata(name, path)218+ if err != nil {219+ return Blob{}, false, err220+ }206221 limits := s.currentLimits()207222 streamLimit := limits.MaxFileBytes208223 quotaStream := false
@@ -249,13 +264,13 @@ func (s *Service) putValidatedWithCommit(ctx context.Context, source io.Reader,
249264 return Blob{}, false, err250265 }251266 }252- return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, commit: commit})267+ return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, name: name, metadataPath: path, commit: commit})253268 }254269 255270 type uploadCandidate struct {256- path, hash, typ, uploader, reservation string257- size int64258- commit func(context.Context, *sql.Tx, Blob, bool) error271+ path, hash, typ, uploader, reservation, name, metadataPath string272+ size int64273+ commit func(context.Context, *sql.Tx, Blob, bool) error259274 }260275 261276 func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate) (Blob, bool, error) {
@@ -304,6 +319,9 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
304319 if _, err := tx.ExecContext(ctx, claimSQL, sha, uploader, time.Now().Unix(), terms.ExpiresAt); err != nil {305320 return err306321 }322+ if err := s.saveClaimMetadata(ctx, tx, sha, uploader, candidate.name, candidate.metadataPath); err != nil {323+ return err324+ }307325 }308326 if candidate.reservation != "" {309327 if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
@@ -374,6 +392,9 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
374392 if _, err := tx.ExecContext(ctx, claimSQL, entry.SHA256, entry.Uploader, entry.Uploaded, terms.ExpiresAt); err != nil {375393 return fmt.Errorf("record blob claim: %w", err)376394 }395+ if err := s.saveClaimMetadata(ctx, tx, entry.SHA256, entry.Uploader, candidate.name, candidate.metadataPath); err != nil {396+ return err397+ }377398 }378399 if candidate.reservation != "" {379400 if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
M internal/daemon/approvals.go +22 -1
M internal/daemon/approvals_browse.go +34 -0
M internal/daemon/approvals_test.go +56 -0
@@ -175,6 +175,62 @@ func TestBrowseApprovalsStatesAndAccess(t *testing.T) {
175175 }176176 }177177 178+func TestBrowseApprovalsIncludesWikiProposalsAndMerges(t *testing.T) {179+ _, tenant, owner, _, moderator, member := wikiProposalTenant(t, "propose")180+ now := time.Now().Unix()181+ proposal := wikiArticle(t, tenant, testAgentSecret, "Approvals", now-200)182+ wikiArticle(t, tenant, wikiOwnerSecret, "Approvals", now-300)183+ 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"})184+ result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})185+ if err != nil {186+ t.Fatal(err)187+ }188+ items := wikiItems(result, "items")189+ if approvalByID(items, proposal.ID) == nil || approvalByID(items, merge.ID) == nil {190+ t.Fatalf("wiki approvals missing: %v", items)191+ }192+ proposalItem := approvalByID(items, proposal.ID)193+ if proposalItem["type"] != approvalWikiProposal || proposalItem["state"] != "open" || proposalItem["about"].(map[string]any)["url"] != "http://relay.test/wiki/approvals?version="+proposal.ID {194+ t.Fatalf("proposal approval %v", proposalItem)195+ }196+ mergeItem := approvalByID(items, merge.ID)197+ if mergeItem["type"] != approvalWikiMerge || mergeItem["state"] != "open" || !strings.Contains(mergeItem["about"].(map[string]any)["url"].(string), "?merge="+merge.ID) {198+ t.Fatalf("merge approval %v", mergeItem)199+ }200+ if got, err := approvalCall(t, tenant, member, "browseapprovals", map[string]any{}); err != nil || len(wikiItems(got, "items")) != 0 {201+ t.Fatalf("member saw wiki approvals: %v %v", got, err)202+ }203+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "+", []string{"e", proposal.ID})204+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-40, "-", []string{"e", merge.ID})205+ result, err = approvalCall(t, tenant, moderator, "browseapprovals", map[string]any{})206+ if err != nil {207+ t.Fatal(err)208+ }209+ if approvalByID(wikiItems(result, "items"), proposal.ID) == nil {210+ t.Fatal("moderator cannot see proposal")211+ }212+ if approvalByID(wikiItems(result, "items"), proposal.ID)["state"] != "answered" {213+ t.Fatalf("proposal state %v", result)214+ }215+ detail, err := approvalCall(t, tenant, moderator, "browseapproval", map[string]any{"id": proposal.ID})216+ if err != nil || detail["item"].(map[string]any)["state"] != "answered" {217+ t.Fatalf("proposal detail %v %v", detail, err)218+ }219+ if _, err := approvalCall(t, tenant, member, "browseapproval", map[string]any{"id": proposal.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {220+ t.Fatalf("proposal detail leaked to member: %v", err)221+ }222+ detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": merge.ID})223+ if err != nil || detail["item"].(map[string]any)["state"] != "answered" {224+ t.Fatalf("merge detail %v %v", detail, err)225+ }226+ proposal2 := wikiArticle(t, tenant, testAgentSecret, "Reply stays open", now-20)227+ wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-10, "A note", []string{"e", proposal2.ID})228+ detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": proposal2.ID})229+ if err != nil || detail["item"].(map[string]any)["state"] != "open" {230+ t.Fatalf("wiki reply settled proposal: %v %v", detail, err)231+ }232+}233+178234 func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {179235 _, tenant := testTenant(t)180236 ctx := context.Background()
M internal/daemon/client_browse.go +11 -107
@@ -7,7 +7,6 @@ import (
77 "errors"88 "fmt"99 "io"10- "net/url"1110 "sort"1211 "strings"1312 "time"
@@ -237,112 +236,6 @@ func (t *Tenant) browseRepoEvents(ctx context.Context, actor string, r gitrelay.
237236 return result, nil238237 }239238 240-func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {241- if !t.Policy().Features.Files {242- return nil, errors.New("not found: files are disabled")243- }244- if actor == "" {245- return nil, errors.New("auth-required: sign in to browse your files")246- }247- role, err := t.community.Role(ctx, actor)248- if err != nil {249- return nil, err250- }251- var entries []blob.Blob252- var next string253- if role == "owner" || role == "moderator" {254- entries, next, err = t.browseAllFiles(ctx, q)255- } else if strings.TrimSpace(q.Query) != "" {256- // ListPage is uploader scoped and cannot filter server-side. Walk its257- // cursor until the requested number of matches is collected.258- cursor := q.Cursor259- for len(entries) < q.Limit {260- var page []blob.Blob261- page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)262- if err != nil {263- break264- }265- for _, entry := range page {266- if blobMatchesQuery(entry, q.Query) {267- entries = append(entries, entry)268- if len(entries) == q.Limit {269- break270- }271- }272- }273- if next == "" {274- break275- }276- cursor = next277- }278- } else {279- entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)280- }281- if err != nil {282- return nil, err283- }284- items := []map[string]any{}285- for _, entry := range entries {286- if q.Query != "" && !blobMatchesQuery(entry, q.Query) {287- continue288- }289- items = append(items, t.browseBlobMetadata(entry))290- }291- return map[string]any{"items": items, "next_cursor": next}, nil292-}293-294-func (t *Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {295- query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"296- args := []any{q.Cursor}297- if q.Query != "" {298- query += " AND (instr(lower(sha256), ?) > 0 OR instr(lower(type), ?) > 0 OR instr(lower(uploader), ?) > 0)"299- needle := strings.ToLower(strings.TrimSpace(q.Query))300- args = append(args, needle, needle, needle)301- }302- query += " ORDER BY sha256 LIMIT ?"303- args = append(args, q.Limit+1)304- rows, err := t.store.DB().QueryContext(ctx, query, args...)305- if err != nil {306- return nil, "", err307- }308- defer rows.Close()309- entries := []blob.Blob{}310- for rows.Next() {311- var entry blob.Blob312- if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {313- return nil, "", err314- }315- entries = append(entries, entry)316- }317- if err := rows.Err(); err != nil {318- return nil, "", err319- }320- next := ""321- if len(entries) > q.Limit {322- entries = entries[:q.Limit]323- next = entries[len(entries)-1].SHA256324- }325- return entries, next, nil326-}327-328-func blobMatchesQuery(entry blob.Blob, query string) bool {329- needle := strings.ToLower(strings.TrimSpace(query))330- if needle == "" {331- return true332- }333- return strings.Contains(strings.ToLower(entry.SHA256), needle) ||334- strings.Contains(strings.ToLower(entry.Type), needle) ||335- strings.Contains(strings.ToLower(entry.Uploader), needle)336-}337-338-func (t *Tenant) browseBlobMetadata(entry blob.Blob) map[string]any {339- portable := blob.BlossomURI{Hash: entry.SHA256, Extension: "bin", Author: entry.Uploader, Size: entry.Size}340- if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {341- portable.Servers = []string{origin.Scheme + "://" + origin.Host}342- }343- 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()}344-}345-346239 func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error) {347240 if !t.Policy().Features.Files {348241 return nil, errors.New("not found: files are disabled")
@@ -365,6 +258,17 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
365258 }366259 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)367260 result := t.browseBlobMetadata(entry)261+ if actor != "" {262+ var name, filePath string263+ 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 {264+ if name != "" {265+ result["name"] = name266+ }267+ if filePath != "" {268+ result["path"] = filePath269+ }270+ }271+ }368272 result["binary"], result["truncated"] = binary, truncated369273 if entry.Type == "application/octet-stream" {370274 if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {
A internal/daemon/client_files.go +507 -0
@@ -0,0 +1,507 @@
1+package daemon2+3+// Contextual file browsing. The legacy (empty View) response remains the4+// Blossom inventory used by MCP clients; the views below are for the web UI.5+import (6+ "context"7+ "encoding/base64"8+ "encoding/json"9+ "errors"10+ "net/url"11+ "path"12+ "sort"13+ "strings"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/internal/blob"17+ "github.com/FelineStateMachine/tinyrelay/internal/event"18+ "github.com/FelineStateMachine/tinyrelay/internal/sites"19+ "github.com/FelineStateMachine/tinyrelay/internal/storage"20+)21+22+func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {23+ if q.View == "" {24+ return t.browseFilesLegacy(ctx, actor, q)25+ }26+ if !t.Policy().Features.Files {27+ return nil, errors.New("not found: files are disabled")28+ }29+ if actor == "" {30+ return nil, errors.New("auth-required: sign in to browse your files")31+ }32+ role, err := t.community.Role(ctx, actor)33+ if err != nil {34+ return nil, err35+ }36+ canManage := role == "owner" || role == "moderator"37+ var result any38+ switch q.View {39+ case "library":40+ result, err = t.browseLibrary(ctx, actor, q)41+ case "sites":42+ result, err = t.browseSiteFiles(ctx, actor, q)43+ case "rooms":44+ result, err = t.browseRoomFiles(ctx, actor, q)45+ case "storage":46+ if !canManage {47+ return nil, errors.New("restricted: storage inventory requires owner or moderator")48+ }49+ result, err = t.browseStorage(ctx, actor, q)50+ default:51+ return nil, errors.New("invalid: file view")52+ }53+ if err != nil {54+ return nil, err55+ }56+ if row, ok := result.(map[string]any); ok {57+ row["can_manage"] = canManage58+ }59+ return result, nil60+}61+62+func (t Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {63+ if actor == "" {64+ return nil, errors.New("auth-required: sign in to browse your files")65+ }66+ role, err := t.community.Role(ctx, actor)67+ if err != nil {68+ return nil, err69+ }70+ var entries []blob.Blob71+ var next string72+ if role == "owner" || role == "moderator" {73+ entries, next, err = t.browseAllFiles(ctx, q)74+ } else if q.Query != "" {75+ cursor := q.Cursor76+ for len(entries) < q.Limit {77+ var page []blob.Blob78+ page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)79+ if err != nil {80+ break81+ }82+ for _, e := range page {83+ if blobMatchesQuery(e, q.Query) {84+ entries = append(entries, e)85+ }86+ }87+ if next == "" {88+ break89+ }90+ cursor = next91+ }92+ } else {93+ entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)94+ }95+ if err != nil {96+ return nil, err97+ }98+ items := []map[string]any{}99+ for _, e := range entries {100+ if q.Query == "" || blobMatchesQuery(e, q.Query) {101+ items = append(items, t.browseBlobMetadata(e))102+ }103+ }104+ return map[string]any{"items": items, "next_cursor": next}, nil105+}106+107+func (t Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {108+ query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"109+ args := []any{q.Cursor}110+ needle := strings.ToLower(strings.TrimSpace(q.Query))111+ if needle != "" {112+ query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"113+ args = append(args, needle, needle, needle)114+ }115+ query += " ORDER BY sha256 LIMIT ?"116+ args = append(args, q.Limit+1)117+ rows, err := t.store.DB().QueryContext(ctx, query, args...)118+ if err != nil {119+ return nil, "", err120+ }121+ defer rows.Close()122+ out := []blob.Blob{}123+ for rows.Next() {124+ var e blob.Blob125+ if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded); err != nil {126+ return nil, "", err127+ }128+ out = append(out, e)129+ }130+ if err := rows.Err(); err != nil {131+ return nil, "", err132+ }133+ next := ""134+ if len(out) > q.Limit {135+ next = out[q.Limit-1].SHA256136+ out = out[:q.Limit]137+ }138+ return out, next, nil139+}140+func blobMatchesQuery(e blob.Blob, q string) bool {141+ n := strings.ToLower(strings.TrimSpace(q))142+ return n == "" || strings.Contains(strings.ToLower(e.SHA256), n) || strings.Contains(strings.ToLower(e.Type), n) || strings.Contains(strings.ToLower(e.Uploader), n)143+}144+func (t Tenant) browseBlobMetadata(e blob.Blob) map[string]any {145+ portable := blob.BlossomURI{Hash: e.SHA256, Extension: "bin", Author: e.Uploader, Size: e.Size}146+ if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {147+ portable.Servers = []string{origin.Scheme + "://" + origin.Host}148+ }149+ 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()}150+}151+152+func (t Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[string]any, error) {153+ 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`154+ args := []any{}155+ if !all {156+ q += " WHERE c.uploader=?"157+ args = append(args, actor)158+ }159+ q += " ORDER BY b.uploaded DESC,b.sha256 DESC"160+ rows, err := t.store.DB().QueryContext(ctx, q, args...)161+ if err != nil {162+ return nil, err163+ }164+ defer rows.Close()165+ out := []map[string]any{}166+ for rows.Next() {167+ var e blob.Blob168+ var name, p string169+ if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded, &name, &p); err != nil {170+ return nil, err171+ }172+ m := t.browseBlobMetadata(e)173+ m["name"] = name174+ m["path"] = p175+ out = append(out, m)176+ }177+ return out, rows.Err()178+}179+180+func (t Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {181+ rows, err := t.claimRows(ctx, actor, false)182+ if err != nil {183+ return nil, err184+ }185+ excluded := map[string]bool{}186+ paths, rooms, err := t.contextHashes(ctx, actor)187+ if err != nil {188+ return nil, err189+ }190+ for h := range paths {191+ excluded[h] = true192+ }193+ for h := range rooms {194+ excluded[h] = true195+ }196+ return t.folderItems(rows, excluded, q, "library", false), nil197+}198+func (t Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q clientBrowseRequest, view string, includeExcluded bool) map[string]any {199+ prefix := strings.Trim(q.Path, "/")200+ if prefix != "" {201+ prefix += "/"202+ }203+ folders := map[string]bool{}204+ files := []map[string]any{}205+ needle := strings.ToLower(strings.TrimSpace(q.Query))206+ for _, m := range rows {207+ if !includeExcluded && excluded[m["sha256"].(string)] && m["name"] == "" {208+ continue209+ }210+ p := m["path"].(string)211+ name := m["name"].(string)212+ if p == "" {213+ if name != "" {214+ p = name215+ } else {216+ short := m["sha256"].(string)217+ if len(short) > 12 {218+ short = short[:12]219+ }220+ p = "(" + strings.ReplaceAll(m["type"].(string), "/", "-") + ") " + short221+ }222+ }223+ if needle != "" && !strings.Contains(strings.ToLower(p+" "+m["type"].(string)+" "+m["sha256"].(string)), needle) {224+ continue225+ }226+ if !strings.HasPrefix(p, prefix) {227+ continue228+ }229+ rest := strings.TrimPrefix(p, prefix)230+ if rest == "" {231+ continue232+ }233+ if i := strings.IndexByte(rest, '/'); i >= 0 {234+ folders[rest[:i]] = true235+ continue236+ }237+ if p != "" {238+ m["path"] = p239+ m["name"] = path.Base(p)240+ }241+ m["kind"] = "file"242+ m["open"] = "/file?hash=" + url.QueryEscape(m["sha256"].(string))243+ if contextName, ok := m["context"].(string); ok {244+ m["context_url"] = "/files?view=" + contextName + "s&path=" + url.QueryEscape(path.Dir(p))245+ }246+ files = append(files, m)247+ }248+ items := []map[string]any{}249+ for n := range folders {250+ 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, "/"))})251+ }252+ items = append(items, files...)253+ sort.Slice(items, func(i, j int) bool { return fileItemKey(items[i]) < fileItemKey(items[j]) })254+ if q.Cursor != "" {255+ cursor := q.Cursor256+ if decoded, err := base64.RawURLEncoding.DecodeString(cursor); err == nil {257+ cursor = string(decoded)258+ }259+ position := sort.Search(len(items), func(i int) bool { return fileItemKey(items[i]) > cursor })260+ items = items[position:]261+ }262+ next := ""263+ if len(items) > q.Limit {264+ next = base64.RawURLEncoding.EncodeToString([]byte(fileItemKey(items[q.Limit-1])))265+ items = items[:q.Limit]266+ }267+ return map[string]any{"items": items, "next_cursor": next, "breadcrumbs": breadcrumbs(view, prefix), "view": view, "path": strings.Trim(prefix, "/"), "can_manage": false}268+}269+func fileItemKey(item map[string]any) string {270+ if item["kind"] == "folder" {271+ return "0\x00" + item["path"].(string)272+ }273+ return "1\x00" + item["path"].(string) + "\x00" + item["sha256"].(string)274+}275+func breadcrumbs(view, p string) []map[string]string {276+ label := map[string]string{"library": "My files", "sites": "Sites", "rooms": "Rooms", "storage": "Storage"}[view]277+ out := []map[string]string{{"name": label, "open": "/files?view=" + view}}278+ parts := strings.Split(strings.Trim(p, "/"), "/")279+ cur := ""280+ for _, v := range parts {281+ if v == "" {282+ continue283+ }284+ if cur != "" {285+ cur += "/"286+ }287+ cur += v288+ out = append(out, map[string]string{"name": v, "open": "/files?view=" + view + "&path=" + url.QueryEscape(cur)})289+ }290+ return out291+}292+293+func (t Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {294+ sitesH := map[string][]string{}295+ roomsH := map[string][]string{}296+ var before *storage.EventCursor297+ for {298+ 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})299+ if err != nil {300+ return nil, nil, err301+ }302+ for _, e := range r.Events {303+ label := sites.SiteLabel(e)304+ for _, p := range sites.SitePaths(e) {305+ if len(p) == 3 {306+ sitesH[p[2]] = append(sitesH[p[2]], label+"/"+strings.TrimPrefix(p[1], "/"))307+ }308+ }309+ }310+ if !r.More || len(r.Events) == 0 {311+ break312+ }313+ last := r.Events[len(r.Events)-1]314+ before = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}315+ }316+ rs, err := t.community.Rooms(ctx)317+ if err != nil {318+ return nil, nil, err319+ }320+ for _, rm := range rs {321+ if _, _, e := t.roomFor(ctx, actor, rm.ID); e != nil {322+ continue323+ }324+ rows, e := t.store.DB().QueryContext(ctx, "SELECT sha256,filename FROM room_attachments WHERE room_id=? AND room_event_id=?", rm.ID, rm.EventID)325+ if e != nil {326+ return nil, nil, e327+ }328+ for rows.Next() {329+ var h, filename string330+ if e := rows.Scan(&h, &filename); e != nil {331+ rows.Close()332+ return nil, nil, e333+ }334+ roomsH[h] = append(roomsH[h], rm.ID+"/"+roomAttachmentFilename(filename))335+ }336+ if err := rows.Err(); err != nil {337+ rows.Close()338+ return nil, nil, err339+ }340+ rows.Close()341+ }342+ return sitesH, roomsH, nil343+}344+345+func (t Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {346+ query := `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE 1=1`347+ args := []any{}348+ if q.Cursor != "" {349+ var cursor struct {350+ Uploaded int64351+ Hash string352+ }353+ decoded, err := base64.RawURLEncoding.DecodeString(q.Cursor)354+ if err != nil || json.Unmarshal(decoded, &cursor) != nil || len(cursor.Hash) != 64 {355+ return nil, errors.New("invalid: storage cursor")356+ }357+ query += " AND (uploaded<? OR (uploaded=? AND sha256<?))"358+ args = append(args, cursor.Uploaded, cursor.Uploaded, cursor.Hash)359+ }360+ if needle := strings.ToLower(strings.TrimSpace(q.Query)); needle != "" {361+ query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"362+ args = append(args, needle, needle, needle)363+ }364+ query += " ORDER BY uploaded DESC,sha256 DESC LIMIT ?"365+ args = append(args, q.Limit+1)366+ rows, err := t.store.DB().QueryContext(ctx, query, args...)367+ if err != nil {368+ return nil, err369+ }370+ defer rows.Close()371+ items := []map[string]any{}372+ for rows.Next() {373+ var entry blob.Blob374+ if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {375+ return nil, err376+ }377+ item := t.browseBlobMetadata(entry)378+ item["kind"], item["name"], item["path"] = "file", entry.SHA256[:12], ""379+ items = append(items, item)380+ }381+ if err := rows.Err(); err != nil {382+ return nil, err383+ }384+ next := ""385+ if len(items) > q.Limit {386+ last := items[q.Limit-1]387+ encoded, _ := json.Marshal(struct {388+ Uploaded int64389+ Hash string390+ }{last["uploaded"].(int64), last["sha256"].(string)})391+ next = base64.RawURLEncoding.EncodeToString(encoded)392+ items = items[:q.Limit]393+ }394+ return map[string]any{"items": items, "view": "storage", "path": "", "breadcrumbs": breadcrumbs("storage", ""), "next_cursor": next}, nil395+}396+func (t Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {397+ _, rooms, err := t.contextHashes(ctx, actor)398+ if err != nil {399+ return nil, err400+ }401+ rows, err := t.contextFileRows(ctx, rooms, "room")402+ if err != nil {403+ return nil, err404+ }405+ result := t.folderItems(rows, map[string]bool{}, q, "rooms", true)406+ allRooms, err := t.community.Rooms(ctx)407+ if err != nil {408+ return nil, err409+ }410+ labels := map[string]string{}411+ for _, room := range allRooms {412+ labels[room.ID] = room.Name413+ }414+ for _, item := range result["items"].([]map[string]any) {415+ root := strings.SplitN(item["path"].(string), "/", 2)[0]416+ if q.Path == "" {417+ item["kind"] = "room"418+ if labels[root] != "" {419+ item["name"] = labels[root]420+ }421+ }422+ item["context"], item["context_url"] = labels[root], "/rooms/"+url.PathEscape(root)423+ }424+ crumbs := result["breadcrumbs"].([]map[string]string)425+ if len(crumbs) > 1 && labels[crumbs[1]["name"]] != "" {426+ crumbs[1]["name"] = labels[crumbs[1]["name"]]427+ }428+ return result, nil429+}430+func (t Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {431+ sitesH, _, err := t.contextHashes(ctx, actor)432+ if err != nil {433+ return nil, err434+ }435+ rows, err := t.contextFileRows(ctx, sitesH, "site")436+ if err != nil {437+ return nil, err438+ }439+ result := t.folderItems(rows, map[string]bool{}, q, "sites", true)440+ if q.Path == "" {441+ for _, item := range result["items"].([]map[string]any) {442+ item["kind"] = "site"443+ }444+ }445+ return result, nil446+}447+448+// Read only the referenced blobs. Claim labels belong to uploaders and must449+// not become names for files viewed through someone else's site or room.450+func (t Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {451+ hashes := make([]string, 0, len(refs))452+ for hash := range refs {453+ hashes = append(hashes, hash)454+ }455+ items := []map[string]any{}456+ for start := 0; start < len(hashes); start += 250 {457+ encoded, _ := json.Marshal(hashes[start:min(start+250, len(hashes))])458+ 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))459+ if err != nil {460+ return nil, err461+ }462+ for rows.Next() {463+ var entry blob.Blob464+ if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {465+ rows.Close()466+ return nil, err467+ }468+ item := t.browseBlobMetadata(entry)469+ delete(item, "uploader")470+ items = append(items, item)471+ }472+ if err := rows.Err(); err != nil {473+ rows.Close()474+ return nil, err475+ }476+ rows.Close()477+ }478+ return contextualRows(items, refs, contextName), nil479+}480+481+func contextualRows(rows []map[string]any, refs map[string][]string, contextName string) []map[string]any {482+ out := make([]map[string]any, 0, len(refs))483+ seen := map[string]bool{}484+ for _, row := range rows {485+ hash, _ := row["sha256"].(string)486+ paths, ok := refs[hash]487+ if !ok {488+ continue489+ }490+ for _, ref := range paths {491+ key := contextName + "\x00" + ref + "\x00" + hash492+ if seen[key] {493+ continue494+ }495+ seen[key] = true496+ copy := make(map[string]any, len(row)+3)497+ for k, v := range row {498+ copy[k] = v499+ }500+ copy["path"] = ref501+ copy["name"] = path.Base(ref)502+ copy["context"] = contextName503+ out = append(out, copy)504+ }505+ }506+ return out507+}
A internal/daemon/client_files_test.go +235 -0
@@ -0,0 +1,235 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/blob"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"14+ "github.com/FelineStateMachine/tinyrelay/internal/relay"15+ "github.com/FelineStateMachine/tinyrelay/internal/sites"16+)17+18+func fileItems(t *testing.T, value any) []map[string]any {19+ t.Helper()20+ row, ok := value.(map[string]any)21+ if !ok {22+ t.Fatalf("files result = %T", value)23+ }24+ items, ok := row["items"].([]map[string]any)25+ if !ok {26+ t.Fatalf("files items = %T", row["items"])27+ }28+ return items29+}30+31+func TestFolderItemsKeepsFoldersAndFilesPageable(t *testing.T) {32+ rows := []map[string]any{33+ {"sha256": "aaaaaaaa", "type": "text/plain", "uploader": "alice", "uploaded": int64(2), "name": "readme.txt", "path": "docs/readme.txt"},34+ {"sha256": "bbbbbbbb", "type": "image/png", "uploader": "alice", "uploaded": int64(1), "name": "cover.png", "path": "cover.png"},35+ }36+ first := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1)}, "library", true)37+ items := first["items"].([]map[string]any)38+ if len(items) != 1 || items[0]["kind"] != "folder" || first["next_cursor"] == "" {39+ t.Fatalf("first page = %#v", first)40+ }41+ second := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1), Cursor: first["next_cursor"].(string)}, "library", true)42+ items = second["items"].([]map[string]any)43+ if len(items) != 1 || items[0]["kind"] != "file" || items[0]["name"] != "cover.png" {44+ t.Fatalf("second page = %#v", second)45+ }46+}47+48+func browseRequestForTest(limit int) gitrelay.BrowseRequest {49+ return gitrelay.BrowseRequest{Limit: limit}50+}51+52+func TestContextualRowsRetainsDistinctPathsForSharedHash(t *testing.T) {53+ rows := []map[string]any{{"sha256": "same", "name": "old", "path": "old"}}54+ out := contextualRows(rows, map[string][]string{"same": {"site-a/index.html", "site-b/index.html"}}, "site")55+ if len(out) != 2 || out[0]["path"] == out[1]["path"] {56+ t.Fatalf("contextual rows = %#v", out)57+ }58+}59+60+func TestContextualFilesRespectClaimNamesAndPaginateLibrary(t *testing.T) {61+ _, tenant := testTenant(t)62+ ctx := context.Background()63+ owner := tenant.Policy().Owner64+ other := strings.Repeat("b", 64)65+ shared, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("shared"), Type: "text/plain", Uploader: owner, Name: "owner.txt", Path: "docs/owner.txt"})66+ if err != nil {67+ t.Fatal(err)68+ }69+ 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 {70+ t.Fatal(err)71+ }72+ for i := 0; i < 60; i++ {73+ 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 {74+ t.Fatal(err)75+ }76+ }77+ firstValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50})})78+ if err != nil {79+ t.Fatal(err)80+ }81+ first := fileItems(t, firstValue)82+ firstRow := firstValue.(map[string]any)83+ if len(first) != 50 || firstRow["next_cursor"] == "" {84+ t.Fatalf("library first page = %d, next=%v", len(first), firstRow["next_cursor"])85+ }86+ secondValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50, "cursor": firstRow["next_cursor"]})})87+ if err != nil {88+ t.Fatal(err)89+ }90+ second := fileItems(t, secondValue)91+ if len(second) != 11 {92+ t.Fatalf("library second page = %d, want 11", len(second))93+ }94+ seen := map[string]bool{}95+ for _, item := range append(first, second...) {96+ key := item["path"].(string)97+ if seen[key] {98+ t.Fatalf("duplicate library path %q", key)99+ }100+ seen[key] = true101+ }102+ ownerFile, err := tenant.Execute(ctx, owner, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})103+ if err != nil {104+ t.Fatal(err)105+ }106+ if ownerFile.(map[string]any)["name"] != "owner.txt" || ownerFile.(map[string]any)["path"] != "docs/owner.txt" {107+ t.Fatalf("owner file metadata = %#v", ownerFile)108+ }109+ otherFile, err := tenant.Execute(ctx, other, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})110+ if err != nil {111+ t.Fatal(err)112+ }113+ if otherFile.(map[string]any)["name"] == "owner.txt" || otherFile.(map[string]any)["path"] == "docs/owner.txt" {114+ t.Fatalf("other uploader saw owner's name: %#v", otherFile)115+ }116+}117+118+func TestStorageViewDeduplicatesSharedBlobClaims(t *testing.T) {119+ _, tenant := testTenant(t)120+ ctx := context.Background()121+ owner := tenant.Policy().Owner122+ other := strings.Repeat("c", 64)123+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: owner, Name: "owner.txt"})124+ if err != nil {125+ t.Fatal(err)126+ }127+ if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: other, Name: "other.txt"}); err != nil {128+ t.Fatal(err)129+ }130+ value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "storage", "limit": 50})})131+ if err != nil {132+ t.Fatal(err)133+ }134+ items := fileItems(t, value)135+ count := 0136+ for _, item := range items {137+ if item["sha256"] == entry.SHA256 {138+ count++139+ }140+ }141+ if count != 1 {142+ t.Fatalf("shared blob appears %d times in storage view: %#v", count, items)143+ }144+}145+146+func TestSiteFilesRetainSharedHashPaths(t *testing.T) {147+ app, tenant := testTenant(t)148+ defer app.Close(context.Background())149+ ctx := context.Background()150+ ownerSecret := strings.Repeat("0", 63) + "1"151+ owner := tenant.Policy().Owner152+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("site bytes"), Type: "text/html", Uploader: owner})153+ if err != nil {154+ t.Fatal(err)155+ }156+ manifest := event.Event{Kind: sites.KindSite, CreatedAt: time.Now().Unix(), Tags: [][]string{{"path", "/index.html", entry.SHA256}, {"path", "/mirror.html", entry.SHA256}}}157+ if err := event.Sign(&manifest, ownerSecret); err != nil {158+ t.Fatal(err)159+ }160+ if _, err := tenant.Publish(ctx, manifest, relay.Session{PubKeys: []string{owner}}); err != nil {161+ t.Fatal(err)162+ }163+ value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "limit": 50})})164+ if err != nil {165+ t.Fatal(err)166+ }167+ paths := map[string]bool{}168+ for _, folder := range fileItems(t, value) {169+ if folder["kind"] != "folder" && folder["kind"] != "site" {170+ continue171+ }172+ child, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "path": folder["path"], "limit": 50})})173+ if err != nil {174+ t.Fatal(err)175+ }176+ for _, item := range fileItems(t, child) {177+ if item["sha256"] == entry.SHA256 {178+ paths[item["path"].(string)] = true179+ }180+ }181+ }182+ if len(paths) != 2 {183+ t.Fatalf("site paths for shared hash = %#v", paths)184+ }185+}186+187+func TestRoomFilesRespectPrivateAccessAndSameNameRooms(t *testing.T) {188+ h := newRoomHarness(t)189+ ctx := h.ctx190+ owner := h.keys["owner"]191+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-one"}, {"name", "Twin"}, {"closed"}}, "")192+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-two"}, {"name", "Twin"}, {"closed"}}, "")193+ for i, roomID := range []string{"private-one", "private-two"} {194+ room, err := h.tenant.community.Room(ctx, roomID)195+ if err != nil {196+ t.Fatal(err)197+ }198+ 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"]})199+ if err != nil {200+ t.Fatal(err)201+ }202+ 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 {203+ t.Fatal(err)204+ }205+ }206+ ownerValue, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})207+ if err != nil {208+ t.Fatal(err)209+ }210+ paths := map[string]bool{}211+ for _, folder := range fileItems(t, ownerValue) {212+ if folder["kind"] != "folder" && folder["kind"] != "room" {213+ continue214+ }215+ child, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "path": folder["path"], "limit": 50})})216+ if err != nil {217+ t.Fatal(err)218+ }219+ for _, item := range fileItems(t, child) {220+ if item["kind"] == "file" {221+ paths[item["path"].(string)] = true222+ }223+ }224+ }225+ if len(paths) != 2 {226+ t.Fatalf("same-name room paths = %#v", paths)227+ }228+ bobValue, err := h.tenant.Execute(ctx, h.keys["bob"], "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})229+ if err != nil {230+ t.Fatal(err)231+ }232+ if got := len(fileItems(t, bobValue)); got != 0 {233+ t.Fatalf("private room files visible to outsider: %d", got)234+ }235+}
A internal/daemon/wiki_approval_items.go +213 -0
@@ -0,0 +1,213 @@
1+package daemon2+3+import (4+ "context"5+ "net/url"6+ "strings"7+ "time"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"11+)12+13+// wikiApprovalItems adapts wiki proposals and merge requests to the common14+// approvals shape. A proposal is addressed to the owner and moderators; a15+// merge request is addressed only to its destination author.16+func (t *Tenant) wikiApprovalItems(ctx context.Context, actor string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {17+ roles := t.wikiRoles()18+ decides, err := roles.decides(ctx, actor)19+ if err != nil {20+ return nil, false, err21+ }22+ items, more, err := t.wikiProposalApprovalItems(ctx, actor, decides, roles, before, limit)23+ if err != nil {24+ return nil, false, err25+ }26+ merges, mergeMore, err := t.wikiMergeApprovalItems(ctx, actor, before, limit)27+ if err != nil {28+ return nil, false, err29+ }30+ items = append(items, merges...)31+ return items, more || mergeMore, nil32+}33+34+func (t *Tenant) wikiProposalApprovalItems(ctx context.Context, actor string, decides bool, roles *wikiRoles, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {35+ if !decides {36+ return nil, false, nil37+ }38+ asked := t.wikiApprovalAudience(ctx)39+ items := make([]approvalItem, 0, limit)40+ more := false41+ for {42+ page, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}}, storage.QueryOptions{Now: nowUnix(), Access: storage.Access{All: true}, Limit: limit, Before: before})43+ if err != nil {44+ return nil, false, err45+ }46+ for i, row := range page.Events {47+ before = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}48+ proposer, err := roles.proposer(ctx, row.PubKey)49+ if err != nil {50+ return nil, false, err51+ }52+ if !proposer {53+ continue54+ }55+ version := wikiVersionFrom(row, false)56+ item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))57+ item.Asked = asked58+ item.Subject = version.Title59+ 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)}60+ items = append(items, item)61+ if len(items) == limit {62+ more = page.More || i < len(page.Events)-163+ break64+ }65+ }66+ if len(items) == limit || !page.More || len(page.Events) == 0 {67+ break68+ }69+ }70+ answers, err := t.approvalAnswers(ctx, items, nowUnix())71+ if err != nil {72+ return nil, false, err73+ }74+ for i := range items {75+ approvalSettle(&items[i], answers[items[i].ID], nowUnix())76+ if err := t.settleWikiProposal(ctx, &items[i], roles); err != nil {77+ return nil, false, err78+ }79+ }80+ return items, more, nil81+}82+83+func (t *Tenant) wikiMergeApprovalItems(ctx context.Context, actor string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {84+ 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})85+ if err != nil {86+ return nil, false, err87+ }88+ items := make([]approvalItem, 0, len(page.Events))89+ for _, row := range page.Events {90+ merge := wikiMergeFrom(row)91+ if merge.Destination != actor {92+ continue93+ }94+ item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))95+ item.Asked = []string{actor}96+ item.Subject = "Wiki merge request"97+ item.About = &approvalAbout{Coordinate: merge.Target, Event: row.ID, Kind: "818", URL: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(merge.TargetD) + "?merge=" + row.ID}98+ items = append(items, item)99+ }100+ answers, err := t.approvalAnswers(ctx, items, nowUnix())101+ if err != nil {102+ return nil, false, err103+ }104+ for i := range items {105+ approvalSettle(&items[i], answers[items[i].ID], nowUnix())106+ status, err := t.wikiMergeStatus(ctx, items[i].ID, actor)107+ if err != nil {108+ return nil, false, err109+ }110+ if status.Status == wikiMergeOpen {111+ items[i].State = "open"112+ items[i].Answer = nil113+ } else {114+ items[i].State = "answered"115+ answer, err := t.wikiReactionAnswer(ctx, status.Reaction)116+ if err != nil {117+ return nil, false, err118+ }119+ items[i].Answer = answer120+ }121+ }122+ return items, page.More, nil123+}124+125+func (t *Tenant) settleWikiProposal(ctx context.Context, item *approvalItem, roles *wikiRoles) error {126+ version := wikiVersionFrom(item.Event, false)127+ versions := []wikiVersion{version}128+ if err := t.wikiProposals(ctx, roles, versions); err != nil {129+ return err130+ }131+ switch versions[0].Approval {132+ case wikiProposalApproved, wikiProposalRejected:133+ item.State = "answered"134+ answer, err := t.wikiReactionAnswer(ctx, versions[0].ApprovalEvent)135+ if err != nil {136+ return err137+ }138+ item.Answer = answer139+ default:140+ item.State = "open"141+ item.Answer = nil142+ }143+ return nil144+}145+146+func (t *Tenant) wikiReactionAnswer(ctx context.Context, id string) (*approvalAnswer, error) {147+ if id == "" {148+ return nil, nil149+ }150+ 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}})151+ if err != nil || len(rows.Events) == 0 {152+ return nil, err153+ }154+ reaction := rows.Events[0]155+ decision := approvalDecision(reaction.Content)156+ if decision == "" {157+ return nil, nil158+ }159+ return &approvalAnswer{ID: reaction.ID, Kind: reaction.Kind, Author: reaction.PubKey, Decision: decision, Content: reaction.Content, CreatedAt: reaction.CreatedAt}, nil160+}161+162+// wikiApprovalByID returns a wiki request only when the caller is one of the163+// people allowed to decide it, or the author of the proposed version. This164+// keeps the detail endpoint consistent with the approvals listing while165+// allowing a proposer to inspect their own pending request.166+func (t *Tenant) wikiApprovalByID(ctx context.Context, actor, id string) (approvalItem, bool, error) {167+ 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}})168+ if err != nil || len(rows.Events) == 0 {169+ return approvalItem{}, false, err170+ }171+ row := rows.Events[0]172+ roles := t.wikiRoles()173+ if row.Kind == kindWikiArticle {174+ proposer, err := roles.proposer(ctx, row.PubKey)175+ if err != nil {176+ return approvalItem{}, false, err177+ }178+ decides, err := roles.decides(ctx, actor)179+ if err != nil {180+ return approvalItem{}, false, err181+ }182+ if !proposer || (!decides && row.PubKey != actor) {183+ return approvalItem{}, false, nil184+ }185+ version := wikiVersionFrom(row, false)186+ item := approvalItemFrom(row, approvalWikiProposal, strings.TrimRight(t.publicURL, "/"))187+ item.Asked = t.wikiApprovalAudience(ctx)188+ item.Subject = version.Title189+ 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)}190+ return item, true, nil191+ }192+ merge := wikiMergeFrom(row)193+ if merge.Destination == "" || merge.Destination != actor {194+ return approvalItem{}, false, nil195+ }196+ item := approvalItemFrom(row, approvalWikiMerge, strings.TrimRight(t.publicURL, "/"))197+ item.Asked = []string{actor}198+ item.Subject = "Wiki merge request"199+ 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)}200+ return item, true, nil201+}202+203+func (t *Tenant) wikiApprovalAudience(ctx context.Context) []string {204+ moderators, err := t.community.Moderators(ctx)205+ if err != nil {206+ return []string{t.Policy().Owner}207+ }208+ return uniqueStrings(append([]string{t.Policy().Owner}, moderators...))209+}210+211+func nowUnix() int64 {212+ return time.Now().Unix()213+}
M internal/webui/browse.html +11 -8
@@ -12,24 +12,27 @@
1212 {{end}}</section>1313 {{end}}1414 15-{{define "files"}}<section>15+{{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library">1616 <h1>Files</h1>17-<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>17+<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>18+<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>19+<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>20+{{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}}1821 {{if .Error}}{{template "alert" .Error}}1922 {{else if .Feed}}<table id="rows">20-<thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead>23+<thead><tr><th scope="col">Name</th><th scope="col">Details</th><th scope="col">Added</th></tr></thead>2124 <tbody>22-{{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>25+{{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>2326 {{end}}</tbody>2427 </table>25-{{else}}<p>No files were reported.</p>28+{{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>2629 {{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/files" $.Query "cursor" .}}">next page</a></p>2730 {{end}}</section>2831 {{end}}2932 3033 {{define "file"}}{{if .Error}}<section><h1>File</h1>{{template "alert" .Error}}</section>3134 {{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section id="file-view">32-<h1>File</h1>35+<h1>{{or (str (index $file "name")) "File"}}</h1>3336 <table id="facts">3437 <tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>3538 <tr><th>type</th><td>{{str (index $file "type")}}</td></tr>
@@ -39,7 +42,7 @@
3942 </table>4043 <file-workspace-root hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" hidden></file-workspace-root>4144 <noscript><p>Enable JavaScript to decrypt files and browse folder manifests locally. Raw downloads remain available above.</p></noscript>42-<file-tools hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" size="{{str (index $file "size")}}" name="{{shortID (index $file "sha256")}}"></file-tools>45+<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>4346 {{$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>4447 {{else if hasPrefix $type "video/"}}<div id="media"><video controls src="{{$raw}}"></video></div>4548 {{else if hasPrefix $type "audio/"}}<div id="media"><audio controls src="{{$raw}}"></audio></div>
@@ -58,7 +61,7 @@
5861 {{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}}">5962 <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>6063 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}61-<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>64+<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>6265 </approval-item>6366 {{end}}{{else}}<p>Nothing waits for your decision.</p>6467 {{end}}{{if $done}}<h3>Answered</h3>
M internal/webui/file-workspace.js +17 -8
@@ -186,19 +186,21 @@
186186 }187187 // storeBackground hands one blob to the browser's Background Fetch with188188 // every request signed up front, then waits for the parked descriptor.189- async storeBackground(bytes, {hash, type, name, fragment}) {189+ async storeBackground(bytes, {hash, type, name, fragment, metadata}) {190190 const registration = await navigator.serviceWorker.ready;191191 const base = new URL(tiny.localPath("/"), location.href).href;192192 const authorize = (target, method, body) => tiny.authorization(target, method, body);193193 let requests;194194 const probe = await fetch(base, {method: "OPTIONS"}).catch(() => null);195195 if (probe && /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(probe.headers.get("allow") || "")) {196- const planned = await upload().plan(bytes, {url: base, hash, type, authorize});197- requests = planned.requests.map(198- item => new Request(item.url, {method: item.method, headers: item.headers, body: item.body})199- );196+ const planned = await upload().plan(bytes, {url: base, hash, type});197+ requests = await Promise.all(planned.requests.map(async item => {198+ const target = this.uploadTarget(item.url, metadata);199+ const headers = {...item.headers, authorization: await authorize(target, item.method, item.body)};200+ return new Request(target, {method: item.method, headers, body: item.body});201+ }));200202 } else {201- const target = base.replace(/\/$/, "") + "/upload";203+ const target = this.uploadTarget(base.replace(/\/$/, "") + "/upload", metadata);202204 requests = [203205 new Request(target, {204206 method: "PUT",
@@ -429,18 +431,25 @@
429431 }430432 }431433 async storePlain(files) {434+ const query = new URL(location.href).searchParams;435+ const parent = (!query.get("view") || query.get("view") === "library") ? query.get("path") || "" : "";432436 for (const [index, file] of files.entries()) {433437 checkAbort(this.controller.signal);434438 this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…");435439 const bytes = new Uint8Array(await file.arrayBuffer());436440 const type = file.type || "application/octet-stream";441+ const selectedPath = file.webkitRelativePath || file.name;442+ const metadata = {filename: file.name, path: parent ? parent + "/" + selectedPath : selectedPath};437443 if (files.length === 1 && bytes.byteLength >= BACKGROUND_BYTES && backgroundAvailable()) {438- await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name});444+ await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name, metadata});439445 continue;440446 }441- await tiny.signedFetch("/upload", "PUT", bytes, {contentType: type, signal: this.controller.signal});447+ await tiny.signedFetch(this.uploadTarget("/upload", metadata), "PUT", bytes, {contentType: type, signal: this.controller.signal});442448 }443449 }450+ uploadTarget(target, metadata) {451+ return metadata ? target + "?" + new URLSearchParams(metadata) : target;452+ }444453 // storeSealed encrypts one file with a fresh AES-GCM key. The ciphertext445454 // and key stay in memory so a retry resumes without re-encrypting.446455 async storeSealed(file) {
A internal/webui/files.go +24 -0
A internal/webui/files_test.go +65 -0
@@ -0,0 +1,65 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/policy"12+)13+14+type fileLibraryBackend struct{ fakeBackend }15+16+func (b *fileLibraryBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {17+ if method != "browsefiles" {18+ return nil, nil19+ }20+ b.call, b.params = method+":"+actor, params21+ var query map[string]any22+ if err := json.Unmarshal(params[0], &query); err != nil {23+ return nil, err24+ }25+ return map[string]any{26+ "view": query["view"], "path": query["path"], "can_manage": true, "next_cursor": "next-file",27+ "breadcrumbs": []map[string]string{{"name": "My files", "open": "/files"}, {"name": "Trip", "open": "/files?path=Trip"}},28+ "items": []map[string]any{29+ {"kind": "folder", "name": "Photos", "path": "Trip/Photos", "open": "/files?path=Trip%2FPhotos"},30+ {"kind": "file", "name": "Snow & sun.jpg", "sha256": strings.Repeat("f", 64), "type": "image/jpeg", "size": 16384, "uploaded": 1789066800},31+ },32+ }, nil33+}34+35+func TestFilesRenderNamedFoldersAndPreserveBrowseQuery(t *testing.T) {36+ backend := &fileLibraryBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}37+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})38+ if err != nil {39+ t.Fatal(err)40+ }41+ for _, target := range []string{"/files", "/files?view=sites&path=Trip&q=snow&cursor=old"} {42+ response := httptest.NewRecorder()43+ app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))44+ if response.Code != http.StatusOK {45+ t.Fatalf("%s: status %d", target, response.Code)46+ }47+ var query map[string]any48+ if err := json.Unmarshal(backend.params[0], &query); err != nil {49+ t.Fatal(err)50+ }51+ body := response.Body.String()52+ for _, want := range []string{`id="file-library"`, `aria-label="File collections"`, `Snow &amp; sun.jpg`, `Photos`, `16.0 KiB`, `loading="lazy"`, `next-file`} {53+ if !strings.Contains(body, want) {54+ t.Errorf("%s missing %q", target, want)55+ }56+ }57+ if target == "/files" {58+ if query["view"] != "library" || !strings.Contains(body, `href="/files" aria-current="page"`) {59+ t.Fatalf("default view was not library: %#v", query)60+ }61+ } else if query["view"] != "sites" || query["path"] != "Trip" || query["q"] != "snow" || query["cursor"] != "old" || !strings.Contains(body, `path=Trip`) || !strings.Contains(body, `q=snow`) {62+ t.Fatalf("browse query was lost: %#v", query)63+ }64+ }65+}
M internal/webui/public.html +1 -1
M internal/webui/style.css +19 -1
@@ -82,6 +82,17 @@ share-link{display:inline-block}share-link:empty{display:none}
8282 #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)}8383 #rows th:last-child{text-align:right}8484 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}85+#file-views{border-bottom:1px solid var(--line);margin:0 0 14px}86+#file-views a{display:inline-block;padding:10px 14px;color:var(--muted);border-bottom:2px solid transparent}87+#file-views a:first-child{padding-left:0}#file-views a[aria-current=page]{color:var(--accent);border-color:var(--accent)}88+#file-context,#file-empty{color:var(--muted)}#file-breadcrumbs{margin:16px 0;font:13px var(--mono);overflow-wrap:anywhere}89+#file-library #rows{table-layout:fixed}#file-library #rows td{height:auto;padding:12px 10px;white-space:normal;overflow-wrap:anywhere}90+#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}91+#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}92+#file-library #rows img,#file-library [data-file-icon]{display:block;width:40px;height:40px;border-radius:5px;object-fit:cover;background:var(--rail)}93+#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)}94+#file-library #rows small{display:block;margin-top:3px;color:var(--muted);font:12px var(--mono)}95+@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}}8596 #pages{font:12px var(--mono)}8697 #collaboration-filter,#file-search,#wiki-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}8798 #collaboration-body{max-width:720px}
@@ -275,7 +286,8 @@ wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margi
275286 #gridhead{font:12px var(--mono);color:var(--muted);margin:0 0 6px}276287 277288 /* Sign in. */278-#signer-qr:not([hidden]){display:block;margin:8px 0;background:#fff;padding:8px;border:1px solid var(--line)}289+#signin{max-width:720px}290+#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)}279291 280292 /* Rooms: the rail lists rooms, messages read oldest first, and the compose bar stays at the bottom of the column. */281293 #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}
@@ -365,6 +377,12 @@ html,body{overflow:auto}
365377 #tree{border-right:0;border-bottom:1px solid var(--line)}366378 #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}367379 #grid,#gridhead,h2:has(+#gridhead){display:none}380+#signin #bunker label{display:block}381+#signin #bunker-url{display:block;width:100%;min-width:0;margin-top:2px}382+#signin #bunker button{display:block;margin-top:8px}383+#signin #remember-signer{width:auto;min-width:0}384+#signin p{max-width:60ch}385+#signin #signer-qr:not([hidden]){width:min(200px,100%)}368386 room-message[data-own]{margin-left:12px;padding-right:10px}369387 /* Phones: each wiki row reads as a title line and one muted line of facts. */370388 #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "}
M internal/webui/templates.go +1 -0
M internal/webui/webui.go +7 -0
M scripts/file-workspace.test.mjs +25 -1
@@ -149,11 +149,33 @@ test("nested folder components upload and decrypt large files without exposing k
149149 const key = new URLSearchParams(new URL(url).hash.slice(1)).get("key");150150 for (const {url: target, init} of server.requests) {151151 assert.equal(target.href.includes(key), false);152+ assert.equal(target.searchParams.has("filename"), false);153+ assert.equal(target.searchParams.has("path"), false);152154 if (init.body) assert.equal(new TextDecoder().decode(init.body).includes("héllo"), false);153155 }154156 assert.ok(server.blobs.size >= 8);155157 });156158 159+test("plain uploads retain names and folder paths within the current library folder", async () => {160+ const {window, FileUpload} = load(true);161+ window.location.href = "https://relay.test/files?view=library&path=Projects";162+ const requests = [];163+ window.tiny.signedFetch = async (target, method, bytes) => requests.push({target, method, bytes});164+ const upload = new FileUpload();165+ upload.connectedCallback();166+ upload.controller = new AbortController();167+ await upload.storePlain([folderFile("image", "Trip/photos/snow & sun.jpg"), new File(["notes"], "notes.txt")]);168+ const first = new URL(requests[0].target, window.location.href);169+ assert.equal(first.pathname, "/upload");170+ assert.equal(first.searchParams.get("filename"), "snow & sun.jpg");171+ assert.equal(first.searchParams.get("path"), "Projects/Trip/photos/snow & sun.jpg");172+ assert.equal(new URL(requests[1].target, window.location.href).searchParams.get("path"), "Projects/notes.txt");173+ assert.equal(new TextDecoder().decode(requests[0].bytes), "image");174+ window.location.href = "https://relay.test/files?view=sites&path=weather";175+ await upload.storePlain([new File(["file"], "new.txt")]);176+ assert.equal(new URL(requests[2].target, window.location.href).searchParams.get("path"), "new.txt");177+});178+157179 test("directory fanout stays invisible while 175 entries remain browsable", async t => {158180 const server = workspaceServer(t);159181 const files = Array.from({length: 175}, (_, index) => folderFile("content " + index, "wide/file-" + String(index).padStart(3, "0")));
@@ -189,7 +211,7 @@ test("plain uploads store every chosen file and refresh the listing", async () =
189211 const form = upload.querySelector("form");190212 form.elements.namedItem("file").files = [new File(["one"], "a.txt", {type: "text/plain"}), new File(["two"], "b.bin")];191213 assert.equal(await upload.run(form), undefined);192- assert.deepEqual(sent, [{path: "/upload", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload", method: "PUT", size: 3, type: "application/octet-stream"}]);214+ 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"}]);193215 assert.equal(refreshed, 1);194216 assert.equal(upload.out.textContent, "Stored 2 files.");195217 });
@@ -317,6 +339,8 @@ test("large uploads hand signed requests to Background Fetch and read the parked
317339 assert.ok(started, "background fetch was not started");318340 assert.equal(started.requests.length, 2);319341 assert.equal(started.requests[0].method, "PATCH");342+ assert.equal(new URL(started.requests[0].url).searchParams.get("filename"), "big.bin");343+ assert.equal(new URL(started.requests[1].url).searchParams.get("path"), "big.bin");320344 assert.equal(started.requests[1].headers.get("upload-offset"), String(5 * 1024 * 1024));321345 assert.match(started.requests[0].headers.get("authorization"), /^Nostr PATCH:/);322346 assert.equal(started.options.uploadTotal, bytes.byteLength);