tinyrelayfile
tinyrelay/internal/daemon/client_files.go/

internal/daemon/client_files.go raw
1package daemon
2
3// Contextual file browsing. The legacy (empty View) response remains the
4// Blossom inventory used by MCP clients; the views below are for the web UI.
5import (
6 "context"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "net/url"
11 "path"
12 "sort"
13 "strings"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/blob"
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/sites"
19 "github.com/FelineStateMachine/tinyrelay/internal/storage"
20)
21
22func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
23 if q.View == "" {
24 return t.browseFilesLegacy(ctx, actor, q)
25 }
26 if !t.Policy().Features.Files {
27 return nil, errors.New("not found: files are disabled")
28 }
29 if actor == "" {
30 return nil, errors.New("auth-required: sign in to browse your files")
31 }
32 role, err := t.community.Role(ctx, actor)
33 if err != nil {
34 return nil, err
35 }
36 canManage := role == "owner" || role == "moderator"
37 var result any
38 switch q.View {
39 case "library":
40 result, err = t.browseLibrary(ctx, actor, q)
41 case "sites":
42 result, err = t.browseSiteFiles(ctx, actor, q)
43 case "rooms":
44 result, err = t.browseRoomFiles(ctx, actor, q)
45 case "storage":
46 if !canManage {
47 return nil, errors.New("restricted: storage inventory requires owner or moderator")
48 }
49 result, err = t.browseStorage(ctx, actor, q)
50 default:
51 return nil, errors.New("invalid: file view")
52 }
53 if err != nil {
54 return nil, err
55 }
56 if row, ok := result.(map[string]any); ok {
57 row["can_manage"] = canManage
58 }
59 return result, nil
60}
61
62func (t *Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
63 if actor == "" {
64 return nil, errors.New("auth-required: sign in to browse your files")
65 }
66 role, err := t.community.Role(ctx, actor)
67 if err != nil {
68 return nil, err
69 }
70 var entries []blob.Blob
71 var next string
72 if role == "owner" || role == "moderator" {
73 entries, next, err = t.browseAllFiles(ctx, q)
74 } else if q.Query != "" {
75 cursor := q.Cursor
76 for len(entries) < q.Limit {
77 var page []blob.Blob
78 page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)
79 if err != nil {
80 break
81 }
82 for _, e := range page {
83 if blobMatchesQuery(e, q.Query) {
84 entries = append(entries, e)
85 }
86 }
87 if next == "" {
88 break
89 }
90 cursor = next
91 }
92 } else {
93 entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)
94 }
95 if err != nil {
96 return nil, err
97 }
98 items := []map[string]any{}
99 for _, e := range entries {
100 if q.Query == "" || blobMatchesQuery(e, q.Query) {
101 items = append(items, t.browseBlobMetadata(e))
102 }
103 }
104 return map[string]any{"items": items, "next_cursor": next}, nil
105}
106
107func (t *Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {
108 query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"
109 args := []any{q.Cursor}
110 needle := strings.ToLower(strings.TrimSpace(q.Query))
111 if needle != "" {
112 query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"
113 args = append(args, needle, needle, needle)
114 }
115 query += " ORDER BY sha256 LIMIT ?"
116 args = append(args, q.Limit+1)
117 rows, err := t.store.DB().QueryContext(ctx, query, args...)
118 if err != nil {
119 return nil, "", err
120 }
121 defer rows.Close()
122 out := []blob.Blob{}
123 for rows.Next() {
124 var e blob.Blob
125 if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded); err != nil {
126 return nil, "", err
127 }
128 out = append(out, e)
129 }
130 if err := rows.Err(); err != nil {
131 return nil, "", err
132 }
133 next := ""
134 if len(out) > q.Limit {
135 next = out[q.Limit-1].SHA256
136 out = out[:q.Limit]
137 }
138 return out, next, nil
139}
140func blobMatchesQuery(e blob.Blob, q string) bool {
141 n := strings.ToLower(strings.TrimSpace(q))
142 return n == "" || strings.Contains(strings.ToLower(e.SHA256), n) || strings.Contains(strings.ToLower(e.Type), n) || strings.Contains(strings.ToLower(e.Uploader), n)
143}
144func (t *Tenant) browseBlobMetadata(e blob.Blob) map[string]any {
145 portable := blob.BlossomURI{Hash: e.SHA256, Extension: "bin", Author: e.Uploader, Size: e.Size}
146 if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {
147 portable.Servers = []string{origin.Scheme + "://" + origin.Host}
148 }
149 return map[string]any{"sha256": e.SHA256, "size": e.Size, "type": e.Type, "uploader": e.Uploader, "uploaded": e.Uploaded, "url": strings.TrimRight(t.publicURL, "/") + "/" + e.SHA256, "download_url": strings.TrimRight(t.publicURL, "/") + "/files/raw?hash=" + url.QueryEscape(e.SHA256), "blossom_uri": portable.String()}
150}
151
152func (t *Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[string]any, error) {
153 q := `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.purpose,''),COALESCE(m.logical_size,0),COALESCE(m.logical_type,''),b.access FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 LEFT JOIN blob_claim_metadata m ON m.sha256=b.sha256 AND m.uploader=c.uploader`
154 args := []any{}
155 if !all {
156 includeMembers := false
157 if role, roleErr := t.community.Role(ctx, actor); roleErr == nil && t.memberBlobReader(ctx, actor, role) {
158 includeMembers = true
159 }
160 if includeMembers {
161 q += " WHERE c.uploader=? OR b.access=?"
162 args = append(args, actor, blob.AccessMembers)
163 } else {
164 q += " WHERE c.uploader=?"
165 args = append(args, actor)
166 }
167 }
168 q += " ORDER BY b.uploaded DESC,b.sha256 DESC"
169 rows, err := t.store.DB().QueryContext(ctx, q, args...)
170 if err != nil {
171 return nil, err
172 }
173 defer rows.Close()
174 out := []map[string]any{}
175 for rows.Next() {
176 var e blob.Blob
177 var name, p, purpose, logicalType, access string
178 var logicalSize int64
179 if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded, &name, &p, &purpose, &logicalSize, &logicalType, &access); err != nil {
180 return nil, err
181 }
182 m := t.browseBlobMetadata(e)
183 m["access"] = access
184 m["name"] = name
185 m["path"] = p
186 m["purpose"] = purpose
187 if purpose == "file" && logicalType != "" {
188 m["logical_size"] = logicalSize
189 }
190 if logicalType != "" {
191 m["logical_type"] = logicalType
192 }
193 out = append(out, m)
194 }
195 return out, rows.Err()
196}
197
198func (t *Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
199 rows, err := t.claimRows(ctx, actor, false)
200 if err != nil {
201 return nil, err
202 }
203 excluded := map[string]bool{}
204 paths, rooms, err := t.contextHashes(ctx, actor)
205 if err != nil {
206 return nil, err
207 }
208 for h := range paths {
209 excluded[h] = true
210 }
211 for h := range rooms {
212 excluded[h] = true
213 }
214 rows = libraryRows(rows, excluded)
215 return t.folderItems(rows, excluded, q, "library", false), nil
216}
217
218// libraryRows keeps implementation objects out of the user's file library.
219// New encrypted uploads mark their chunk claims with purpose=chunk. Older
220// unnamed octet streams cannot be identified safely from size alone, so they
221// remain visible in one unorganized folder instead of being deleted or
222// silently hidden.
223func libraryRows(rows []map[string]any, excluded map[string]bool) []map[string]any {
224 out := make([]map[string]any, 0, len(rows))
225 for _, row := range rows {
226 if row["purpose"] == "chunk" {
227 continue
228 }
229 if row["purpose"] == "file" {
230 if size, ok := row["logical_size"]; ok {
231 row["size"] = size
232 }
233 }
234 if excluded[row["sha256"].(string)] && row["name"] == "" {
235 continue
236 }
237 if row["name"] == "" && row["path"] == "" && row["type"] == "application/octet-stream" {
238 hash := row["sha256"].(string)
239 short := hash
240 if len(short) > 12 {
241 short = short[:12]
242 }
243 row["path"] = "Unorganized uploads/" + short
244 row["name"] = short
245 }
246 out = append(out, row)
247 }
248 return out
249}
250func (t *Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q clientBrowseRequest, view string, includeExcluded bool) map[string]any {
251 prefix := strings.Trim(q.Path, "/")
252 if prefix != "" {
253 prefix += "/"
254 }
255 folders := map[string]bool{}
256 files := []map[string]any{}
257 needle := strings.ToLower(strings.TrimSpace(q.Query))
258 for _, m := range rows {
259 if !includeExcluded && excluded[m["sha256"].(string)] && m["name"] == "" {
260 continue
261 }
262 p := m["path"].(string)
263 name := m["name"].(string)
264 if p == "" {
265 if name != "" {
266 p = name
267 } else {
268 short := m["sha256"].(string)
269 if len(short) > 12 {
270 short = short[:12]
271 }
272 p = "(" + strings.ReplaceAll(m["type"].(string), "/", "-") + ") " + short
273 }
274 }
275 if needle != "" && !strings.Contains(strings.ToLower(p+" "+m["type"].(string)+" "+m["sha256"].(string)), needle) {
276 continue
277 }
278 if !strings.HasPrefix(p, prefix) {
279 continue
280 }
281 rest := strings.TrimPrefix(p, prefix)
282 if rest == "" {
283 continue
284 }
285 if i := strings.IndexByte(rest, '/'); i >= 0 {
286 folders[rest[:i]] = true
287 continue
288 }
289 if p != "" {
290 m["path"] = p
291 m["name"] = path.Base(p)
292 }
293 m["kind"] = "file"
294 m["open"] = "/file?hash=" + url.QueryEscape(m["sha256"].(string))
295 if contextName, ok := m["context"].(string); ok {
296 m["context_url"] = "/files?view=" + contextName + "s&path=" + url.QueryEscape(path.Dir(p))
297 }
298 files = append(files, m)
299 }
300 items := []map[string]any{}
301 for n := range folders {
302 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, "/"))})
303 }
304 items = append(items, files...)
305 sort.Slice(items, func(i, j int) bool { return fileItemKey(items[i]) < fileItemKey(items[j]) })
306 if q.Cursor != "" {
307 cursor := q.Cursor
308 if decoded, err := base64.RawURLEncoding.DecodeString(cursor); err == nil {
309 cursor = string(decoded)
310 }
311 position := sort.Search(len(items), func(i int) bool { return fileItemKey(items[i]) > cursor })
312 items = items[position:]
313 }
314 next := ""
315 if len(items) > q.Limit {
316 next = base64.RawURLEncoding.EncodeToString([]byte(fileItemKey(items[q.Limit-1])))
317 items = items[:q.Limit]
318 }
319 return map[string]any{"items": items, "next_cursor": next, "breadcrumbs": breadcrumbs(view, prefix), "view": view, "path": strings.Trim(prefix, "/"), "can_manage": false}
320}
321func fileItemKey(item map[string]any) string {
322 if item["kind"] == "folder" {
323 return "0\x00" + item["path"].(string)
324 }
325 return "1\x00" + item["path"].(string) + "\x00" + item["sha256"].(string)
326}
327func breadcrumbs(view, p string) []map[string]string {
328 label := map[string]string{"library": "My files", "sites": "Sites", "rooms": "Rooms", "storage": "Storage"}[view]
329 out := []map[string]string{{"name": label, "open": "/files?view=" + view}}
330 parts := strings.Split(strings.Trim(p, "/"), "/")
331 cur := ""
332 for _, v := range parts {
333 if v == "" {
334 continue
335 }
336 if cur != "" {
337 cur += "/"
338 }
339 cur += v
340 out = append(out, map[string]string{"name": v, "open": "/files?view=" + view + "&path=" + url.QueryEscape(cur)})
341 }
342 return out
343}
344
345func (t *Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {
346 sitesH := map[string][]string{}
347 roomsH := map[string][]string{}
348 var before *storage.EventCursor
349 for {
350 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})
351 if err != nil {
352 return nil, nil, err
353 }
354 for _, e := range r.Events {
355 label := sites.SiteLabel(e)
356 for _, p := range sites.SitePaths(e) {
357 if len(p) == 3 {
358 sitesH[p[2]] = append(sitesH[p[2]], label+"/"+strings.TrimPrefix(p[1], "/"))
359 }
360 }
361 }
362 if !r.More || len(r.Events) == 0 {
363 break
364 }
365 last := r.Events[len(r.Events)-1]
366 before = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}
367 }
368 rs, err := t.community.Rooms(ctx)
369 if err != nil {
370 return nil, nil, err
371 }
372 for _, rm := range rs {
373 if _, _, e := t.roomFor(ctx, actor, rm.ID); e != nil {
374 continue
375 }
376 rows, e := t.store.DB().QueryContext(ctx, "SELECT sha256,filename FROM room_attachments WHERE room_id=? AND room_event_id=?", rm.ID, rm.EventID)
377 if e != nil {
378 return nil, nil, e
379 }
380 for rows.Next() {
381 var h, filename string
382 if e := rows.Scan(&h, &filename); e != nil {
383 rows.Close()
384 return nil, nil, e
385 }
386 roomsH[h] = append(roomsH[h], rm.ID+"/"+roomAttachmentFilename(filename))
387 }
388 if err := rows.Err(); err != nil {
389 rows.Close()
390 return nil, nil, err
391 }
392 rows.Close()
393 }
394 return sitesH, roomsH, nil
395}
396
397func (t *Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
398 query := `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE 1=1`
399 args := []any{}
400 if q.Cursor != "" {
401 var cursor struct {
402 Uploaded int64
403 Hash string
404 }
405 decoded, err := base64.RawURLEncoding.DecodeString(q.Cursor)
406 if err != nil || json.Unmarshal(decoded, &cursor) != nil || len(cursor.Hash) != 64 {
407 return nil, errors.New("invalid: storage cursor")
408 }
409 query += " AND (uploaded<? OR (uploaded=? AND sha256<?))"
410 args = append(args, cursor.Uploaded, cursor.Uploaded, cursor.Hash)
411 }
412 if needle := strings.ToLower(strings.TrimSpace(q.Query)); needle != "" {
413 query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"
414 args = append(args, needle, needle, needle)
415 }
416 query += " ORDER BY uploaded DESC,sha256 DESC LIMIT ?"
417 args = append(args, q.Limit+1)
418 rows, err := t.store.DB().QueryContext(ctx, query, args...)
419 if err != nil {
420 return nil, err
421 }
422 defer rows.Close()
423 items := []map[string]any{}
424 for rows.Next() {
425 var entry blob.Blob
426 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {
427 return nil, err
428 }
429 item := t.browseBlobMetadata(entry)
430 item["kind"], item["name"], item["path"] = "file", entry.SHA256[:12], ""
431 items = append(items, item)
432 }
433 if err := rows.Err(); err != nil {
434 return nil, err
435 }
436 next := ""
437 if len(items) > q.Limit {
438 last := items[q.Limit-1]
439 encoded, _ := json.Marshal(struct {
440 Uploaded int64
441 Hash string
442 }{last["uploaded"].(int64), last["sha256"].(string)})
443 next = base64.RawURLEncoding.EncodeToString(encoded)
444 items = items[:q.Limit]
445 }
446 return map[string]any{"items": items, "view": "storage", "path": "", "breadcrumbs": breadcrumbs("storage", ""), "next_cursor": next}, nil
447}
448func (t *Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
449 _, rooms, err := t.contextHashes(ctx, actor)
450 if err != nil {
451 return nil, err
452 }
453 rows, err := t.contextFileRows(ctx, rooms, "room")
454 if err != nil {
455 return nil, err
456 }
457 result := t.folderItems(rows, map[string]bool{}, q, "rooms", true)
458 allRooms, err := t.community.Rooms(ctx)
459 if err != nil {
460 return nil, err
461 }
462 labels := map[string]string{}
463 for _, room := range allRooms {
464 labels[room.ID] = room.Name
465 }
466 for _, item := range result["items"].([]map[string]any) {
467 root := strings.SplitN(item["path"].(string), "/", 2)[0]
468 if q.Path == "" {
469 item["kind"] = "room"
470 if labels[root] != "" {
471 item["name"] = labels[root]
472 }
473 }
474 item["context"], item["context_url"] = labels[root], "/rooms/"+url.PathEscape(root)
475 }
476 crumbs := result["breadcrumbs"].([]map[string]string)
477 if len(crumbs) > 1 && labels[crumbs[1]["name"]] != "" {
478 crumbs[1]["name"] = labels[crumbs[1]["name"]]
479 }
480 return result, nil
481}
482func (t *Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {
483 sitesH, _, err := t.contextHashes(ctx, actor)
484 if err != nil {
485 return nil, err
486 }
487 rows, err := t.contextFileRows(ctx, sitesH, "site")
488 if err != nil {
489 return nil, err
490 }
491 result := t.folderItems(rows, map[string]bool{}, q, "sites", true)
492 if q.Path == "" {
493 for _, item := range result["items"].([]map[string]any) {
494 item["kind"] = "site"
495 }
496 }
497 return result, nil
498}
499
500// Read only the referenced blobs. Claim labels belong to uploaders and must
501// not become names for files viewed through someone else's site or room.
502func (t *Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {
503 hashes := make([]string, 0, len(refs))
504 for hash := range refs {
505 hashes = append(hashes, hash)
506 }
507 items := []map[string]any{}
508 for start := 0; start < len(hashes); start += 250 {
509 encoded, _ := json.Marshal(hashes[start:min(start+250, len(hashes))])
510 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))
511 if err != nil {
512 return nil, err
513 }
514 for rows.Next() {
515 var entry blob.Blob
516 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {
517 rows.Close()
518 return nil, err
519 }
520 item := t.browseBlobMetadata(entry)
521 delete(item, "uploader")
522 items = append(items, item)
523 }
524 if err := rows.Err(); err != nil {
525 rows.Close()
526 return nil, err
527 }
528 rows.Close()
529 }
530 return contextualRows(items, refs, contextName), nil
531}
532
533func contextualRows(rows []map[string]any, refs map[string][]string, contextName string) []map[string]any {
534 out := make([]map[string]any, 0, len(refs))
535 seen := map[string]bool{}
536 for _, row := range rows {
537 hash, _ := row["sha256"].(string)
538 paths, ok := refs[hash]
539 if !ok {
540 continue
541 }
542 for _, ref := range paths {
543 key := contextName + "\x00" + ref + "\x00" + hash
544 if seen[key] {
545 continue
546 }
547 seen[key] = true
548 copy := make(map[string]any, len(row)+3)
549 for k, v := range row {
550 copy[k] = v
551 }
552 copy["path"] = ref
553 copy["name"] = path.Base(ref)
554 copy["context"] = contextName
555 out = append(out, copy)
556 }
557 }
558 return out
559}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs