package main import ( "context" "encoding/json" "os/exec" "strconv" "strings" "time" ) // Everything here shells out with exec.CommandContext and an argument list — never a shell string. type probeResult struct { Title string Artist string Duration time.Duration } type ffprobeOutput struct { Format struct { Duration string `json:"duration"` Tags map[string]string `json:"tags"` } `json:"format"` Streams []struct { CodecType string `json:"codec_type"` Tags map[string]string `json:"tags"` } `json:"streams"` } // probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case // by container (title, TITLE, Title), so the map is lowercased before anything is read from it. func probe(ctx context.Context, path string) (probeResult, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output() if err != nil { return probeResult{}, err } var parsed ffprobeOutput if err := json.Unmarshal(out, &parsed); err != nil { return probeResult{}, err } tags := map[string]string{} for _, stream := range parsed.Streams { if stream.CodecType != "audio" { continue } for k, v := range stream.Tags { tags[strings.ToLower(k)] = v } } // Container tags win over stream tags when both exist. for k, v := range parsed.Format.Tags { tags[strings.ToLower(k)] = v } var res probeResult res.Title = clean(tags["title"], 100) res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100) if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil { res.Duration = time.Duration(secs * float64(time.Second)) } return res, nil } func firstOf(m map[string]string, keys ...string) string { for _, k := range keys { if v := strings.TrimSpace(m[k]); v != "" { return v } } return "" } // Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on // render, but a title with an embedded newline wrecks every list layout it appears in. func clean(s string, max int) string { s = strings.Map(func(r rune) rune { if r == '\n' || r == '\r' || r == '\t' { return ' ' } if r < 0x20 || r == 0x7f { return -1 } return r }, s) s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) if r := []rune(s); len(r) > max { s = strings.TrimSpace(string(r[:max])) } return s } // convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. // No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth // showing — "Invalid data found when processing input" beats "submission failed". func convertToOpus(ctx context.Context, in, out string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out) var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return tail(stderr.String(), 400), err } return "", nil } func tail(s string, n int) string { s = strings.TrimSpace(s) lines := strings.Split(s, "\n") if len(lines) > 3 { lines = lines[len(lines)-3:] } s = strings.TrimSpace(strings.Join(lines, " ")) if r := []rune(s); len(r) > n { s = string(r[len(r)-n:]) } return s }