clone

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

feat/tinyagent
tinyrelay / internal / daemon / room_attachments.go
room_attachments.go 9.0K
1package daemon23import (4 "bytes"5 "context"6 "crypto/sha256"7 "database/sql"8 "encoding/hex"9 "encoding/json"10 "errors"11 "fmt"12 "io"13 "mime"14 "net/http"15 "path"16 "regexp"17 "strings"1819 "github.com/FelineStateMachine/tinyrelay/internal/blob"20 "github.com/FelineStateMachine/tinyrelay/internal/community"21 "github.com/FelineStateMachine/tinyrelay/protocol/auth"22)2324const roomAttachmentMaxBytes int64 = 32 << 202526var roomAttachmentPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/attachments$`)2728var mediaPath = regexp.MustCompile(`^/media/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$`)2930type roomAttachmentScope struct {31 RoomID, EventID string32}3334type roomAttachmentInput struct {35 Body []byte36 Type string37 Filename string38}3940// storeRoomAttachment applies the same upload policy to browser and MCP calls.41func (t *Tenant) storeRoomAttachment(ctx context.Context, actor, roomID string, input roomAttachmentInput) (map[string]any, error) {42 if err := t.roomAttachmentUploader(ctx, actor, roomID); err != nil {43 return nil, err44 }45 room, _, err := t.roomFor(ctx, actor, roomID)46 if err != nil {47 return nil, err48 }49 if len(input.Body) == 0 || int64(len(input.Body)) > roomAttachmentMaxBytes {50 return nil, errors.New("invalid: attachment must be between 1 byte and 32 MiB")51 }52 typ := input.Type53 if typ == "" {54 typ = http.DetectContentType(input.Body)55 }56 if _, _, err := mime.ParseMediaType(typ); err != nil {57 return nil, errors.New("invalid: attachment media type")58 }59 filename := roomAttachmentFilename(input.Filename)60 commit := func(commitCtx context.Context, tx *sql.Tx, entry blob.Blob, created bool) error {61 if !created {62 var scoped int63 if err := tx.QueryRowContext(commitCtx, "SELECT COUNT(*) FROM room_attachments WHERE sha256=?", entry.SHA256).Scan(&scoped); err != nil {64 return err65 }66 if scoped == 0 {67 return errors.New("conflict: this file already exists without room restrictions; share its existing link")68 }69 }70 var currentEvent string71 if err := tx.QueryRowContext(commitCtx, "SELECT event_id FROM rooms WHERE id=? AND deleted_at=0", roomID).Scan(&currentEvent); err != nil {72 return err73 }74 if currentEvent != room.EventID {75 return errors.New("conflict: the room changed while uploading; try again")76 }77 _, err := tx.ExecContext(commitCtx, `INSERT OR IGNORE INTO room_attachments(room_id,room_event_id,sha256,filename,created_at) VALUES(?,?,?,?,unixepoch())`, roomID, room.EventID, entry.SHA256, filename)78 return err79 }80 entry, err := t.blobs.Put(ctx, blob.PutOptions{Reader: bytes.NewReader(input.Body), Type: typ, Uploader: actor, Commit: commit})81 if err != nil {82 return nil, err83 }84 descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/")+"/media", entry)85 descriptor["filename"] = filename86 descriptor["room"] = roomID87 descriptor["nip94"] = append(descriptor["nip94"].([][]string), []string{"filename", filename})88 return descriptor, nil89}9091func roomAttachmentFilename(filename string) string {92 filename = strings.Map(func(r rune) rune {93 if r < 32 || r == 127 {94 return ' '95 }96 return r97 }, path.Base(filename))98 runes := []rune(strings.TrimSpace(filename))99 if len(runes) > 255 {100 runes = runes[:255]101 }102 filename = string(runes)103 if filename == "" || filename == "." || filename == "/" {104 return "file"105 }106 return filename107}108109func (t *Tenant) roomAttachmentUploader(ctx context.Context, actor, roomID string) error {110 if actor == "" {111 return errors.New("auth-required: attachment uploader")112 }113 if !t.Policy().Features.Files {114 return errors.New("restricted: file uploads are disabled")115 }116 banned, err := t.community.IsBanned(ctx, actor)117 if err != nil {118 return fmt.Errorf("check attachment uploader ban: %w", err)119 }120 if banned {121 return errors.New("blocked: this pubkey is banned from this relay")122 }123 standing, err := t.community.Role(ctx, actor)124 if err != nil {125 return fmt.Errorf("check attachment uploader role: %w", err)126 }127 allowed := t.Policy().Writes == "open" || (t.Policy().Writes == "owner" && standing == "owner") || (t.Policy().Writes != "owner" && standing != "")128 if !allowed {129 return errors.New("restricted: file upload is not allowed")130 }131 if standing == "agent" {132 if _, err := t.agentUploadGrant(ctx, actor); err != nil {133 return err134 }135 }136 if room, role, err := t.roomFor(ctx, actor, roomID); err != nil {137 return err138 } else if role == "" && !(room.Access == community.RoomOpen && standing != "") {139 return errors.New("restricted: join the room before uploading attachments")140 }141 return nil142}143144func (t *Tenant) initRoomAttachments(ctx context.Context) error {145 _, err := t.store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS room_attachments (146 room_id TEXT NOT NULL, room_event_id TEXT NOT NULL DEFAULT '',147 sha256 TEXT NOT NULL, filename TEXT NOT NULL DEFAULT '',148 created_at INTEGER NOT NULL, PRIMARY KEY(room_id, room_event_id, sha256),149 FOREIGN KEY(sha256) REFERENCES blobs(sha256) ON DELETE CASCADE150 ); CREATE INDEX IF NOT EXISTS room_attachments_hash ON room_attachments(sha256);`)151 if err != nil {152 return fmt.Errorf("initialize room attachments: %w", err)153 }154 return nil155}156157func (t *Tenant) roomAttachmentRooms(ctx context.Context, hash string) ([]roomAttachmentScope, error) {158 rows, err := t.store.DB().QueryContext(ctx, "SELECT room_id,room_event_id FROM room_attachments WHERE sha256=?", hash)159 if err != nil {160 return nil, fmt.Errorf("lookup room attachment: %w", err)161 }162 defer rows.Close()163 var rooms []roomAttachmentScope164 for rows.Next() {165 var room roomAttachmentScope166 if err := rows.Scan(&room.RoomID, &room.EventID); err != nil {167 return nil, fmt.Errorf("scan room attachment: %w", err)168 }169 rooms = append(rooms, room)170 }171 return rooms, rows.Err()172}173174func (t *Tenant) roomAttachmentAccess(ctx context.Context, actor, hash string) error {175 rooms, err := t.roomAttachmentRooms(ctx, hash)176 if err != nil {177 return err178 }179 if len(rooms) == 0 {180 return nil181 }182 for _, scope := range rooms {183 if room, _, err := t.roomFor(ctx, actor, scope.RoomID); err == nil && room.EventID == scope.EventID {184 return nil185 }186 }187 if actor == "" {188 return errors.New("auth-required: room attachment")189 }190 return errors.New("restricted: room attachment membership required")191}192193func (t *Tenant) roomAttachmentHTTP(w http.ResponseWriter, r *http.Request) bool {194 if match := roomAttachmentPath.FindStringSubmatch(r.URL.Path); match != nil {195 if r.Method == http.MethodPut {196 t.uploadRoomAttachment(w, r, match[1])197 } else {198 w.Header().Set("Allow", http.MethodPut)199 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)200 }201 return true202 }203 match := mediaPath.FindStringSubmatch(r.URL.Path)204 if match == nil {205 return false206 }207 if r.Method != http.MethodGet && r.Method != http.MethodHead {208 w.Header().Set("Allow", "GET, HEAD")209 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)210 return true211 }212 if err := t.serveRoomMedia(w, r, match[1]); err != nil {213 browseHTTPError(w, err)214 }215 return true216}217218func (t *Tenant) serveRoomMedia(w http.ResponseWriter, r *http.Request, hash string) error {219 var actor string220 var err error221 if auth.IsBlossomAuthorization(r.Header.Get("Authorization")) {222 actor, err = t.authorizeBlob(r, blob.ActionGet)223 } else {224 actor, err = t.resolveUIActor(r)225 }226 if err != nil {227 return err228 }229 if err := t.browseRead(r.Context(), actor); err != nil {230 return err231 }232 // Use the same safe content headers and range serving as /files/raw.233 w.Header().Set("Cache-Control", "private, no-store")234 w.Header().Set("Content-Security-Policy", "sandbox")235 w.Header().Set("X-Content-Type-Options", "nosniff")236 request := r.Clone(r.Context())237 query := request.URL.Query()238 query.Set("hash", hash)239 request.URL.RawQuery = query.Encode()240 return t.fileDownload(w, request, actor)241}242243func (t *Tenant) uploadRoomAttachment(w http.ResponseWriter, r *http.Request, roomID string) {244 // Authenticate and authorize before buffering file bytes. The payload245 // binding is checked after reading, without consuming the proof twice.246 token, err := t.auth.VerifyNIP98Deferred(r.Header.Get("Authorization"), t.requestURL(r), r.Method)247 if err != nil {248 browseHTTPError(w, err)249 return250 }251 if err := t.roomAttachmentUploader(r.Context(), token.PubKey, roomID); err != nil {252 browseHTTPError(w, err)253 return254 }255 r.Body = http.MaxBytesReader(w, r.Body, roomAttachmentMaxBytes+1)256 body, err := io.ReadAll(r.Body)257 if err != nil {258 http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)259 return260 }261 if int64(len(body)) > roomAttachmentMaxBytes {262 http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)263 return264 }265 hash := sha256.Sum256(body)266 if err := t.auth.ValidateBlobPayload(r.Header.Get("Authorization"), hex.EncodeToString(hash[:])); err != nil {267 browseHTTPError(w, err)268 return269 }270 result, err := t.storeRoomAttachment(r.Context(), token.PubKey, roomID, roomAttachmentInput{Body: body, Type: r.Header.Get("Content-Type"), Filename: r.URL.Query().Get("filename")})271 if err != nil {272 browseHTTPError(w, err)273 return274 }275 w.Header().Set("Content-Type", "application/json")276 _ = json.NewEncoder(w).Encode(result)277}