171 lines
5.9 KiB
Go
171 lines
5.9 KiB
Go
// 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 (
|
|
"bytes"
|
|
"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
|
|
// MaxOutput bounds how much of ffprobe's stdout is read; zero means DefaultMaxOutput.
|
|
MaxOutput int
|
|
}
|
|
|
|
// DefaultMaxOutput is far above the narrowed probe output of a legitimate file (a few
|
|
// hundred bytes) and far below what a hostile one can make ffprobe print.
|
|
const DefaultMaxOutput = 1 << 20
|
|
|
|
// maxStderr is how much of ffprobe's stderr is kept for the error message.
|
|
const maxStderr = 4096
|
|
|
|
// errOutputCap is the write error that stops ffprobe once it prints past the cap.
|
|
var errOutputCap = errors.New("audio: ffprobe output exceeds the cap")
|
|
|
|
// capWriter collects up to max bytes and then refuses further writes, which closes the
|
|
// pipe and ends the process instead of buffering whatever it prints.
|
|
type capWriter struct {
|
|
buf bytes.Buffer
|
|
max int
|
|
}
|
|
|
|
func (w *capWriter) Write(p []byte) (int, error) {
|
|
if w.buf.Len()+len(p) > w.max {
|
|
return 0, errOutputCap
|
|
}
|
|
return w.buf.Write(p)
|
|
}
|
|
|
|
// headWriter keeps the first max bytes and silently drops the rest.
|
|
type headWriter struct {
|
|
buf bytes.Buffer
|
|
max int
|
|
}
|
|
|
|
func (w *headWriter) Write(p []byte) (int, error) {
|
|
if room := w.max - w.buf.Len(); room > 0 {
|
|
w.buf.Write(p[:min(room, len(p))])
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// 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
|
|
maxOut := f.MaxOutput
|
|
if maxOut <= 0 {
|
|
maxOut = DefaultMaxOutput
|
|
}
|
|
stdout, stderr := &capWriter{max: maxOut}, &headWriter{max: maxStderr}
|
|
cmd.Stdout, cmd.Stderr = stdout, stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
|
|
}
|
|
if errors.Is(err, errOutputCap) {
|
|
return uploads.Probe{}, fmt.Errorf("%w: ffprobe printed more than %d bytes", uploads.ErrUnreadable, maxOut)
|
|
}
|
|
var exit *exec.ExitError
|
|
if errors.As(err, &exit) {
|
|
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, stderr.buf.String())
|
|
}
|
|
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
|
|
}
|
|
p, err := ParseProbe(stdout.buf.Bytes())
|
|
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
|
|
}
|