clone

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

main
tinyrelay / internal / daemon / client_files.go
client_files.go 17.7K
1package daemon23// Contextual file browsing. The legacy (empty View) response remains the4// 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"1516 "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)2122func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {23 if q.View == "" {24 return t.browseFilesLegacy(ctx, actor, q)25 }26 if !t.Policy().Features.Files {27 return nil, errors.New("not found: files are disabled")28 }29 if actor == "" {30 return nil, errors.New("auth-required: sign in to browse your files")31 }32 role, err := t.community.Role(ctx, actor)33 if err != nil {34 return nil, err35 }36 canManage := role == "owner" || role == "moderator"37 var result any38 switch q.View {39 case "library":40 result, err = t.browseLibrary(ctx, actor, q)41 case "sites":42 result, err = t.browseSiteFiles(ctx, actor, q)43 case "rooms":44 result, err = t.browseRoomFiles(ctx, actor, q)45 case "storage":46 if !canManage {47 return nil, errors.New("restricted: storage inventory requires owner or moderator")48 }49 result, err = t.browseStorage(ctx, actor, q)50 default:51 return nil, errors.New("invalid: file view")52 }53 if err != nil {54 return nil, err55 }56 if row, ok := result.(map[string]any); ok {57 row["can_manage"] = canManage58 }59 return result, nil60}6162func (t *Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {63 if actor == "" {64 return nil, errors.New("auth-required: sign in to browse your files")65 }66 role, err := t.community.Role(ctx, actor)67 if err != nil {68 return nil, err69 }70 var entries []blob.Blob71 var next string72 if role == "owner" || role == "moderator" {73 entries, next, err = t.browseAllFiles(ctx, q)74 } else if q.Query != "" {75 cursor := q.Cursor76 for len(entries) < q.Limit {77 var page []blob.Blob78 page, next, err = t.blobs.ListPage(ctx, actor, q.Limit-len(entries), cursor)79 if err != nil {80 break81 }82 for _, e := range page {83 if blobMatchesQuery(e, q.Query) {84 entries = append(entries, e)85 }86 }87 if next == "" {88 break89 }90 cursor = next91 }92 } else {93 entries, next, err = t.blobs.ListPage(ctx, actor, q.Limit, q.Cursor)94 }95 if err != nil {96 return nil, err97 }98 items := []map[string]any{}99 for _, e := range entries {100 if q.Query == "" || blobMatchesQuery(e, q.Query) {101 items = append(items, t.browseBlobMetadata(e))102 }103 }104 return map[string]any{"items": items, "next_cursor": next}, nil105}106107func (t *Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {108 query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"109 args := []any{q.Cursor}110 needle := strings.ToLower(strings.TrimSpace(q.Query))111 if needle != "" {112 query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"113 args = append(args, needle, needle, needle)114 }115 query += " ORDER BY sha256 LIMIT ?"116 args = append(args, q.Limit+1)117 rows, err := t.store.DB().QueryContext(ctx, query, args...)118 if err != nil {119 return nil, "", err120 }121 defer rows.Close()122 out := []blob.Blob{}123 for rows.Next() {124 var e blob.Blob125 if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded); err != nil {126 return nil, "", err127 }128 out = append(out, e)129 }130 if err := rows.Err(); err != nil {131 return nil, "", err132 }133 next := ""134 if len(out) > q.Limit {135 next = out[q.Limit-1].SHA256136 out = out[:q.Limit]137 }138 return out, next, nil139}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}151152func (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 := false157 if role, roleErr := t.community.Role(ctx, actor); roleErr == nil && t.memberBlobReader(ctx, actor, role) {158 includeMembers = true159 }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, err172 }173 defer rows.Close()174 out := []map[string]any{}175 for rows.Next() {176 var e blob.Blob177 var name, p, purpose, logicalType, access string178 var logicalSize int64179 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, err181 }182 m := t.browseBlobMetadata(e)183 m["access"] = access184 m["name"] = name185 m["path"] = p186 m["purpose"] = purpose187 if purpose == "file" && logicalType != "" {188 m["logical_size"] = logicalSize189 }190 if logicalType != "" {191 m["logical_type"] = logicalType192 }193 out = append(out, m)194 }195 return out, rows.Err()196}197198func (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, err202 }203 excluded := map[string]bool{}204 paths, rooms, err := t.contextHashes(ctx, actor)205 if err != nil {206 return nil, err207 }208 for h := range paths {209 excluded[h] = true210 }211 for h := range rooms {212 excluded[h] = true213 }214 rows = libraryRows(rows, excluded)215 return t.folderItems(rows, excluded, q, "library", false), nil216}217218// libraryRows keeps implementation objects out of the user's file library.219// New encrypted uploads mark their chunk claims with purpose=chunk. Older220// unnamed octet streams cannot be identified safely from size alone, so they221// remain visible in one unorganized folder instead of being deleted or222// 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 continue228 }229 if row["purpose"] == "file" {230 if size, ok := row["logical_size"]; ok {231 row["size"] = size232 }233 }234 if excluded[row["sha256"].(string)] && row["name"] == "" {235 continue236 }237 if row["name"] == "" && row["path"] == "" && row["type"] == "application/octet-stream" {238 hash := row["sha256"].(string)239 short := hash240 if len(short) > 12 {241 short = short[:12]242 }243 row["path"] = "Unorganized uploads/" + short244 row["name"] = short245 }246 out = append(out, row)247 }248 return out249}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 continue261 }262 p := m["path"].(string)263 name := m["name"].(string)264 if p == "" {265 if name != "" {266 p = name267 } else {268 short := m["sha256"].(string)269 if len(short) > 12 {270 short = short[:12]271 }272 p = "(" + strings.ReplaceAll(m["type"].(string), "/", "-") + ") " + short273 }274 }275 if needle != "" && !strings.Contains(strings.ToLower(p+" "+m["type"].(string)+" "+m["sha256"].(string)), needle) {276 continue277 }278 if !strings.HasPrefix(p, prefix) {279 continue280 }281 rest := strings.TrimPrefix(p, prefix)282 if rest == "" {283 continue284 }285 if i := strings.IndexByte(rest, '/'); i >= 0 {286 folders[rest[:i]] = true287 continue288 }289 if p != "" {290 m["path"] = p291 m["name"] = path.Base(p)292 }293 m["kind"] = "file"294 m["open"] = "/file/" + url.PathEscape(m["sha256"].(string))295 if contextName, ok := m["context"].(string); ok {296 m["context_url"] = filesPageURL(contextName+"s", 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": filesPageURL(view, 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.Cursor308 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": filesPageURL(view, "")}}330 parts := strings.Split(strings.Trim(p, "/"), "/")331 cur := ""332 for _, v := range parts {333 if v == "" {334 continue335 }336 if cur != "" {337 cur += "/"338 }339 cur += v340 out = append(out, map[string]string{"name": v, "open": filesPageURL(view, cur)})341 }342 return out343}344345// filesPageURL is the files page for a view and folder. The library is the346// bare /files page; the other views are /files/{view}.347func filesPageURL(view, folder string) string {348 page := "/files"349 if view != "library" {350 page += "/" + url.PathEscape(view)351 }352 if folder == "" || folder == "." {353 return page354 }355 return page + "?path=" + url.QueryEscape(folder)356}357358func (t *Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {359 sitesH := map[string][]string{}360 roomsH := map[string][]string{}361 var before *storage.EventCursor362 for {363 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})364 if err != nil {365 return nil, nil, err366 }367 for _, e := range r.Events {368 label := sites.SiteLabel(e)369 for _, p := range sites.SitePaths(e) {370 if len(p) == 3 {371 sitesH[p[2]] = append(sitesH[p[2]], label+"/"+strings.TrimPrefix(p[1], "/"))372 }373 }374 }375 if !r.More || len(r.Events) == 0 {376 break377 }378 last := r.Events[len(r.Events)-1]379 before = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}380 }381 rs, err := t.community.Rooms(ctx)382 if err != nil {383 return nil, nil, err384 }385 for _, rm := range rs {386 if _, _, e := t.roomFor(ctx, actor, rm.ID); e != nil {387 continue388 }389 rows, e := t.store.DB().QueryContext(ctx, "SELECT sha256,filename FROM room_attachments WHERE room_id=? AND room_event_id=?", rm.ID, rm.EventID)390 if e != nil {391 return nil, nil, e392 }393 for rows.Next() {394 var h, filename string395 if e := rows.Scan(&h, &filename); e != nil {396 rows.Close()397 return nil, nil, e398 }399 roomsH[h] = append(roomsH[h], rm.ID+"/"+roomAttachmentFilename(filename))400 }401 if err := rows.Err(); err != nil {402 rows.Close()403 return nil, nil, err404 }405 rows.Close()406 }407 return sitesH, roomsH, nil408}409410func (t *Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {411 query := `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE 1=1`412 args := []any{}413 if q.Cursor != "" {414 var cursor struct {415 Uploaded int64416 Hash string417 }418 decoded, err := base64.RawURLEncoding.DecodeString(q.Cursor)419 if err != nil || json.Unmarshal(decoded, &cursor) != nil || len(cursor.Hash) != 64 {420 return nil, errors.New("invalid: storage cursor")421 }422 query += " AND (uploaded<? OR (uploaded=? AND sha256<?))"423 args = append(args, cursor.Uploaded, cursor.Uploaded, cursor.Hash)424 }425 if needle := strings.ToLower(strings.TrimSpace(q.Query)); needle != "" {426 query += " AND (instr(lower(sha256),?)>0 OR instr(lower(type),?)>0 OR instr(lower(uploader),?)>0)"427 args = append(args, needle, needle, needle)428 }429 query += " ORDER BY uploaded DESC,sha256 DESC LIMIT ?"430 args = append(args, q.Limit+1)431 rows, err := t.store.DB().QueryContext(ctx, query, args...)432 if err != nil {433 return nil, err434 }435 defer rows.Close()436 items := []map[string]any{}437 for rows.Next() {438 var entry blob.Blob439 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {440 return nil, err441 }442 item := t.browseBlobMetadata(entry)443 item["kind"], item["name"], item["path"] = "file", entry.SHA256[:12], ""444 items = append(items, item)445 }446 if err := rows.Err(); err != nil {447 return nil, err448 }449 next := ""450 if len(items) > q.Limit {451 last := items[q.Limit-1]452 encoded, _ := json.Marshal(struct {453 Uploaded int64454 Hash string455 }{last["uploaded"].(int64), last["sha256"].(string)})456 next = base64.RawURLEncoding.EncodeToString(encoded)457 items = items[:q.Limit]458 }459 return map[string]any{"items": items, "view": "storage", "path": "", "breadcrumbs": breadcrumbs("storage", ""), "next_cursor": next}, nil460}461func (t *Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {462 _, rooms, err := t.contextHashes(ctx, actor)463 if err != nil {464 return nil, err465 }466 rows, err := t.contextFileRows(ctx, rooms, "room")467 if err != nil {468 return nil, err469 }470 result := t.folderItems(rows, map[string]bool{}, q, "rooms", true)471 allRooms, err := t.community.Rooms(ctx)472 if err != nil {473 return nil, err474 }475 labels := map[string]string{}476 for _, room := range allRooms {477 labels[room.ID] = room.Name478 }479 for _, item := range result["items"].([]map[string]any) {480 root := strings.SplitN(item["path"].(string), "/", 2)[0]481 if q.Path == "" {482 item["kind"] = "room"483 if labels[root] != "" {484 item["name"] = labels[root]485 }486 }487 item["context"], item["context_url"] = labels[root], "/rooms/"+url.PathEscape(root)488 }489 crumbs := result["breadcrumbs"].([]map[string]string)490 if len(crumbs) > 1 && labels[crumbs[1]["name"]] != "" {491 crumbs[1]["name"] = labels[crumbs[1]["name"]]492 }493 return result, nil494}495func (t *Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {496 sitesH, _, err := t.contextHashes(ctx, actor)497 if err != nil {498 return nil, err499 }500 rows, err := t.contextFileRows(ctx, sitesH, "site")501 if err != nil {502 return nil, err503 }504 result := t.folderItems(rows, map[string]bool{}, q, "sites", true)505 if q.Path == "" {506 for _, item := range result["items"].([]map[string]any) {507 item["kind"] = "site"508 }509 }510 return result, nil511}512513// Read only the referenced blobs. Claim labels belong to uploaders and must514// not become names for files viewed through someone else's site or room.515func (t *Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {516 hashes := make([]string, 0, len(refs))517 for hash := range refs {518 hashes = append(hashes, hash)519 }520 items := []map[string]any{}521 for start := 0; start < len(hashes); start += 250 {522 encoded, _ := json.Marshal(hashes[start:min(start+250, len(hashes))])523 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))524 if err != nil {525 return nil, err526 }527 for rows.Next() {528 var entry blob.Blob529 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {530 rows.Close()531 return nil, err532 }533 item := t.browseBlobMetadata(entry)534 delete(item, "uploader")535 items = append(items, item)536 }537 if err := rows.Err(); err != nil {538 rows.Close()539 return nil, err540 }541 rows.Close()542 }543 return contextualRows(items, refs, contextName), nil544}545546func contextualRows(rows []map[string]any, refs map[string][]string, contextName string) []map[string]any {547 out := make([]map[string]any, 0, len(refs))548 seen := map[string]bool{}549 for _, row := range rows {550 hash, _ := row["sha256"].(string)551 paths, ok := refs[hash]552 if !ok {553 continue554 }555 for _, ref := range paths {556 key := contextName + "\x00" + ref + "\x00" + hash557 if seen[key] {558 continue559 }560 seen[key] = true561 copy := make(map[string]any, len(row)+3)562 for k, v := range row {563 copy[k] = v564 }565 copy["path"] = ref566 copy["name"] = path.Base(ref)567 copy["context"] = contextName568 out = append(out, copy)569 }570 }571 return out572}