| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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" |
| 18 | |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 20 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 21 | "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 22 | ) |
| 23 | |
| 24 | const roomAttachmentMaxBytes int64 = 32 << 20 |
| 25 | |
| 26 | var roomAttachmentPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/attachments$` ) |
| 27 | |
| 28 | var mediaPath = regexp.MustCompile(`^/media/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$` ) |
| 29 | |
| 30 | type roomAttachmentScope struct { |
| 31 | RoomID, EventID string |
| 32 | } |
| 33 | |
| 34 | type roomAttachmentInput struct { |
| 35 | Body []byte |
| 36 | Type string |
| 37 | Filename string |
| 38 | } |
| 39 | |
| 40 | // storeRoomAttachment applies the same upload policy to browser and MCP calls. |
| 41 | func (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, err |
| 44 | } |
| 45 | room, _, err := t.roomFor(ctx, actor, roomID) |
| 46 | if err != nil { |
| 47 | return nil, err |
| 48 | } |
| 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.Type |
| 53 | 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 int |
| 63 | if err := tx.QueryRowContext(commitCtx, "SELECT COUNT(*) FROM room_attachments WHERE sha256=?" , entry.SHA256).Scan(&scoped); err != nil { |
| 64 | return err |
| 65 | } |
| 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 string |
| 71 | if err := tx.QueryRowContext(commitCtx, "SELECT event_id FROM rooms WHERE id=? AND deleted_at=0" , roomID).Scan(¤tEvent); err != nil { |
| 72 | return err |
| 73 | } |
| 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 err |
| 79 | } |
| 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, err |
| 83 | } |
| 84 | descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/" )+"/media" , entry) |
| 85 | descriptor["filename" ] = filename |
| 86 | descriptor["room" ] = roomID |
| 87 | descriptor["nip94" ] = append(descriptor["nip94" ].([][]string), []string{"filename" , filename}) |
| 88 | return descriptor, nil |
| 89 | } |
| 90 | |
| 91 | func roomAttachmentFilename(filename string) string { |
| 92 | filename = strings.Map(func(r rune) rune { |
| 93 | if r < 32 || r == 127 { |
| 94 | return ' ' |
| 95 | } |
| 96 | return r |
| 97 | }, 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 filename |
| 107 | } |
| 108 | |
| 109 | func (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 err |
| 134 | } |
| 135 | } |
| 136 | if room, role, err := t.roomFor(ctx, actor, roomID); err != nil { |
| 137 | return err |
| 138 | } else if role == "" && !(room.Access == community.RoomOpen && standing != "" ) { |
| 139 | return errors.New("restricted: join the room before uploading attachments" ) |
| 140 | } |
| 141 | return nil |
| 142 | } |
| 143 | |
| 144 | func (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 CASCADE |
| 150 | ); 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 nil |
| 155 | } |
| 156 | |
| 157 | func (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 []roomAttachmentScope |
| 164 | for rows.Next() { |
| 165 | var room roomAttachmentScope |
| 166 | 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 | } |
| 173 | |
| 174 | func (t *Tenant) roomAttachmentAccess(ctx context.Context, actor, hash string) error { |
| 175 | rooms, err := t.roomAttachmentRooms(ctx, hash) |
| 176 | if err != nil { |
| 177 | return err |
| 178 | } |
| 179 | if len(rooms) == 0 { |
| 180 | return nil |
| 181 | } |
| 182 | for _, scope := range rooms { |
| 183 | if room, _, err := t.roomFor(ctx, actor, scope.RoomID); err == nil && room.EventID == scope.EventID { |
| 184 | return nil |
| 185 | } |
| 186 | } |
| 187 | if actor == "" { |
| 188 | return errors.New("auth-required: room attachment" ) |
| 189 | } |
| 190 | return errors.New("restricted: room attachment membership required" ) |
| 191 | } |
| 192 | |
| 193 | func (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 true |
| 202 | } |
| 203 | match := mediaPath.FindStringSubmatch(r.URL.Path) |
| 204 | if match == nil { |
| 205 | return false |
| 206 | } |
| 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 true |
| 211 | } |
| 212 | if err := t.serveRoomMedia(w, r, match[1]); err != nil { |
| 213 | browseHTTPError(w, err) |
| 214 | } |
| 215 | return true |
| 216 | } |
| 217 | |
| 218 | func (t *Tenant) serveRoomMedia(w http.ResponseWriter, r *http.Request, hash string) error { |
| 219 | var actor string |
| 220 | var err error |
| 221 | 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 err |
| 228 | } |
| 229 | if err := t.browseRead(r.Context(), actor); err != nil { |
| 230 | return err |
| 231 | } |
| 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 | } |
| 242 | |
| 243 | func (t *Tenant) uploadRoomAttachment(w http.ResponseWriter, r *http.Request, roomID string) { |
| 244 | // Authenticate and authorize before buffering file bytes. The payload |
| 245 | // binding is checked after reading, without consuming the proof twice. |
| 246 | token, err := t.auth.VerifyNIP98(r.Header.Get("Authorization" ), t.requestURL(r), r.Method, "" ) |
| 247 | if err != nil { |
| 248 | browseHTTPError(w, err) |
| 249 | return |
| 250 | } |
| 251 | if err := t.roomAttachmentUploader(r.Context(), token.PubKey, roomID); err != nil { |
| 252 | browseHTTPError(w, err) |
| 253 | return |
| 254 | } |
| 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 | return |
| 260 | } |
| 261 | if int64(len(body)) > roomAttachmentMaxBytes { |
| 262 | http.Error(w, "attachment exceeds 32 MiB" , http.StatusRequestEntityTooLarge) |
| 263 | return |
| 264 | } |
| 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 | return |
| 269 | } |
| 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 | return |
| 274 | } |
| 275 | w.Header().Set("Content-Type" , "application/json" ) |
| 276 | _ = json.NewEncoder(w).Encode(result) |
| 277 | } |