clone

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

feat/standalone-relay
tinyrelay / internal / daemon / client_files.go
client_files.go 17.5K
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?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.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": "/files?view=" + 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": "/files?view=" + view + "&path=" + url.QueryEscape(cur)})341 }342 return out343}344345func (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.EventCursor349 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, err353 }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 break364 }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, err371 }372 for _, rm := range rs {373 if _, _, e := t.roomFor(ctx, actor, rm.ID); e != nil {374 continue375 }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, e379 }380 for rows.Next() {381 var h, filename string382 if e := rows.Scan(&h, &filename); e != nil {383 rows.Close()384 return nil, nil, e385 }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, err391 }392 rows.Close()393 }394 return sitesH, roomsH, nil395}396397func (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 int64403 Hash string404 }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, err421 }422 defer rows.Close()423 items := []map[string]any{}424 for rows.Next() {425 var entry blob.Blob426 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {427 return nil, err428 }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, err435 }436 next := ""437 if len(items) > q.Limit {438 last := items[q.Limit-1]439 encoded, _ := json.Marshal(struct {440 Uploaded int64441 Hash string442 }{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}, nil447}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, err452 }453 rows, err := t.contextFileRows(ctx, rooms, "room")454 if err != nil {455 return nil, err456 }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, err461 }462 labels := map[string]string{}463 for _, room := range allRooms {464 labels[room.ID] = room.Name465 }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, nil481}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, err486 }487 rows, err := t.contextFileRows(ctx, sitesH, "site")488 if err != nil {489 return nil, err490 }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, nil498}499500// Read only the referenced blobs. Claim labels belong to uploaders and must501// 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, err513 }514 for rows.Next() {515 var entry blob.Blob516 if err := rows.Scan(&entry.SHA256, &entry.Size, &entry.Type, &entry.Uploader, &entry.Uploaded); err != nil {517 rows.Close()518 return nil, err519 }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, err527 }528 rows.Close()529 }530 return contextualRows(items, refs, contextName), nil531}532533func 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 continue541 }542 for _, ref := range paths {543 key := contextName + "\x00" + ref + "\x00" + hash544 if seen[key] {545 continue546 }547 seen[key] = true548 copy := make(map[string]any, len(row)+3)549 for k, v := range row {550 copy[k] = v551 }552 copy["path"] = ref553 copy["name"] = path.Base(ref)554 copy["context"] = contextName555 out = append(out, copy)556 }557 }558 return out559}