tinyrelayfile
tinyrelay/internal/daemon/room_attachments.go/

internal/daemon/room_attachments.go raw
1package daemon
2
3import (
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
24const roomAttachmentMaxBytes int64 = 32 << 20
25
26var roomAttachmentPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/attachments$`)
27
28var mediaPath = regexp.MustCompile(`^/media/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$`)
29
30type roomAttachmentScope struct {
31 RoomID, EventID string
32}
33
34type 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.
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, 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(&currentEvent); 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
91func 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
109func (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
144func (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
157func (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
174func (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
193func (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
218func (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
243func (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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs