package uploads import ( "context" "errors" "fmt" "io" "os" "sync" "time" "git.d-ma.be/mathias/tapir/internal/domain" ) // ErrUnreadable is what a Prober returns when the FILE is the problem (not media, // truncated, damaged), as opposed to the prober itself failing. Accept turns it // into a user-facing rejection; any other prober error is our fault and surfaces // as an internal error — a missing ffprobe must not read as "your file is bad". var ErrUnreadable = errors.New("uploads: file is not readable media") // Prober inspects a file on disk. The ffprobe adapter implements it. type Prober interface { Probe(ctx context.Context, path string) (Probe, error) } // Store is the slice of the private transcript store the intake needs. type Store interface { CreateUpload(ctx context.Context, userID, title string, lang domain.Language, hadVideo bool) (string, error) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error } // Job is one accepted upload awaiting transcription. Path is a temp file the // receiver owns: it must delete it when finished (the audio is never retained). type Job struct { UserID string UploadID string Path string // Language is what the user chose for this recording; empty means Swedish (a job // queued before the choice existed). Language domain.Language // Duration is what the file CLAIMED when probed. The worker compares it with what // was really converted; zero means unknown (ADR-035 review). Duration time.Duration } // Submitter hands accepted jobs to the in-process worker (ADR-033). type Submitter interface { Submit(Job) error } // Intake validates an uploaded recording and, if it passes, queues it. type Intake struct { store Store prober Prober sub Submitter limits Limits tmpDir string // MaxReceives and MaxReceivesPerUser bound how many request bodies Accept reads at // once; zero means the defaults. Without them N parallel POSTs each spool up to // MaxBytes while the queue still looks empty, and the spool volume fills (#47). MaxReceives int MaxReceivesPerUser int mu sync.Mutex receives int byUser map[string]int } const ( defaultMaxReceives = 4 defaultMaxReceivesPerUser = 2 ) // acquireReceive reserves a receive slot for userID, or reports there is none. Every // successful call must be paired with releaseReceive. func (in *Intake) acquireReceive(userID string) bool { maxAll, maxUser := in.MaxReceives, in.MaxReceivesPerUser if maxAll <= 0 { maxAll = defaultMaxReceives } if maxUser <= 0 { maxUser = defaultMaxReceivesPerUser } in.mu.Lock() defer in.mu.Unlock() if in.receives >= maxAll || in.byUser[userID] >= maxUser { return false } if in.byUser == nil { in.byUser = map[string]int{} } in.receives++ in.byUser[userID]++ return true } func (in *Intake) releaseReceive(userID string) { in.mu.Lock() defer in.mu.Unlock() in.receives-- if in.byUser[userID]--; in.byUser[userID] <= 0 { delete(in.byUser, userID) } } // NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist. func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intake { return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir} } // ErrClientRead marks an error that came from READING the client's body: a connection that // dropped, a body cut short. It is the sender's to retry, unlike a spool, probe or database // fault, which is ours. It is a marker on the error, not a sentinel that others may wrap: // io.ErrUnexpectedEOF alone is also what a database drop looks like. var ErrClientRead = errors.New("uploads: client body read failed") // clientReader remembers the last non-EOF error its source returned, so Accept can tell // a failure reading the client from a failure writing the spool file. type clientReader struct { r io.Reader err error } func (c *clientReader) Read(p []byte) (int, error) { n, err := c.r.Read(p) if err != nil && !errors.Is(err, io.EOF) { c.err = err } return n, err } // busyMessage is what a user is told when the queue is full. const busyMessage = "Too many recordings are waiting to be transcribed. Please try again in a few minutes." // Accept spools src to disk (bounded by MaxBytes), probes it, validates it, records // a queued upload and submits the job. It returns the upload id. A file that fails // validation yields a *RejectError and creates NO row — rejection is synchronous // and leaves nothing behind. On success the temp file is handed to the job and the // worker deletes it; on every failure path Accept deletes it itself. func (in *Intake) Accept(ctx context.Context, userID, title string, lang domain.Language, src io.Reader) (string, error) { // Refuse before spooling when the queue has no room, if the submitter can say so: a full // queue otherwise cost up to the upload cap on disk, an ffprobe run and a failed row for // every refused attempt. if rc, ok := in.sub.(interface{ HasRoom(userID string) bool }); ok && !rc.HasRoom(userID) { return "", &RejectError{Reason: ReasonBusy, Message: busyMessage} } // HasRoom cannot see bodies still being received; bound those separately. if !in.acquireReceive(userID) { return "", &RejectError{Reason: ReasonBusy, Message: busyMessage} } defer in.releaseReceive(userID) f, err := os.CreateTemp(in.tmpDir, "upload-*") if err != nil { return "", fmt.Errorf("uploads: spool: %w", err) } path := f.Name() handedOver := false defer func() { if !handedOver { _ = os.Remove(path) } }() // Read one byte past the cap so "exactly at the limit" and "over" are told apart. cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)} n, err := io.Copy(f, cr) if cerr := f.Close(); err == nil { err = cerr } if err != nil { if cr.err != nil && errors.Is(err, cr.err) { return "", fmt.Errorf("%w: %w", ErrClientRead, err) } return "", fmt.Errorf("uploads: receive: %w", err) } if n > in.limits.MaxBytes { return "", &RejectError{Reason: ReasonTooLarge, Message: fmt.Sprintf( "This file is larger than the %d MB limit.", in.limits.MaxBytes>>20)} } probe, err := in.prober.Probe(ctx, path) if errors.Is(err, ErrUnreadable) { return "", &RejectError{ Reason: ReasonUnreadable, Message: "This file could not be read as audio; it may be damaged or not a recording.", } } if err != nil { return "", fmt.Errorf("uploads: probe: %w", err) } if err := Validate(probe, in.limits); err != nil { return "", err } id, err := in.store.CreateUpload(ctx, userID, title, lang, probe.HasVideo) if err != nil { return "", fmt.Errorf("uploads: create: %w", err) } if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, Language: lang, Duration: probe.Duration}); err != nil { if errors.Is(err, ErrQueueFull) { // The user's to retry, not a server fault: refuse it as such, and do not leave // the row queued -- that is the stuck spinner. if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrBusy); ferr != nil { return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr) } return "", &RejectError{Reason: ReasonBusy, Message: busyMessage} } // A row that will never run must not sit queued: that is the stuck spinner. if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil { return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr) } return "", fmt.Errorf("uploads: submit: %w", err) } handedOver = true return id, nil }