| 1 | package blob |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 9 | "io" |
| 10 | "net" |
| 11 | "net/http" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "regexp" |
| 15 | "strings" |
| 16 | "sync" |
| 17 | ) |
| 18 | |
| 19 | // Action identifies the file-door operation being authorized. |
| 20 | type Action string |
| 21 | |
| 22 | const ( |
| 23 | ActionUpload Action = "upload" |
| 24 | ActionGet Action = "get" |
| 25 | ActionDelete Action = "delete" |
| 26 | ActionList Action = "list" |
| 27 | ActionMirror Action = "mirror" |
| 28 | ActionReport Action = "report" |
| 29 | ) |
| 30 | |
| 31 | // Authorize authenticates an HTTP request for an action and returns the |
| 32 | // caller's pubkey. |
| 33 | type Authorize func(request *http.Request, action Action) (pubkey string, err error) |
| 34 | |
| 35 | // CanRead reports whether any supplied reader pubkey may read the blob. |
| 36 | type CanRead func(ctx context.Context, blobSHA string, readerPubkeys []string) bool |
| 37 | |
| 38 | // ResolveIP resolves a host for mirror URL policy checks. |
| 39 | type ResolveIP func(ctx context.Context, host string) ([]net.IP, error) |
| 40 | |
| 41 | // ReportRecorder records a report in the host moderation transaction. The |
| 42 | // targetKind and reportType values describe the reported object and report; |
| 43 | // blob reports currently pass "blob" for both. |
| 44 | type ReportRecorder func(ctx context.Context, tx *sql.Tx, reporter string, targetBlob string, targetKind string, reportType string, content string) error |
| 45 | |
| 46 | // Config supplies durable storage, HTTP policy and host integration hooks. |
| 47 | type Config struct { |
| 48 | Root string |
| 49 | PublicURL string |
| 50 | Store *storage.Store |
| 51 | Authorize Authorize |
| 52 | CanRead CanRead |
| 53 | HTTPClient *http.Client |
| 54 | ResolveIP ResolveIP |
| 55 | IsOwner func(string) bool |
| 56 | ValidateUpload func(*http.Request, string) error |
| 57 | // ResolveServers returns the ordered BUD-03 servers for an authorized |
| 58 | // caller. It is used only for Blossom URI mirror fallback. |
| 59 | ResolveServers func(context.Context, string) ([]string, error) |
| 60 | // RecordReport joins native blob reporting to the host moderation ledger. |
| 61 | // It runs inside the blob report transaction. |
| 62 | RecordReport ReportRecorder |
| 63 | // Limits is read for each upload so policy changes apply without reopening |
| 64 | // the blob service. Zero values mean unlimited. |
| 65 | Limits func() Limits |
| 66 | // UploadTerms returns what an uploader's standing imposes on its uploads: |
| 67 | // an agent grant with a sites ttl gives its claims an expiry, and one with |
| 68 | // the encrypted flag refuses plain uploads. Zero values mean no terms. |
| 69 | UploadTerms func(context.Context, string) (UploadTerms, error) |
| 70 | } |
| 71 | |
| 72 | // Limits are applied when each upload starts. Zero leaves that limit open. |
| 73 | type Limits struct { |
| 74 | MaxFileBytes int64 |
| 75 | UserStorageBytes int64 |
| 76 | } |
| 77 | |
| 78 | // UploadTerms is what an uploader's grant imposes on the blobs it stores. |
| 79 | // ExpiresAt is the unix time its claims lapse, 0 for never; Encrypted |
| 80 | // requires the stored bytes to be an encrypted blob or manifest. |
| 81 | type UploadTerms struct { |
| 82 | ExpiresAt int64 |
| 83 | Encrypted bool |
| 84 | } |
| 85 | |
| 86 | // encryptedTypes are the content types the browser uploader gives encrypted |
| 87 | // objects: AES-GCM ciphertext for files and chunks, and the BUD-16 directory |
| 88 | // type for encrypted manifests. Anything the relay recognizes as a page, |
| 89 | // image, video, audio or document is plain. |
| 90 | var encryptedTypes = map[string]bool{"application/octet-stream" : true, "application/vnd.blossom.directory+msgpack" : true} |
| 91 | |
| 92 | // Blob is the metadata for one content-addressed object. |
| 93 | type Blob struct { |
| 94 | SHA256 string `json:"sha256"` |
| 95 | Size int64 `json:"size"` |
| 96 | Type string `json:"type"` |
| 97 | Uploader string `json:"uploader"` |
| 98 | Uploaded int64 `json:"uploaded"` |
| 99 | } |
| 100 | |
| 101 | // BlobClaimMetadata names a blob for one uploader. Names are claim-scoped so |
| 102 | // deduplicated blobs can have different names for different uploaders. |
| 103 | type BlobClaimMetadata struct { |
| 104 | SHA256 string `json:"sha256"` |
| 105 | Uploader string `json:"uploader"` |
| 106 | Name string `json:"name"` |
| 107 | Path string `json:"path"` |
| 108 | Purpose string `json:"purpose,omitempty"` |
| 109 | Size int64 `json:"size,omitempty"` |
| 110 | Type string `json:"type,omitempty"` |
| 111 | UpdatedAt int64 `json:"updated_at"` |
| 112 | } |
| 113 | |
| 114 | // PutOptions describes a blob upload and its optional metadata transaction. |
| 115 | type PutOptions struct { |
| 116 | Reader io.Reader |
| 117 | Type string |
| 118 | Uploader string |
| 119 | Hash string |
| 120 | Name string |
| 121 | Path string |
| 122 | // Access controls who may retrieve the stored bytes. Empty means public. |
| 123 | Access string |
| 124 | // Purpose identifies logical encrypted roots or their internal chunks. |
| 125 | Purpose string |
| 126 | // Commit records ownership metadata in the blob transaction. Its isNew |
| 127 | // argument reports whether the blob was newly installed. An error rolls |
| 128 | // back all claims. |
| 129 | // New bytes remain unavailable until their ownership metadata is durable. |
| 130 | Commit func(ctx context.Context, tx *sql.Tx, blob Blob, isNew bool) error |
| 131 | } |
| 132 | |
| 133 | // Service owns blob files and their metadata operations. |
| 134 | type Service struct { |
| 135 | config Config |
| 136 | root string |
| 137 | store *storage.Store |
| 138 | resolve ResolveIP |
| 139 | quotaMu sync.Mutex |
| 140 | // multipartMu protects partial files and their range metadata. Lock it |
| 141 | // before quotaMu; network bodies are never read while either is held. |
| 142 | multipartMu sync.Mutex |
| 143 | activeParts map[string]int |
| 144 | // multipartFinalizing prevents another chunk from changing a complete |
| 145 | // session while its assembled file is being hashed. Hashing is deliberately |
| 146 | // outside multipartMu because the upload may be as large as 1 GiB. |
| 147 | multipartFinalizing map[string]bool |
| 148 | // verifyMultipartHook is used by package tests to pause finalization |
| 149 | // without allocating a GiB fixture. It is nil in production. |
| 150 | verifyMultipartHook func(context.Context, string, string, int64) error |
| 151 | } |
| 152 | |
| 153 | var ErrBlocked = errors.New("blocked: this blob was removed by a moderator" ) |
| 154 | var ErrHashMismatch = errors.New("conflict: mirrored blob hash does not match the requested hash" ) |
| 155 | var ErrFileTooLarge = errors.New("invalid: blob exceeds the per-file size limit" ) |
| 156 | var ErrQuotaExceeded = errors.New("restricted: uploader storage quota exceeded" ) |
| 157 | var ErrMultipartConflict = errors.New("conflict: upload metadata differs from existing session" ) |
| 158 | var ErrNotEncrypted = errors.New("restricted: agent grant requires encrypted uploads" ) |
| 159 | var ErrAccessConflict = errors.New("conflict: the blob already has a different access policy" ) |
| 160 | var ErrInvalidAccess = errors.New("invalid: access must be public or members" ) |
| 161 | |
| 162 | const ( |
| 163 | AccessPublic = "public" |
| 164 | AccessMembers = "members" |
| 165 | ) |
| 166 | |
| 167 | var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$` ) |
| 168 | var blobPathPattern = regexp.MustCompile(`^/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$` ) |
| 169 | |
| 170 | // HandlesPath keeps the daemon mount in sync with every supported file door. |
| 171 | func HandlesPath(path string) bool { |
| 172 | return path == "/upload" || path == "/mirror" || path == "/report" || |
| 173 | path == "/files/metadata" || |
| 174 | path == "/nip96" || path == "/.well-known/nostr/nip96.json" || |
| 175 | strings.HasPrefix(path, "/nip96/" ) || strings.HasPrefix(path, "/list/" ) || |
| 176 | blobPathPattern.MatchString(path) |
| 177 | } |
| 178 | |
| 179 | var extensions = map[string]string{ |
| 180 | "image/png" : "png" , "image/jpeg" : "jpg" , "image/gif" : "gif" , "image/webp" : "webp" , "image/avif" : "avif" , "image/svg+xml" : "svg" , |
| 181 | "video/mp4" : "mp4" , "video/webm" : "webm" , "video/quicktime" : "mov" , "audio/mpeg" : "mp3" , "audio/ogg" : "ogg" , "audio/wav" : "wav" , "audio/mp4" : "m4a" , |
| 182 | "application/pdf" : "pdf" , "application/json" : "json" , "text/plain" : "txt" , "text/markdown" : "md" , |
| 183 | "application/vnd.blossom.directory+msgpack" : "bdir" , |
| 184 | } |
| 185 | |
| 186 | func New(ctx context.Context, config Config) (*Service, error) { |
| 187 | if config.Root == "" || config.Store == nil { |
| 188 | return nil, errors.New("blob root and store are required" ) |
| 189 | } |
| 190 | if err := os.MkdirAll(filepath.Join(config.Root, "blobs" ), 0o700); err != nil { |
| 191 | return nil, fmt.Errorf("create blob root: %w" , err) |
| 192 | } |
| 193 | if _, err := config.Store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS blobs ( |
| 194 | sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL, |
| 195 | uploader TEXT NOT NULL, uploaded INTEGER NOT NULL, access TEXT NOT NULL DEFAULT 'public' |
| 196 | ); CREATE TABLE IF NOT EXISTS blob_claims ( |
| 197 | sha256 TEXT NOT NULL, uploader TEXT NOT NULL, claimed_at INTEGER NOT NULL, |
| 198 | PRIMARY KEY (sha256, uploader), FOREIGN KEY (sha256) REFERENCES blobs(sha256) ON DELETE CASCADE |
| 199 | ); CREATE INDEX IF NOT EXISTS blobs_uploader ON blobs(uploader, uploaded DESC); |
| 200 | CREATE INDEX IF NOT EXISTS blob_claims_uploader ON blob_claims(uploader, claimed_at DESC, sha256 DESC); |
| 201 | CREATE TABLE IF NOT EXISTS blob_claim_metadata ( |
| 202 | sha256 TEXT NOT NULL, uploader TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, |
| 203 | purpose TEXT NOT NULL DEFAULT '' , logical_size INTEGER NOT NULL DEFAULT 0, logical_type TEXT NOT NULL DEFAULT '' , |
| 204 | updated_at INTEGER NOT NULL, PRIMARY KEY (sha256,uploader,path), |
| 205 | FOREIGN KEY (sha256,uploader) REFERENCES blob_claims(sha256,uploader) ON DELETE CASCADE |
| 206 | ); |
| 207 | CREATE INDEX IF NOT EXISTS blob_claim_metadata_uploader ON blob_claim_metadata(uploader,updated_at DESC,sha256 DESC); |
| 208 | CREATE TABLE IF NOT EXISTS blob_reports ( |
| 209 | id TEXT PRIMARY KEY, reporter TEXT NOT NULL, target_pubkey TEXT NOT NULL DEFAULT '' , |
| 210 | target_blob TEXT NOT NULL, type TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL, |
| 211 | status TEXT NOT NULL DEFAULT 'open' , resolved_by TEXT NOT NULL DEFAULT '' , |
| 212 | resolved_at INTEGER NOT NULL DEFAULT 0, action TEXT NOT NULL DEFAULT '' , |
| 213 | UNIQUE(reporter,target_blob)); |
| 214 | CREATE TRIGGER IF NOT EXISTS blob_claim_on_insert AFTER INSERT ON blobs |
| 215 | WHEN NEW.uploader != '' BEGIN INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) VALUES(NEW.sha256,NEW.uploader,NEW.uploaded); END; |
| 216 | CREATE TABLE IF NOT EXISTS blob_blocks (sha256 TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '' , blocked_at INTEGER NOT NULL DEFAULT 0); |
| 217 | CREATE TABLE IF NOT EXISTS blob_tombstones (sha256 TEXT PRIMARY KEY, deleted_at INTEGER NOT NULL); |
| 218 | CREATE TABLE IF NOT EXISTS multipart_uploads (id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, uploader TEXT NOT NULL, length INTEGER NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_seen INTEGER NOT NULL, name TEXT NOT NULL DEFAULT '' , path TEXT NOT NULL DEFAULT '' , access TEXT NOT NULL DEFAULT 'public' , purpose TEXT NOT NULL DEFAULT '' ); |
| 219 | CREATE TABLE IF NOT EXISTS multipart_parts (upload_id TEXT NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY(upload_id, offset), FOREIGN KEY(upload_id) REFERENCES multipart_uploads(id) ON DELETE CASCADE); |
| 220 | INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) |
| 221 | SELECT sha256,uploader,uploaded FROM blobs WHERE uploader != '' `); err != nil { |
| 222 | return nil, fmt.Errorf("initialize blob metadata: %w" , err) |
| 223 | } |
| 224 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blobs ADD COLUMN access TEXT NOT NULL DEFAULT 'public'" ); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 225 | return nil, fmt.Errorf("add blob access: %w" , err) |
| 226 | } |
| 227 | for _, column := range []string{"purpose TEXT NOT NULL DEFAULT ''" , "logical_size INTEGER NOT NULL DEFAULT 0" , "logical_type TEXT NOT NULL DEFAULT ''" } { |
| 228 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blob_claim_metadata ADD COLUMN " +column); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 229 | return nil, fmt.Errorf("add blob claim metadata field: %w" , err) |
| 230 | } |
| 231 | } |
| 232 | // Claims made under an agent grant with a ttl carry an expiry; every |
| 233 | // earlier claim keeps 0, which never lapses. |
| 234 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blob_claims ADD COLUMN expires INTEGER NOT NULL DEFAULT 0" ); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 235 | return nil, fmt.Errorf("add blob claim expiry: %w" , err) |
| 236 | } |
| 237 | for _, column := range []string{"name TEXT NOT NULL DEFAULT ''" , "path TEXT NOT NULL DEFAULT ''" } { |
| 238 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN " +column); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 239 | return nil, fmt.Errorf("add multipart metadata: %w" , err) |
| 240 | } |
| 241 | } |
| 242 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN access TEXT NOT NULL DEFAULT 'public'" ); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 243 | return nil, fmt.Errorf("add multipart access: %w" , err) |
| 244 | } |
| 245 | if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN purpose TEXT NOT NULL DEFAULT ''" ); err != nil && !strings.Contains(err.Error(), "duplicate column" ) { |
| 246 | return nil, fmt.Errorf("add multipart purpose: %w" , err) |
| 247 | } |
| 248 | if _, err := config.Store.DB().ExecContext(ctx, "CREATE INDEX IF NOT EXISTS blob_claims_expires ON blob_claims(expires) WHERE expires>0" ); err != nil { |
| 249 | return nil, fmt.Errorf("index blob claim expiry: %w" , err) |
| 250 | } |
| 251 | if err := reconcile(ctx, config.Store, filepath.Join(config.Root, "blobs" )); err != nil { |
| 252 | return nil, err |
| 253 | } |
| 254 | resolve := config.ResolveIP |
| 255 | if resolve == nil { |
| 256 | resolve = func(ctx context.Context, host string) ([]net.IP, error) { |
| 257 | return net.DefaultResolver.LookupIP(ctx, "ip" , host) |
| 258 | } |
| 259 | } |
| 260 | s := &Service{config: config, root: filepath.Join(config.Root, "blobs" ), store: config.Store, resolve: resolve, activeParts: make(map[string]int), multipartFinalizing: make(map[string]bool)} |
| 261 | if err := s.reconcileMultipart(ctx); err != nil { |
| 262 | return nil, err |
| 263 | } |
| 264 | return s, nil |
| 265 | } |
| 266 | |
| 267 | func (s *Service) Handler() http.HandlerFunc { return s.serveHTTP } |