Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func main() {
debug := flag.Bool("debug", false, "Log every decoded DNS query")
dnsMediaEnabled := flag.Bool("dns-media-enabled", false, "Serve media via DNS (slow relay)")
dnsMediaMaxSizeKB := flag.Int("dns-media-max-size", 100, "Per-file cap for the DNS relay in KB (0 = no cap)")
dnsAudioMaxSizeKB := flag.Int("dns-audio-max-size", 0, "Per-file cap for audio/voice files in the DNS relay in KB (0 = fallback to dns-media-max-size)")
dnsMediaCacheTTLMin := flag.Int("dns-media-cache-ttl", 600, "TTL for DNS-relay cached media, in minutes")
dnsMediaCompression := flag.String("dns-media-compression", "gzip", "Compression for DNS-relay media bytes: none|gzip|deflate")
chatDomains := flag.String("chat-domains", "", "Comma-separated dedicated sub-domains for the chat messenger (empty = chat off)")
Expand Down Expand Up @@ -327,6 +328,11 @@ func main() {
*dnsMediaMaxSizeKB = n
}
}
if env := os.Getenv("THEFEED_DNS_AUDIO_MAX_SIZE_KB"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*dnsAudioMaxSizeKB = n
}
}
if env := os.Getenv("THEFEED_DNS_MEDIA_CACHE_TTL_MIN"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*dnsMediaCacheTTLMin = n
Expand Down Expand Up @@ -377,6 +383,7 @@ func main() {
Debug: *debug,
DNSMediaEnabled: *dnsMediaEnabled,
DNSMediaMaxSize: int64(*dnsMediaMaxSizeKB) * 1024,
DNSAudioMaxSize: int64(*dnsAudioMaxSizeKB) * 1024,
DNSMediaCacheTTL: *dnsMediaCacheTTLMin,
DNSMediaCompression: *dnsMediaCompression,
FetchInterval: time.Duration(*fetchIntervalMin) * time.Minute,
Expand Down
13 changes: 8 additions & 5 deletions internal/protocol/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"hash/fnv"
"strconv"
"strings"
"unicode"
)

// Relay indices: each MediaMeta.Relays[N] flags whether the file is
Expand Down Expand Up @@ -147,11 +148,13 @@ func SanitiseMediaFilename(s string) string {
func filterFilenameRunes(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= '0' && r <= '9',
r >= 'A' && r <= 'Z',
r >= 'a' && r <= 'z',
r == '.', r == '_', r == '-':
if r == unicode.ReplacementChar {
continue
}
if r == ':' || r == '/' || r == '\\' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
continue
}
if unicode.IsPrint(r) {
b.WriteRune(r)
}
}
Expand Down
19 changes: 10 additions & 9 deletions internal/protocol/media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,16 @@ func TestParseMediaTextUnknownRelaysIgnored(t *testing.T) {

func TestSanitiseMediaFilename(t *testing.T) {
cases := map[string]string{
"": "",
"report.zip": "report.zip",
"path/to/report.zip": "report.zip",
"..": "",
"a:b\nc.txt": "abc.txt",
"hello": "hello",
"WeIrD-Name_v2.tar.gz": "WeIrD-Name_v2.tar.gz",
"\xff\xfe.txt": "media.txt",
"\u062d\u0645\u0644\u0647.zip": "media.zip",
"": "",
"report.zip": "report.zip",
"path/to/report.zip": "report.zip",
"..": "",
"a:b\nc.txt": "abc.txt",
"hello": "hello",
"WeIrD-Name_v2.tar.gz": "WeIrD-Name_v2.tar.gz",
"\xff\xfe.txt": "media.txt",
"\u062d\u0645\u0644\u0647.zip": "حمله.zip",
"یه نام تست.mp3": "یه نام تست.mp3",
}
for in, want := range cases {
if got := SanitiseMediaFilename(in); got != want {
Expand Down
5 changes: 4 additions & 1 deletion internal/server/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,10 @@ func loadChannelsFromFile(path string) ([]string, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
channels = append(channels, strings.TrimPrefix(line, "@"))
parts := strings.Fields(line)
if len(parts) > 0 {
channels = append(channels, strings.TrimPrefix(parts[0], "@"))
}
}
return channels, scanner.Err()
}
Expand Down
180 changes: 164 additions & 16 deletions internal/server/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import (
"fmt"
"hash/crc32"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
Expand All @@ -25,10 +29,11 @@ import (
// The cache is safe for concurrent use. Hot-path operations (Store, GetBlock)
// are O(log n) at worst and typically O(1) with the help of two side maps.
type MediaCache struct {
maxFileBytes int64
ttl time.Duration
compression protocol.MediaCompression
dnsEnabled bool // when false, RelayDNS stays unset on the wire
maxFileBytes int64
maxAudioBytes int64
ttl time.Duration
compression protocol.MediaCompression
dnsEnabled bool // when false, RelayDNS stays unset on the wire

logf func(format string, args ...interface{})

Expand Down Expand Up @@ -70,6 +75,7 @@ type mediaEntry struct {
// MediaCacheConfig configures a new MediaCache.
type MediaCacheConfig struct {
MaxFileBytes int64
MaxAudioBytes int64
TTL time.Duration
Compression protocol.MediaCompression
Logf func(format string, args ...interface{})
Expand All @@ -93,11 +99,12 @@ func NewMediaCache(cfg MediaCacheConfig) *MediaCache {
logf = func(string, ...interface{}) {}
}
return &MediaCache{
maxFileBytes: cfg.MaxFileBytes,
ttl: cfg.TTL,
compression: cfg.Compression,
dnsEnabled: cfg.DNSRelayEnabled,
logf: logf,
maxFileBytes: cfg.MaxFileBytes,
maxAudioBytes: cfg.MaxAudioBytes,
ttl: cfg.TTL,
compression: cfg.Compression,
dnsEnabled: cfg.DNSRelayEnabled,
logf: logf,
byKey: make(map[string]*mediaEntry),
byChannel: make(map[uint16]*mediaEntry),
byHash: make(map[uint32]*mediaEntry),
Expand Down Expand Up @@ -127,7 +134,9 @@ func (c *MediaCache) Store(cacheKey, tag string, content []byte, mimeType, filen
// upload. SkipGitHub keeps the DNS allocation but skips the upload —
// used when many small siblings share one bundled GitHub upload.
type MediaCacheStoreOptions struct {
SkipGitHub bool
SkipGitHub bool
MaxFileBytesOverride *int64
MaxAudioBytesOverride *int64
}

// StoreWithOptions is Store with selective relay control.
Expand All @@ -138,16 +147,65 @@ func (c *MediaCache) StoreWithOptions(cacheKey, tag string, content []byte, mime
if tag == "" {
tag = protocol.MediaFile
}
if tag == protocol.MediaAudio && os.Getenv("THEFEED_OPUS_TRANSCODE") == "1" {
if transcoded, err := c.transcodeToOpus(content); err == nil {
content = transcoded
mimeType = "audio/ogg"
filename = changeExtensionToOpus(filename)
} else {
c.logf("media: ffmpeg transcoding failed, using original: %v", err)
}
}
size := int64(len(content))
if max := c.MaxAcceptableBytes(); max > 0 && size > max {

dnsLimit := c.maxFileBytes
if opts.MaxFileBytesOverride != nil && *opts.MaxFileBytesOverride != -1 {
dnsLimit = *opts.MaxFileBytesOverride
}
audioLimit := c.maxAudioBytes
if opts.MaxAudioBytesOverride != nil && *opts.MaxAudioBytesOverride != -1 {
audioLimit = *opts.MaxAudioBytesOverride
}

dns := dnsLimit
if audioLimit > 0 && isAudioOrVoice(tag, mimeType) {
dns = audioLimit
}

var ghMax int64
c.mu.RLock()
gh := c.gh
c.mu.RUnlock()
if gh != nil {
ghMax = gh.MaxBytes()
}

var max int64
if (dns == 0 && c.dnsEnabled) || (gh != nil && ghMax == 0) {
max = 0
} else if !c.dnsEnabled {
max = ghMax
} else if gh == nil {
max = dns
} else if ghMax > dns {
max = ghMax
} else {
max = dns
}

if max > 0 && size > max {
atomic.AddUint64(&c.storeRejected, 1)
return protocol.MediaMeta{
Tag: tag,
Size: size,
Relays: nil,
}, ErrTooLarge
}
dnsFits := c.maxFileBytes == 0 || size <= c.maxFileBytes

if audioLimit > 0 && isAudioOrVoice(tag, mimeType) {
dnsLimit = audioLimit
}
dnsFits := dnsLimit == 0 || size <= dnsLimit

now := time.Now()
hash := crc32.ChecksumIEEE(content)
Expand Down Expand Up @@ -538,17 +596,24 @@ func (c *MediaCache) TouchRelayEntries() {
}
}

// MaxAcceptableBytes returns the largest file size any enabled relay would
// accept. Callers use it as the "should we even fetch this?" gate so that
// files which fit GitHub but not DNS still get pulled. 0 means "no cap".
func (c *MediaCache) MaxAcceptableBytes() int64 {
// isAudioOrVoice returns true if the given tag or mimeType indicates an audio or voice file.
func isAudioOrVoice(tag, mimeType string) bool {
return tag == protocol.MediaAudio || strings.HasPrefix(strings.ToLower(mimeType), "audio/")
}

// MaxAcceptableBytesFor returns the largest file size any enabled relay would
// accept for a specific tag and mimeType. 0 means "no cap".
func (c *MediaCache) MaxAcceptableBytesFor(tag, mimeType string) int64 {
if c == nil {
return 0
}
c.mu.RLock()
gh := c.gh
c.mu.RUnlock()
dns := c.maxFileBytes
if c.maxAudioBytes > 0 && isAudioOrVoice(tag, mimeType) {
dns = c.maxAudioBytes
}
var ghMax int64
if gh != nil {
ghMax = gh.MaxBytes()
Expand All @@ -569,6 +634,13 @@ func (c *MediaCache) MaxAcceptableBytes() int64 {
return dns
}

// MaxAcceptableBytes returns the largest file size any enabled relay would
// accept. Callers use it as the "should we even fetch this?" gate so that
// files which fit GitHub but not DNS still get pulled. 0 means "no cap".
func (c *MediaCache) MaxAcceptableBytes() int64 {
return c.MaxAcceptableBytesFor("", "")
}

// splitMediaBlocks compresses the content (when compression != none),
// prepends the protocol media header, then splits the result into
// randomly-sized blocks. The CRC32 carried in the header is over the
Expand Down Expand Up @@ -641,3 +713,79 @@ func DecompressMediaBytes(r io.Reader, compression protocol.MediaCompression) (i
}
return nil, fmt.Errorf("unsupported media compression: %d", compression)
}

func (c *MediaCache) transcodeToOpus(content []byte) ([]byte, error) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, fmt.Errorf("ffmpeg not found: %w", err)
}

// Write original audio to a temp file.
inTemp, err := os.CreateTemp("", "feed_audio_in_*")
if err != nil {
return nil, fmt.Errorf("failed to create input temp file: %w", err)
}
inPath := inTemp.Name()

if _, err := inTemp.Write(content); err != nil {
inTemp.Close()
os.Remove(inPath)
return nil, fmt.Errorf("failed to write input temp file: %w", err)
}
inTemp.Close()
// content slice is no longer needed by this function — the caller
// will replace it with the transcoded bytes, allowing GC to reclaim
// the original audio memory.

// Prepare output temp file path.
outTemp, err := os.CreateTemp("", "feed_audio_out_*.opus")
if err != nil {
os.Remove(inPath)
return nil, fmt.Errorf("failed to create output temp file: %w", err)
}
outPath := outTemp.Name()
outTemp.Close()

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

// Try libopus first (higher quality), fall back to native opus encoder.
cmd := exec.CommandContext(ctx, ffmpegPath, "-y", "-i", inPath, "-vn", "-c:a", "libopus", "-b:a", "16k", "-ac", "1", "-ar", "16000", "-vbr", "on", outPath)
output, err := cmd.CombinedOutput()
if err != nil {
cmdFallback := exec.CommandContext(ctx, ffmpegPath, "-y", "-i", inPath, "-vn", "-c:a", "opus", "-b:a", "16k", "-ac", "1", "-ar", "16000", "-vbr", "on", outPath)
outputFallback, errFallback := cmdFallback.CombinedOutput()
if errFallback != nil {
os.Remove(inPath)
os.Remove(outPath)
return nil, fmt.Errorf("ffmpeg transcoding failed: %v (libopus output: %s) (opus output: %s)", errFallback, string(output), string(outputFallback))
}
}

// Eagerly remove input file — frees disk space immediately.
os.Remove(inPath)

opusBytes, err := os.ReadFile(outPath)
// Eagerly remove output file — frees disk space immediately.
os.Remove(outPath)
if err != nil {
return nil, fmt.Errorf("failed to read output temp file: %w", err)
}

if len(opusBytes) == 0 {
return nil, fmt.Errorf("transcoded file is empty")
}

return opusBytes, nil
}

func changeExtensionToOpus(filename string) string {
if filename == "" {
return "audio.opus"
}
ext := filepath.Ext(filename)
if ext == "" {
return filename + ".opus"
}
return strings.TrimSuffix(filename, ext) + ".opus"
}
12 changes: 10 additions & 2 deletions internal/server/media_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ func downloadHTTPMedia(ctx context.Context, cache *MediaCache, tag, rawURL strin
return protocol.MediaMeta{}, false
}

maxBytes := cache.MaxAcceptableBytes()
maxBytes := cache.MaxAcceptableBytesFor(tag, ctype)
if overrideMax, ok := GetMaxBytesFromContext(ctx, tag, ctype, cache); ok {
maxBytes = overrideMax
}
if maxBytes > 0 && resp.ContentLength > 0 && resp.ContentLength > maxBytes {
size := resp.ContentLength
return protocol.MediaMeta{
Expand Down Expand Up @@ -121,7 +124,12 @@ func downloadHTTPMedia(ctx context.Context, cache *MediaCache, tag, rawURL strin
}, true
}

meta, err := cache.Store(cacheKey, tag, bytes, resp.Header.Get("Content-Type"), urlBaseName(parsed))
var storeOpts MediaCacheStoreOptions
if maxFile, maxAudio, ok := GetContextLimits(ctx); ok {
storeOpts.MaxFileBytesOverride = &maxFile
storeOpts.MaxAudioBytesOverride = &maxAudio
}
meta, err := cache.StoreWithOptions(cacheKey, tag, bytes, resp.Header.Get("Content-Type"), urlBaseName(parsed), storeOpts)
if err != nil {
if errors.Is(err, ErrTooLarge) {
return meta, true
Expand Down
Loading