From e3e1f17c40a2b7172e5d1c00d433c749dcfa92f7 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:39:51 +0200 Subject: [PATCH] feat(uploads): validation policy for probed audio uploads (ADR-033) Video is checked first so it is always refused as video; limits and reasons are typed so callers and tests branch on Reason, not message text. Co-Authored-By: Claude Sonnet 5.5 --- internal/uploads/validate.go | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 internal/uploads/validate.go diff --git a/internal/uploads/validate.go b/internal/uploads/validate.go new file mode 100644 index 0000000..8dfea85 --- /dev/null +++ b/internal/uploads/validate.go @@ -0,0 +1,82 @@ +// Package uploads is the use case behind "upload an audio recording" (ADR-033): +// validate the file by PROBING it, keep the transcript private, never retain the +// audio. It depends only on ports (a Prober, a store, a job Submitter) — the +// concrete ffprobe adapter, the web handler and the transcription worker are wired +// from outside. +package uploads + +import ( + "fmt" + "time" +) + +// Limits bound what an upload may be. Both are rejected synchronously, with a +// specific message, rather than by a timeout (ADR-033). +type Limits struct { + MaxBytes int64 + MaxDuration time.Duration +} + +// Probe is what inspecting an uploaded file revealed. It is the input to Validate +// because a name or MIME type cannot say whether a container carries video: a +// Teams voice-only export is typically an .mp4 with no video stream. +type Probe struct { + Duration time.Duration + HasAudio bool + HasVideo bool +} + +// Reason is the machine-checkable class of a rejection. +type Reason string + +const ( + ReasonVideo Reason = "video" + ReasonNoAudio Reason = "no_audio" + ReasonTooLong Reason = "too_long" + ReasonTooLarge Reason = "too_large" + ReasonUnreadable Reason = "unreadable" +) + +// RejectError is a synchronous, user-presentable refusal. Message is written for +// the person who uploaded the file; Reason is for code and tests. +type RejectError struct { + Reason Reason + Message string +} + +func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message } + +// Validate decides whether a probed file may be transcribed. Video is checked +// first so a video file is always refused as video, whatever its length. +func Validate(p Probe, l Limits) error { + switch { + case p.HasVideo: + return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " + + "Only audio recordings are supported for now; video upload is tracked in issue #28."} + case !p.HasAudio: + return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."} + case p.Duration <= 0: + return &RejectError{Reason: ReasonUnreadable, Message: "Could not read a length from this file; it may be damaged."} + case p.Duration > l.MaxDuration: + return &RejectError{Reason: ReasonTooLong, Message: fmt.Sprintf( + "This recording is %s long; the limit is %s.", FormatDuration(p.Duration), FormatDuration(l.MaxDuration))} + } + return nil +} + +// FormatDuration renders a length for people: "1 h 42 min", "45 min", "30 s". +func FormatDuration(d time.Duration) string { + d = d.Round(time.Second) + h := int(d / time.Hour) + m := int(d % time.Hour / time.Minute) + switch { + case h > 0 && m > 0: + return fmt.Sprintf("%d h %d min", h, m) + case h > 0: + return fmt.Sprintf("%d h", h) + case m > 0: + return fmt.Sprintf("%d min", m) + default: + return fmt.Sprintf("%d s", int(d/time.Second)) + } +}