tinyrelayfile
tinyrelay/internal/blob/multipart.go/

internal/blob/multipart.go raw
1package blob
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "io"
11 "mime"
12 "net/http"
13 "os"
14 "path/filepath"
15 "sort"
16 "strconv"
17 "strings"
18 "time"
19)
20
21const (
22 multipartExpiry = 60 * time.Second
23 multipartChunkLimit = 8 << 20
24 multipartStorageLimit = 1 << 30
25 multipartSessionLimit = 64
26 multipartUserSessionLimit = 16
27 multipartActiveLimit = 32
28 multipartRangeLimit = 1024
29)
30
31type multipartRequest struct {
32 id, hash, uploader, typ, name, metadataPath, access, purpose string
33 length, offset, count int64
34}
35
36type multipartError struct {
37 status int
38 message string
39}
40
41func (e *multipartError) Error() string { return e.message }
42func multipartFailure(status int, message string) error { return &multipartError{status, message} }
43func (s *Service) failMultipart(w http.ResponseWriter, err error) {
44 var failure *multipartError
45 if errors.As(err, &failure) {
46 s.fail(w, failure.status, err)
47 return
48 }
49 s.fail(w, statusFor(err), err)
50}
51
52func (s *Service) multipart(w http.ResponseWriter, r *http.Request, hash string) {
53 uploader, err := s.authorize(r, ActionUpload)
54 if err != nil {
55 s.fail(w, http.StatusUnauthorized, err)
56 return
57 }
58 part, err := parseMultipart(r, hash, uploader)
59 if err != nil {
60 s.failMultipart(w, err)
61 return
62 }
63 if err := s.reserveMultipart(r.Context(), part); err != nil {
64 s.failMultipart(w, err)
65 return
66 }
67 defer s.releaseMultipart(part.id)
68 chunk, err := s.stageMultipart(r, part)
69 if err != nil {
70 s.failMultipart(w, err)
71 return
72 }
73 defer os.Remove(chunk)
74 entry, created, complete, err := s.commitMultipart(r.Context(), part, chunk)
75 if err != nil {
76 s.failMultipart(w, err)
77 return
78 }
79 if !complete {
80 w.WriteHeader(http.StatusNoContent)
81 return
82 }
83 s.writeJSON(w, chooseStatus(created, http.StatusCreated), s.descriptor(r, entry))
84}
85
86func parseMultipart(r *http.Request, hash, uploader string) (multipartRequest, error) {
87 part := multipartRequest{id: multipartID(hash, uploader), hash: hash, uploader: uploader, count: r.ContentLength}
88 name, metadataPath, metadataErr := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))
89 if metadataErr != nil {
90 return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())
91 }
92 part.name, part.metadataPath = name, metadataPath
93 part.access, metadataErr = parseAccess(r.URL.Query().Get("access"))
94 if metadataErr != nil {
95 return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())
96 }
97 part.purpose = strings.TrimSpace(r.URL.Query().Get("purpose"))
98 if part.purpose != "" && part.purpose != "chunk" && part.purpose != "file" {
99 return part, multipartFailure(http.StatusBadRequest, "invalid upload purpose")
100 }
101 if r.ContentLength < 0 {
102 return part, multipartFailure(411, "Content-Length is required")
103 }
104 if r.Header.Get("Content-Type") != "application/octet-stream" {
105 return part, multipartFailure(415, "Content-Type must be application/octet-stream")
106 }
107 for _, item := range []struct {
108 name string
109 target *int64
110 }{{"Upload-Length", &part.length}, {"Upload-Offset", &part.offset}} {
111 raw := r.Header.Get(item.name)
112 value, err := strconv.ParseInt(raw, 10, 64)
113 if err != nil || value < 0 || raw == "" || strings.Trim(raw, "0123456789") != "" {
114 return part, multipartFailure(400, item.name+" must be a nonnegative integer")
115 }
116 *item.target = value
117 }
118 if part.offset > part.length || part.count > part.length-part.offset {
119 return part, multipartFailure(416, "chunk range exceeds Upload-Length")
120 }
121 if part.count == 0 && part.length != 0 {
122 return part, multipartFailure(400, "nonempty uploads require nonempty chunks")
123 }
124 if part.count > multipartChunkLimit || part.length > multipartStorageLimit {
125 return part, multipartFailure(413, "multipart upload exceeds temporary storage limits")
126 }
127 typ, _, err := mime.ParseMediaType(r.Header.Get("Upload-Type"))
128 if err != nil || !strings.Contains(typ, "/") {
129 return part, multipartFailure(400, "Upload-Type must be a MIME type")
130 }
131 part.typ = contentType(typ)
132 return part, nil
133}
134
135func multipartID(hash, uploader string) string {
136 digest := sha256.Sum256([]byte(uploader))
137 return hash + "-" + hex.EncodeToString(digest[:])
138}
139func (s *Service) multipartPath(id string) string { return filepath.Join(s.root, ".multipart-"+id) }
140
141// Admission reserves the final length before reading any request bytes. Each
142// active chunk is also bounded, so unlimited durable storage does not mean
143// unlimited temporary files or sessions.
144func (s *Service) reserveMultipart(ctx context.Context, part multipartRequest) error {
145 s.multipartMu.Lock()
146 defer s.multipartMu.Unlock()
147 s.quotaMu.Lock()
148 defer s.quotaMu.Unlock()
149 if err := s.cleanupMultipartLocked(ctx); err != nil {
150 return err
151 }
152 active := 0
153 for _, count := range s.activeParts {
154 active += count
155 }
156 if active >= multipartActiveLimit {
157 return multipartFailure(429, "too many active upload chunks")
158 }
159 if err := s.multipartAdmission(ctx, part); err != nil {
160 return err
161 }
162 s.activeParts[part.id]++
163 return nil
164}
165
166func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest) error {
167 if s.blocked(ctx, part.hash) {
168 return ErrBlocked
169 }
170 limits := s.currentLimits()
171 if limits.MaxFileBytes > 0 && part.length > limits.MaxFileBytes {
172 return ErrFileTooLarge
173 }
174 existing, lookupErr := s.lookup(ctx, part.hash)
175 if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
176 return lookupErr
177 }
178 if lookupErr == nil {
179 if existing.Size != part.length {
180 return ErrMultipartConflict
181 }
182 return s.checkMultipartQuota(ctx, part, limits)
183 }
184 var length int64
185 var typ, name, metadataPath, access, purpose string
186 err := s.store.DB().QueryRowContext(ctx, "SELECT length,type,name,path,access,purpose FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ, &name, &metadataPath, &access, &purpose)
187 if err != nil && !errors.Is(err, sql.ErrNoRows) {
188 return err
189 }
190 if err == nil && (length != part.length || typ != part.typ || access != part.access || (purpose != "" && purpose != part.purpose)) {
191 return ErrMultipartConflict
192 }
193 if err := s.checkMultipartQuota(ctx, part, limits); err != nil {
194 return err
195 }
196 if errors.Is(err, sql.ErrNoRows) {
197 return s.createMultipart(ctx, part)
198 }
199 if part.name == "" && part.metadataPath == "" {
200 part.name, part.metadataPath = name, metadataPath
201 }
202 if part.purpose == "" {
203 part.purpose = purpose
204 }
205 _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=?,name=?,path=?,purpose=? WHERE id=?", time.Now().Unix(), part.name, part.metadataPath, part.purpose, part.id)
206 return err
207}
208
209func (s *Service) checkMultipartQuota(ctx context.Context, part multipartRequest, limits Limits) error {
210 if limits.UserStorageBytes == 0 || s.hasClaim(ctx, part.hash, part.uploader) {
211 return nil
212 }
213 used, err := s.quotaUsageExcept(ctx, part.uploader, part.id)
214 if err != nil {
215 return err
216 }
217 if part.length > limits.UserStorageBytes-used {
218 return ErrQuotaExceeded
219 }
220 return nil
221}
222
223func (s *Service) createMultipart(ctx context.Context, part multipartRequest) error {
224 var count, userCount int
225 var total int64
226 err := s.store.DB().QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(SUM(length),0),COALESCE(SUM(uploader=?),0) FROM multipart_uploads`, part.uploader).Scan(&count, &total, &userCount)
227 if err != nil {
228 return err
229 }
230 if count >= multipartSessionLimit || userCount >= multipartUserSessionLimit || part.length > multipartStorageLimit-total {
231 return multipartFailure(429, "temporary upload capacity is full")
232 }
233 path := s.multipartPath(part.id)
234 file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0o600)
235 if err != nil {
236 return err
237 }
238 err = errors.Join(file.Truncate(part.length), file.Sync(), file.Close())
239 if err != nil {
240 _ = os.Remove(path)
241 return err
242 }
243 now := time.Now().Unix()
244 _, err = s.store.DB().ExecContext(ctx, "INSERT INTO multipart_uploads(id,sha256,uploader,length,type,created,last_seen,name,path,access,purpose) VALUES(?,?,?,?,?,?,?,?,?,?,?)", part.id, part.hash, part.uploader, part.length, part.typ, now, now, part.name, part.metadataPath, part.access, part.purpose)
245 if err != nil {
246 _ = os.Remove(path)
247 }
248 return err
249}
250
251func (s *Service) stageMultipart(r *http.Request, part multipartRequest) (string, error) {
252 chunk, err := os.CreateTemp(s.root, ".multipart-chunk-")
253 if err != nil {
254 return "", err
255 }
256 path := chunk.Name()
257 keep := false
258 defer func() {
259 _ = chunk.Close()
260 if !keep {
261 _ = os.Remove(path)
262 }
263 }()
264 hasher := sha256.New()
265 if _, err := io.CopyN(io.MultiWriter(chunk, hasher), r.Body, part.count); err != nil {
266 return "", multipartFailure(400, "chunk body is shorter than Content-Length")
267 }
268 var extra [1]byte
269 if n, err := io.ReadFull(r.Body, extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
270 return "", multipartFailure(400, "chunk body is longer than Content-Length")
271 }
272 if err := r.Context().Err(); err != nil {
273 return "", err
274 }
275 if s.config.ValidateUpload != nil {
276 if err := s.config.ValidateUpload(r, hex.EncodeToString(hasher.Sum(nil))); err != nil {
277 return "", err
278 }
279 }
280 if err := chunk.Close(); err != nil {
281 return "", err
282 }
283 keep = true
284 return path, nil
285}
286
287func (s *Service) releaseMultipart(id string) {
288 s.multipartMu.Lock()
289 defer s.multipartMu.Unlock()
290 s.activeParts[id]--
291 if s.activeParts[id] > 0 {
292 return
293 }
294 delete(s.activeParts, id)
295 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
296 defer cancel()
297 var parts int
298 if err := s.store.DB().QueryRowContext(ctx, "SELECT COUNT(*) FROM multipart_parts WHERE upload_id=?", id).Scan(&parts); err == nil && parts == 0 {
299 s.quotaMu.Lock()
300 defer s.quotaMu.Unlock()
301 _ = s.discardMultipart(ctx, id)
302 }
303}
304
305func (s *Service) commitMultipart(ctx context.Context, part multipartRequest, chunk string) (Blob, bool, bool, error) {
306 s.multipartMu.Lock()
307 if s.multipartFinalizing[part.id] {
308 s.multipartMu.Unlock()
309 return Blob{}, false, false, multipartFailure(http.StatusConflict, "multipart upload is being finalized")
310 }
311 // Recheck policy after streaming, including uploads whose next chunk does
312 // not complete the file. Policy may have changed while the body arrived.
313 s.quotaMu.Lock()
314 admissionErr := s.multipartAdmission(ctx, part)
315 s.quotaMu.Unlock()
316 if admissionErr != nil {
317 s.multipartMu.Unlock()
318 return Blob{}, false, false, admissionErr
319 }
320 name, metadataPath, purpose := part.name, part.metadataPath, part.purpose
321 if name == "" && metadataPath == "" {
322 if err := s.store.DB().QueryRowContext(ctx, "SELECT name,path,purpose FROM multipart_uploads WHERE id=?", part.id).Scan(&name, &metadataPath, &purpose); err != nil && !errors.Is(err, sql.ErrNoRows) {
323 s.multipartMu.Unlock()
324 return Blob{}, false, false, err
325 }
326 }
327 candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, name: name, metadataPath: metadataPath, access: part.access, purpose: purpose, reservation: part.id}
328 if existing, err := s.lookup(ctx, part.hash); err == nil {
329 if existing.Size != part.length {
330 s.multipartMu.Unlock()
331 return Blob{}, false, false, ErrMultipartConflict
332 }
333 entry, _, err := s.installUpload(ctx, candidate)
334 s.multipartMu.Unlock()
335 return entry, false, err == nil, err
336 } else if !errors.Is(err, sql.ErrNoRows) {
337 s.multipartMu.Unlock()
338 return Blob{}, false, false, err
339 }
340 ranges, err := s.multipartRanges(ctx, part)
341 if err != nil {
342 s.multipartMu.Unlock()
343 return Blob{}, false, false, err
344 }
345 candidate.path = s.multipartPath(part.id)
346 if err := writeMultipartChunk(candidate.path, chunk, part.offset); err != nil {
347 _ = s.discardMultipart(ctx, part.id)
348 s.multipartMu.Unlock()
349 return Blob{}, false, false, err
350 }
351 if err := s.recordMultipartRanges(ctx, part.id, ranges); err != nil {
352 _ = s.discardMultipart(ctx, part.id)
353 s.multipartMu.Unlock()
354 return Blob{}, false, false, err
355 }
356 if len(ranges) != 1 || ranges[0].start != 0 || ranges[0].end != part.length {
357 s.multipartMu.Unlock()
358 return Blob{}, false, false, nil
359 }
360 // Keep the session reserved while hashing, but let unrelated multipart
361 // sessions and ordinary uploads proceed. The finalizing marker makes this
362 // small state transition safe without holding the global mutex for the hash.
363 s.multipartFinalizing[part.id] = true
364 s.multipartMu.Unlock()
365 verify := verifyMultipart
366 if s.verifyMultipartHook != nil {
367 verify = s.verifyMultipartHook
368 }
369 if err := verify(ctx, candidate.path, part.hash, part.length); err != nil {
370 s.multipartMu.Lock()
371 delete(s.multipartFinalizing, part.id)
372 // A canceled request must not throw away an otherwise complete session:
373 // the caller can retry finalization without retransmitting its chunks.
374 var cleanupErr error
375 if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
376 cleanupErr = s.discardMultipart(ctx, part.id)
377 }
378 s.multipartMu.Unlock()
379 return Blob{}, false, false, errors.Join(err, cleanupErr)
380 }
381 s.multipartMu.Lock()
382 entry, created, err := s.installUpload(ctx, candidate)
383 cleanupErr := s.discardMultipart(ctx, part.id)
384 delete(s.multipartFinalizing, part.id)
385 s.multipartMu.Unlock()
386 return entry, created, err == nil && cleanupErr == nil, errors.Join(err, cleanupErr)
387}
388
389type multipartRange struct{ start, end int64 }
390
391func (s *Service) multipartRanges(ctx context.Context, part multipartRequest) ([]multipartRange, error) {
392 rows, err := s.store.DB().QueryContext(ctx, "SELECT offset,length FROM multipart_parts WHERE upload_id=? ORDER BY offset", part.id)
393 if err != nil {
394 return nil, err
395 }
396 defer rows.Close()
397 ranges := []multipartRange{{part.offset, part.offset + part.count}}
398 for rows.Next() {
399 var offset, length int64
400 if err := rows.Scan(&offset, &length); err != nil {
401 return nil, err
402 }
403 ranges = append(ranges, multipartRange{offset, offset + length})
404 }
405 if err := rows.Err(); err != nil {
406 return nil, err
407 }
408 sort.Slice(ranges, func(i, j int) bool { return ranges[i].start < ranges[j].start })
409 merged := []multipartRange{ranges[0]}
410 for _, span := range ranges[1:] {
411 last := &merged[len(merged)-1]
412 if span.start <= last.end {
413 last.end = max(last.end, span.end)
414 } else {
415 merged = append(merged, span)
416 }
417 }
418 if len(merged) > multipartRangeLimit {
419 return nil, multipartFailure(429, "too many disjoint upload ranges")
420 }
421 return merged, nil
422}
423
424func writeMultipartChunk(path, chunk string, offset int64) error {
425 source, err := os.Open(chunk)
426 if err != nil {
427 return err
428 }
429 defer source.Close()
430 target, err := os.OpenFile(path, os.O_RDWR, 0o600)
431 if err != nil {
432 return err
433 }
434 defer target.Close()
435 if _, err := target.Seek(offset, io.SeekStart); err != nil {
436 return err
437 }
438 if _, err := io.Copy(target, source); err != nil {
439 return err
440 }
441 return target.Sync()
442}
443func (s *Service) recordMultipartRanges(ctx context.Context, id string, ranges []multipartRange) error {
444 return s.store.WithTx(ctx, func(tx *sql.Tx) error {
445 if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_parts WHERE upload_id=?", id); err != nil {
446 return err
447 }
448 for _, span := range ranges {
449 if _, err := tx.ExecContext(ctx, "INSERT INTO multipart_parts(upload_id,offset,length) VALUES(?,?,?)", id, span.start, span.end-span.start); err != nil {
450 return err
451 }
452 }
453 _, err := tx.ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=? WHERE id=?", time.Now().Unix(), id)
454 return err
455 })
456}
457
458// verifyMultipart hashes the assembled file outside the tenant lock. It checks
459// the request context between reads so a disconnected client stops a long
460// hash instead of holding the session until the hash completes.
461func verifyMultipart(ctx context.Context, path, hash string, size int64) error {
462 file, err := os.Open(path)
463 if err != nil {
464 return err
465 }
466 defer file.Close()
467 hasher := sha256.New()
468 var count int64
469 buffer := make([]byte, 1<<20)
470 for {
471 if err := ctx.Err(); err != nil {
472 return err
473 }
474 n, readErr := file.Read(buffer)
475 if n > 0 {
476 hasher.Write(buffer[:n])
477 count += int64(n)
478 }
479 if errors.Is(readErr, io.EOF) {
480 break
481 }
482 if readErr != nil {
483 return readErr
484 }
485 }
486 if count != size || hex.EncodeToString(hasher.Sum(nil)) != hash {
487 return ErrHashMismatch
488 }
489 return nil
490}
491func (s *Service) discardMultipart(ctx context.Context, id string) error {
492 _, err := s.store.DB().ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", id)
493 if err != nil {
494 return err
495 }
496 if err := os.Remove(s.multipartPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
497 return err
498 }
499 return nil
500}
501
502// CleanupMultipart releases inactive reservations and removes partial bytes.
503// The tenant maintenance scheduler calls it even when no uploads arrive.
504func (s *Service) CleanupMultipart(ctx context.Context) error {
505 s.multipartMu.Lock()
506 defer s.multipartMu.Unlock()
507 s.quotaMu.Lock()
508 defer s.quotaMu.Unlock()
509 return s.cleanupMultipartLocked(ctx)
510}
511func (s *Service) cleanupMultipartLocked(ctx context.Context) error {
512 rows, err := s.store.DB().QueryContext(ctx, "SELECT id FROM multipart_uploads WHERE last_seen <= ?", time.Now().Add(-multipartExpiry).Unix())
513 if err != nil {
514 return err
515 }
516 var expired []string
517 for rows.Next() {
518 var id string
519 if err := rows.Scan(&id); err != nil {
520 _ = rows.Close()
521 return err
522 }
523 if s.activeParts[id] == 0 {
524 expired = append(expired, id)
525 }
526 }
527 err = errors.Join(rows.Err(), rows.Close())
528 if err != nil {
529 return err
530 }
531 for _, id := range expired {
532 if err := s.discardMultipart(ctx, id); err != nil {
533 return err
534 }
535 }
536 return nil
537}
538func (s *Service) reconcileMultipart(ctx context.Context) error {
539 if err := s.CleanupMultipart(ctx); err != nil {
540 return err
541 }
542 entries, err := os.ReadDir(s.root)
543 if err != nil {
544 return err
545 }
546 for _, entry := range entries {
547 if !strings.HasPrefix(entry.Name(), ".multipart-") || entry.IsDir() {
548 continue
549 }
550 id := strings.TrimPrefix(entry.Name(), ".multipart-")
551 var length int64
552 err := s.store.DB().QueryRowContext(ctx, "SELECT length FROM multipart_uploads WHERE id=?", id).Scan(&length)
553 if errors.Is(err, sql.ErrNoRows) {
554 if err := os.Remove(filepath.Join(s.root, entry.Name())); err != nil {
555 return err
556 }
557 continue
558 }
559 if err != nil {
560 return err
561 }
562 info, err := entry.Info()
563 if err != nil {
564 return err
565 }
566 if info.Size() != length {
567 if err := s.discardMultipart(ctx, id); err != nil {
568 return err
569 }
570 }
571 }
572 // A reservation with a missing file cannot represent acknowledged bytes.
573 rows, err := s.store.DB().QueryContext(ctx, "SELECT id FROM multipart_uploads")
574 if err != nil {
575 return err
576 }
577 var missing []string
578 for rows.Next() {
579 var id string
580 if err := rows.Scan(&id); err != nil {
581 _ = rows.Close()
582 return err
583 }
584 if _, err := os.Stat(s.multipartPath(id)); errors.Is(err, os.ErrNotExist) {
585 missing = append(missing, id)
586 } else if err != nil {
587 _ = rows.Close()
588 return err
589 }
590 }
591 err = errors.Join(rows.Err(), rows.Close())
592 if err != nil {
593 return err
594 }
595 for _, id := range missing {
596 if err := s.discardMultipart(ctx, id); err != nil {
597 return fmt.Errorf("reconcile multipart upload: %w", err)
598 }
599 }
600 return nil
601}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs