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