// Package audio holds the adapters that inspect and convert user-supplied audio // with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is // tested with fakes and only this package knows a subprocess is involved. package audio import ( "context" "encoding/json" "errors" "fmt" "os/exec" "strconv" "time" "git.d-ma.be/mathias/tapir/internal/uploads" ) // FFprobe implements uploads.Prober by running ffprobe on the spooled file. type FFprobe struct { // Bin is the ffprobe executable ("ffprobe" resolves via PATH). Bin string } // AllowedDemuxers are the input formats an upload may be. The input is // attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or // URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent // ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed, // concat rejects unsafe paths), but that is version luck; this list is not. It // holds the containers real recordings use and none of the playlist or network // formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm. const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf" // MaxStreams bounds how many streams a file may declare. A 1.3 MB file declaring 900 of // them cost ~570 MB of RSS in each of ffprobe and ffmpeg (#49), against a 512 Mi pod; // real recordings have a handful. const MaxStreams = "32" // inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input. // Local files and pipes only: no network, no other protocols, and a bounded stream count. var inputRestrictions = []string{ "-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers, "-max_streams", MaxStreams, } // probeEntries is exactly what ParseProbe reads. -show_format/-show_streams print every // tag too, and a 120 MB WAV made 491 MB of JSON that Output() buffers. const probeEntries = "format=duration:stream=codec_type:stream_disposition=attached_pic" // killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close. const killGrace = 2 * time.Second // Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe // looked at the file and could not read it: that is the FILE's fault and returns // uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable // output is OURS and is returned as an ordinary error, so it is never reported to // the user as their file being bad. func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) { bin := f.Bin if bin == "" { bin = "ffprobe" } args := append([]string{"-v", "error"}, inputRestrictions...) args = append(args, "-print_format", "json", "-show_entries", probeEntries, path) cmd := exec.CommandContext(ctx, bin, args...) cmd.WaitDelay = killGrace out, err := cmd.Output() if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr) } var exit *exec.ExitError if errors.As(err, &exit) { return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr) } return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err) } p, err := ParseProbe(out) if err != nil { return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err) } return p, nil } // probeJSON is the subset of `ffprobe -print_format json` output that matters. type probeJSON struct { Streams []struct { CodecType string `json:"codec_type"` Disposition struct { AttachedPic int `json:"attached_pic"` } `json:"disposition"` } `json:"streams"` Format struct { Duration string `json:"duration"` } `json:"format"` } // ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is // only attached cover art (an mp3/m4a with an album picture) is not video. A // missing or "N/A" duration yields zero, which uploads.Validate rejects as // unreadable rather than accepting an empty recording. func ParseProbe(data []byte) (uploads.Probe, error) { var j probeJSON if err := json.Unmarshal(data, &j); err != nil { return uploads.Probe{}, fmt.Errorf("decode: %w", err) } var p uploads.Probe for _, s := range j.Streams { switch s.CodecType { case "audio": p.HasAudio = true case "video": if s.Disposition.AttachedPic == 0 { p.HasVideo = true } } } if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 { p.Duration = time.Duration(secs * float64(time.Second)) } return p, nil }