diff --git a/.env.example b/.env.example
index 4b67b4e..e2fb543 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,9 @@ THEFEED_KEY=your-secret-passphrase
# Max messages per channel (default: 15)
#THEFEED_MSG_LIMIT=15
+# Fetch cycle interval in minutes (min 3, default 10)
+#THEFEED_FETCH_INTERVAL=10
+
# Allow remote channel management via DNS (default: disabled)
#THEFEED_ALLOW_MANAGE=0
@@ -38,3 +41,21 @@ THEFEED_KEY=your-secret-passphrase
# Log every decoded DNS query (default: disabled)
#THEFEED_DEBUG=0
+
+# ----- Media Relays -----
+# DNS relay (slow, works in censored networks). Default: off.
+#THEFEED_DNS_MEDIA_ENABLED=1
+#THEFEED_DNS_MEDIA_MAX_SIZE_KB=100
+#THEFEED_DNS_MEDIA_CACHE_TTL_MIN=600
+#THEFEED_DNS_MEDIA_COMPRESSION=gzip
+
+# GitHub relay (fast, plain HTTPS). Default: off.
+# Files are AES-256-GCM encrypted; folder + object names are HMAC'd
+# with THEFEED_KEY so the public repo can't be linked back to your
+# deployment. Needs a personal-access-token with contents:write.
+#THEFEED_GITHUB_RELAY_ENABLED=1
+#THEFEED_GITHUB_RELAY_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+#THEFEED_GITHUB_RELAY_REPO=owner/repo
+#THEFEED_GITHUB_RELAY_BRANCH=main
+#THEFEED_GITHUB_RELAY_MAX_SIZE_KB=15360
+#THEFEED_GITHUB_RELAY_TTL_MIN=600
diff --git a/android/app/src/main/java/com/thefeed/android/AndroidBridge.kt b/android/app/src/main/java/com/thefeed/android/AndroidBridge.kt
index 05757a7..b25a9d2 100644
--- a/android/app/src/main/java/com/thefeed/android/AndroidBridge.kt
+++ b/android/app/src/main/java/com/thefeed/android/AndroidBridge.kt
@@ -77,13 +77,27 @@ class AndroidBridge(private val activity: Activity) {
fun openMedia(base64: String, mime: String, filename: String): Boolean {
return try {
val uri = writeToCache(base64, filename)
+ val resolvedMime = mimeFromFilename(filename, mime)
val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(uri, sanitiseMime(mime))
+ setDataAndType(uri, resolvedMime)
+ putExtra(Intent.EXTRA_TITLE, filename)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
- activity.startActivity(Intent.createChooser(intent, filename))
+ // Try the user's default for this MIME first. Android only
+ // shows a chooser if no default is set; that's the right
+ // behaviour for "play" — we want the actual video player,
+ // not a generic file-handler picker that might copy/download.
+ try {
+ activity.startActivity(intent)
+ } catch (_: Exception) {
+ activity.startActivity(Intent.createChooser(intent, filename))
+ }
true
- } catch (_: Exception) { false }
+ } catch (e: Exception) {
+ Log.e(TAG, "openMedia failed", e)
+ toast("Open failed: ${e.message}")
+ false
+ }
}
@JavascriptInterface
@@ -91,7 +105,7 @@ class AndroidBridge(private val activity: Activity) {
return try {
val uri = writeToCache(base64, filename)
val intent = Intent(Intent.ACTION_SEND).apply {
- type = sanitiseMime(mime)
+ type = mimeFromFilename(filename, mime)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -111,7 +125,7 @@ class AndroidBridge(private val activity: Activity) {
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, safe)
- put(MediaStore.MediaColumns.MIME_TYPE, sanitiseMime(mime))
+ put(MediaStore.MediaColumns.MIME_TYPE, mimeFromFilename(safe, mime))
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
@@ -172,6 +186,45 @@ class AndroidBridge(private val activity: Activity) {
return if (m.matches(Regex("^[\\w./+-]+$"))) m else "application/octet-stream"
}
+ // Derive a MIME type from the filename's extension. When the bytes
+ // were sniffed by Go's http.DetectContentType (e.g., APK files come
+ // back as application/zip because APKs ARE zips), passing that MIME
+ // to MediaStore would make Android append ".zip" to the filename.
+ // Trusting the extension fixes the *.apk.zip / *.docx.zip surprise.
+ private fun mimeFromFilename(name: String, fallback: String): String {
+ val dot = name.lastIndexOf('.')
+ if (dot < 0 || dot == name.length - 1) return sanitiseMime(fallback)
+ val ext = name.substring(dot + 1).lowercase()
+ return when (ext) {
+ "apk" -> "application/vnd.android.package-archive"
+ "pdf" -> "application/pdf"
+ "zip" -> "application/zip"
+ "mp3" -> "audio/mpeg"
+ "ogg", "oga", "opus" -> "audio/ogg"
+ "wav" -> "audio/wav"
+ "m4a" -> "audio/mp4"
+ "mp4", "m4v" -> "video/mp4"
+ "webm" -> "video/webm"
+ "mkv" -> "video/x-matroska"
+ "mov" -> "video/quicktime"
+ "jpg", "jpeg" -> "image/jpeg"
+ "png" -> "image/png"
+ "gif" -> "image/gif"
+ "webp" -> "image/webp"
+ "svg" -> "image/svg+xml"
+ "txt" -> "text/plain"
+ "html", "htm" -> "text/html"
+ "json" -> "application/json"
+ "doc" -> "application/msword"
+ "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ "xls" -> "application/vnd.ms-excel"
+ "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ "ppt" -> "application/vnd.ms-powerpoint"
+ "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
+ else -> sanitiseMime(fallback)
+ }
+ }
+
private fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
diff --git a/internal/protocol/media.go b/internal/protocol/media.go
index 6cf4f40..6f7e54f 100644
--- a/internal/protocol/media.go
+++ b/internal/protocol/media.go
@@ -123,7 +123,7 @@ func SanitiseMediaFilename(s string) string {
return ""
}
- const maxBase = 24
+ const maxBase = 64
const maxExt = 8
base, ext := splitFilenameExt(cleaned)
diff --git a/internal/server/dns.go b/internal/server/dns.go
index 98eb316..83d73a3 100644
--- a/internal/server/dns.go
+++ b/internal/server/dns.go
@@ -51,6 +51,7 @@ type channelFetchStats struct {
type reportEvent struct {
channel uint16
resolver string
+ invalid bool // GetBlock failed for this query
}
type hourlyFetchReport struct {
@@ -59,6 +60,7 @@ type hourlyFetchReport struct {
metadataQueries int64
versionQueries int64
mediaQueries int64 // queries that landed in the media-blob channel range
+ invalidQueries int64 // GetBlock returned an error (unknown ch / blk OOR)
perChannel map[uint16]*channelFetchStats
perResolver map[string]int64
}
@@ -197,7 +199,11 @@ func (s *DNSServer) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
data, err := s.feed.GetBlock(int(channel), int(block))
if err != nil {
- log.Printf("[dns] get block ch=%d blk=%d: %v", channel, block, err)
+ // Don't log: there's a known protocol limitation where the metadata
+ // channel doesn't carry a total-block count, so clients sometimes
+ // over-fetch and ask for blocks/channels that don't exist. Counter
+ // in the hourly report is enough.
+ s.trackInvalidQuery()
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
return
@@ -656,6 +662,13 @@ func (s *DNSServer) trackRequestStart(channel uint16, resolver string) {
s.reportCh <- reportEvent{channel: channel, resolver: resolver}
}
+func (s *DNSServer) trackInvalidQuery() {
+ select {
+ case s.reportCh <- reportEvent{invalid: true}:
+ default:
+ }
+}
+
func (s *DNSServer) runHourlyReports(ctx context.Context) {
rep := newHourlyFetchReport(time.Now())
ticker := time.NewTicker(1 * time.Hour)
@@ -684,6 +697,10 @@ func newHourlyFetchReport(start time.Time) *hourlyFetchReport {
}
func recordReportQuery(rep *hourlyFetchReport, event reportEvent) {
+ if event.invalid {
+ rep.invalidQueries++
+ return
+ }
rep.totalQueries++
if event.resolver != "" {
rep.perResolver[event.resolver]++
@@ -778,6 +795,7 @@ func (s *DNSServer) emitHourlyReport(rep *hourlyFetchReport, final bool) {
"totalMetadataQueries": rep.metadataQueries,
"totalVersionQueries": rep.versionQueries,
"totalMediaQueries": rep.mediaQueries,
+ "totalInvalidQueries": rep.invalidQueries,
"channels": entries,
"topResolvers": resolvers,
"finalFlush": final,
diff --git a/internal/server/dns_report_test.go b/internal/server/dns_report_test.go
new file mode 100644
index 0000000..136fc89
--- /dev/null
+++ b/internal/server/dns_report_test.go
@@ -0,0 +1,143 @@
+package server
+
+import (
+ "testing"
+ "time"
+
+ "github.com/sartoopjj/thefeed/internal/protocol"
+)
+
+// recordReportQuery routes events to the right counter on hourlyFetchReport.
+// Each subtest exercises one branch.
+func TestRecordReportQuery(t *testing.T) {
+ cases := []struct {
+ name string
+ event reportEvent
+ check func(t *testing.T, rep *hourlyFetchReport)
+ }{
+ {
+ name: "invalid increments only invalidQueries",
+ event: reportEvent{invalid: true, channel: 999, resolver: "1.1.1.1:53"},
+ check: func(t *testing.T, rep *hourlyFetchReport) {
+ if rep.invalidQueries != 1 {
+ t.Errorf("invalidQueries = %d, want 1", rep.invalidQueries)
+ }
+ if rep.totalQueries != 0 {
+ t.Errorf("totalQueries = %d, want 0 (invalid must not count toward total)", rep.totalQueries)
+ }
+ if len(rep.perResolver) != 0 {
+ t.Errorf("perResolver populated for invalid event: %v", rep.perResolver)
+ }
+ if rep.metadataQueries != 0 || rep.mediaQueries != 0 || rep.versionQueries != 0 {
+ t.Errorf("invalid event leaked into another counter: %+v", rep)
+ }
+ },
+ },
+ {
+ name: "metadata channel",
+ event: reportEvent{channel: protocol.MetadataChannel, resolver: "1.1.1.1:53"},
+ check: func(t *testing.T, rep *hourlyFetchReport) {
+ if rep.totalQueries != 1 {
+ t.Errorf("totalQueries = %d, want 1", rep.totalQueries)
+ }
+ if rep.metadataQueries != 1 {
+ t.Errorf("metadataQueries = %d, want 1", rep.metadataQueries)
+ }
+ if rep.invalidQueries != 0 {
+ t.Errorf("invalidQueries = %d, want 0", rep.invalidQueries)
+ }
+ if rep.perResolver["1.1.1.1:53"] != 1 {
+ t.Errorf("perResolver missing entry: %v", rep.perResolver)
+ }
+ },
+ },
+ {
+ name: "version channel",
+ event: reportEvent{channel: protocol.VersionChannel},
+ check: func(t *testing.T, rep *hourlyFetchReport) {
+ if rep.versionQueries != 1 {
+ t.Errorf("versionQueries = %d, want 1", rep.versionQueries)
+ }
+ },
+ },
+ {
+ name: "media channel",
+ event: reportEvent{channel: protocol.MediaChannelStart + 5},
+ check: func(t *testing.T, rep *hourlyFetchReport) {
+ if rep.mediaQueries != 1 {
+ t.Errorf("mediaQueries = %d, want 1", rep.mediaQueries)
+ }
+ if len(rep.perChannel) != 0 {
+ t.Errorf("media events should not populate perChannel: %v", rep.perChannel)
+ }
+ },
+ },
+ {
+ name: "regular content channel",
+ event: reportEvent{channel: 5},
+ check: func(t *testing.T, rep *hourlyFetchReport) {
+ if rep.totalQueries != 1 {
+ t.Errorf("totalQueries = %d, want 1", rep.totalQueries)
+ }
+ stats := rep.perChannel[5]
+ if stats == nil || stats.Queries != 1 {
+ t.Errorf("perChannel[5] = %+v, want Queries=1", stats)
+ }
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rep := newHourlyFetchReport(time.Now())
+ recordReportQuery(rep, tc.event)
+ tc.check(t, rep)
+ })
+ }
+}
+
+// Many invalid events shouldn't bleed into other counters. This is the
+// common-case failure mode the counter exists to track (clients
+// over-fetching past the last block of a channel).
+func TestRecordReportQueryInvalidVolume(t *testing.T) {
+ rep := newHourlyFetchReport(time.Now())
+ for i := 0; i < 1000; i++ {
+ recordReportQuery(rep, reportEvent{invalid: true, channel: uint16(i % 65535)})
+ }
+ if rep.invalidQueries != 1000 {
+ t.Errorf("invalidQueries = %d, want 1000", rep.invalidQueries)
+ }
+ if rep.totalQueries != 0 {
+ t.Errorf("totalQueries = %d, want 0 — invalid must never inflate the legit-query total", rep.totalQueries)
+ }
+ if len(rep.perChannel) != 0 || len(rep.perResolver) != 0 {
+ t.Errorf("invalid events polluted perChannel or perResolver maps: ch=%d res=%d",
+ len(rep.perChannel), len(rep.perResolver))
+ }
+}
+
+// Mixed valid and invalid events must each hit only their own counter.
+func TestRecordReportQueryMixed(t *testing.T) {
+ rep := newHourlyFetchReport(time.Now())
+ recordReportQuery(rep, reportEvent{channel: protocol.MetadataChannel, resolver: "1.1.1.1:53"})
+ recordReportQuery(rep, reportEvent{channel: 3, resolver: "1.1.1.1:53"})
+ recordReportQuery(rep, reportEvent{channel: protocol.MediaChannelStart + 1})
+ recordReportQuery(rep, reportEvent{invalid: true})
+ recordReportQuery(rep, reportEvent{invalid: true})
+
+ if rep.totalQueries != 3 {
+ t.Errorf("totalQueries = %d, want 3 (3 valid events)", rep.totalQueries)
+ }
+ if rep.invalidQueries != 2 {
+ t.Errorf("invalidQueries = %d, want 2", rep.invalidQueries)
+ }
+ if rep.metadataQueries != 1 {
+ t.Errorf("metadataQueries = %d, want 1", rep.metadataQueries)
+ }
+ if rep.mediaQueries != 1 {
+ t.Errorf("mediaQueries = %d, want 1", rep.mediaQueries)
+ }
+ if rep.perChannel[3] == nil || rep.perChannel[3].Queries != 1 {
+ t.Errorf("perChannel[3] missing or wrong: %+v", rep.perChannel[3])
+ }
+}
diff --git a/internal/server/github_relay.go b/internal/server/github_relay.go
index 53f5a79..858b907 100644
--- a/internal/server/github_relay.go
+++ b/internal/server/github_relay.go
@@ -523,7 +523,7 @@ func (g *GitHubRelay) getRef(ctx context.Context, branch string) (string, error)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- body, _ := io.ReadAll(resp.Body)
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
bodyStr := string(body)
// Detect "empty repo" by status + body message together. Don't
// trust status alone — GitHub uses 404 for missing branch,
@@ -537,7 +537,7 @@ func (g *GitHubRelay) getRef(ctx context.Context, branch string) (string, error)
if resp.StatusCode == http.StatusNotFound {
return g.bootstrapEmptyRepo(ctx, branch)
}
- return "", fmt.Errorf("%s — %s", resp.Status, bodyStr)
+ return "", fmt.Errorf("%s — %s", resp.Status, trimErrBody(bodyStr))
}
var out struct {
Object struct {
@@ -602,8 +602,7 @@ func (g *GitHubRelay) getCommitTree(ctx context.Context, commitSHA string) (stri
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- body, _ := io.ReadAll(resp.Body)
- return "", fmt.Errorf("%s — %s", resp.Status, string(body))
+ return "", fmt.Errorf("%s — %s", resp.Status, ghErrorBody(resp))
}
var out struct {
Tree struct {
@@ -632,8 +631,7 @@ func (g *GitHubRelay) createBlob(ctx context.Context, content []byte) (string, e
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(resp.Body)
- return "", fmt.Errorf("%s — %s", resp.Status, string(raw))
+ return "", fmt.Errorf("%s — %s", resp.Status, ghErrorBody(resp))
}
var out struct {
SHA string `json:"sha"`
@@ -661,8 +659,7 @@ func (g *GitHubRelay) createTree(ctx context.Context, baseTree string, entries a
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(resp.Body)
- return "", fmt.Errorf("%s — %s", resp.Status, string(raw))
+ return "", fmt.Errorf("%s — %s", resp.Status, ghErrorBody(resp))
}
var out struct {
SHA string `json:"sha"`
@@ -693,8 +690,7 @@ func (g *GitHubRelay) createCommit(ctx context.Context, message, treeSHA string,
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(resp.Body)
- return "", fmt.Errorf("%s — %s", resp.Status, string(raw))
+ return "", fmt.Errorf("%s — %s", resp.Status, ghErrorBody(resp))
}
var out struct {
SHA string `json:"sha"`
@@ -721,14 +717,34 @@ func (g *GitHubRelay) updateRef(ctx context.Context, branch, commitSHA string) e
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
- raw, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("%s — %s", resp.Status, string(raw))
+ return fmt.Errorf("%s — %s", resp.Status, ghErrorBody(resp))
}
return nil
}
// --- HTTP plumbing ----------------------------------------------------------
+// ghErrorBody reads a short, log-safe error body from a non-2xx GitHub
+// response. GitHub's 5xx pages ("Unicorn") are full HTML documents — we
+// don't want them in the log. Truncate to 200 chars and replace HTML
+// blobs with a one-line summary.
+func ghErrorBody(resp *http.Response) string {
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return trimErrBody(string(raw))
+}
+
+func trimErrBody(s string) string {
+ s = strings.TrimSpace(s)
+ lower := strings.ToLower(s)
+ if strings.HasPrefix(lower, " 200 {
+ s = s[:200] + "…"
+ }
+ return s
+}
+
func (g *GitHubRelay) newReq(ctx context.Context, method, urlPath string, body io.Reader) (*http.Request, error) {
full := strings.TrimRight(githubAPI, "/") + urlPath
req, err := http.NewRequestWithContext(ctx, method, full, body)
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index dd2a854..b0b5ee5 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -843,6 +843,17 @@
user-select: none
}
+ .media-lightbox-video {
+ max-width: 100%;
+ max-height: 100%;
+ outline: none;
+ }
+
+ .media-lightbox-audio {
+ width: min(420px, 90vw);
+ outline: none;
+ }
+
.media-lightbox-close {
position: absolute;
top: 12px;
@@ -2548,7 +2559,7 @@
server_fetch_wait: 'سرور در حال دریافت اطلاعات جدید از تلگرام',
no_messages_hint: 'برنامه در حال دریافت پیامها است. لطفاً چند لحظه صبر کنید...',
download: 'دانلود', media_too_large: 'حجم فایل بیش از حد مجاز سرور است', media_failed: 'دانلود ناموفق بود',
- downloading: 'در حال دانلود...', media_open: 'باز کردن', media_save: 'ذخیره', media_share: 'اشتراکگذاری', close: 'بستن',
+ downloading: 'در حال دانلود...', media_open: 'باز کردن', media_play: 'پخش', media_save: 'ذخیره', media_share: 'اشتراکگذاری', close: 'بستن',
queued: 'در صف', media_hash_mismatch: 'هش محتوا با پیام مطابقت ندارد',
blocks_label: 'بلاک',
media_blocked_title: 'برای دانلود در دسترس نیست',
@@ -2593,7 +2604,7 @@
auto_update_off: 'بهروزرسانی خودکار برای @{name} خاموش شد',
media_relay_fallback: 'دانلود از مسیر سریع نشد. از مسیر کند (DNS) امتحان کنیم؟ توجه: ممکن است خیلی کند باشد.',
media_slow_only: 'رله گیتهاب در دسترس نیست. دانلود از مسیر DNS خیلی کند است. ادامه میدهی؟',
- media_rate_limited: 'به محدودیت تعداد درخواست گیتهاب رسیدی. حدود {n} دقیقه دیگه دوباره فعال میشه. حالا از مسیر کند (DNS) دانلود کنیم؟',
+ media_rate_limited: 'محدودیت گیتهاب پر شد ({n} دقیقه تا ریست). از مسیر DNS ادامه میدهیم.',
media_rate_limited_short: 'محدودیت تعداد درخواست گیتهاب',
media_size_mismatch: 'سایز فایل با چیزی که سرور گفته بود نمیخونه',
clear_cache: 'پاک کردن کش', clear_cache_btn: '🗑 پاک کردن کش', cache_cleared: 'کش پاک شد!',
@@ -2717,7 +2728,7 @@
server_fetch_wait: 'Server fetching fresh data from Telegram',
no_messages_hint: 'The app is trying to fetch messages. Please wait a moment...',
download: 'Download', media_too_large: 'Too large for server cache', media_failed: 'Download failed',
- downloading: 'Downloading...', media_open: 'Open', media_save: 'Save', media_share: 'Share', close: 'Close',
+ downloading: 'Downloading...', media_open: 'Open', media_play: 'Play', media_save: 'Save', media_share: 'Share', close: 'Close',
queued: 'Queued', media_hash_mismatch: 'Content hash does not match the message',
blocks_label: 'blocks',
media_blocked_title: 'Not available for download',
@@ -2762,7 +2773,7 @@
auto_update_off: 'Auto-update is now OFF for @{name}',
media_relay_fallback: 'Fast relay failed. Try the slow DNS path? Note: this can be very slow.',
media_slow_only: 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
- media_rate_limited: 'GitHub rate limit hit. Resets in about {n} min. Try the slow DNS path now?',
+ media_rate_limited: 'GitHub rate limit hit ({n} min until reset). Falling back to slow DNS path.',
media_rate_limited_short: 'GitHub rate limit',
media_size_mismatch: 'Downloaded size doesn\'t match the manifest',
clear_cache: 'Clear Cache', clear_cache_btn: '🗑 Clear Cache', cache_cleared: 'Cache cleared!',
@@ -3355,7 +3366,9 @@
await selectChannel(1); return
}
if (data && typeof data === 'object' && data.type === 'no_changes') {
- showToast(t('no_new_messages'));
+ // Only show the toast if the user is still viewing the channel
+ // that reported no changes — silently drop it if they switched.
+ if (data.channel === selectedChannel) showToast(t('no_new_messages'));
if (snapChannel > 0) { delete refreshingChannels[snapChannel]; var fb = document.getElementById('prog-fetch-ch-' + snapChannel); if (fb) fb.remove() }
} else if (data && typeof data === 'object' && data.channel) {
delete refreshingChannels[data.channel]; var fb2 = document.getElementById('prog-fetch-ch-' + data.channel); if (fb2) fb2.remove();
@@ -4258,6 +4271,10 @@
return tag === '[IMAGE]' || tag === '[STICKER]' || tag === '[GIF]';
}
+ function mediaIsPlayableTag(tag) {
+ return tag === '[VIDEO]' || tag === '[AUDIO]' || tag === '[VOICE]';
+ }
+
function mediaExtForTag(tag, mime) {
if (mime) {
if (/^image\/(jpeg|jpg)$/i.test(mime)) return 'jpg';
@@ -4425,9 +4442,9 @@
}
}
- // GH_MAX_RETRIES + GH_RETRY_DELAY_MS govern the fast-path retry budget
- // before we ask the user whether to fall back to slow DNS.
- var GH_MAX_RETRIES = 3, GH_RETRY_DELAY_MS = 800;
+ // Single quick retry on transient errors (network blip, 5xx). 404 / 429
+ // skip retry entirely — file genuinely missing or rate-limited.
+ var GH_MAX_RETRIES = 2, GH_RETRY_DELAY_MS = 500;
async function mediaRunDownload(domID, opts) {
opts = opts || {};
@@ -4521,18 +4538,17 @@
await handleRateLimit(resetMin);
return;
}
- if (source === 'fast') { handleFastFailure(xhr.statusText || ('HTTP ' + xhr.status)); return; }
+ if (source === 'fast') { handleFastFailure(xhr.statusText || ('HTTP ' + xhr.status), xhr.status); return; }
mediaShowError(card, xhr.statusText || ('HTTP ' + xhr.status));
}
}
async function handleRateLimit(resetMin) {
var minutes = (resetMin && resetMin > 0) ? resetMin : '?';
- var msg = (t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min. Try the slow DNS path?').replace('{n}', minutes);
+ var msg = (t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes);
+ showToast(msg);
if (dnsAvail) {
- var ok = await showConfirmDialog(msg, t('yes') || 'Yes', t('no') || 'No');
- if (ok) { restartWith('slow'); return; }
- } else {
- await showInfoDialog(msg, t('ok') || 'OK');
+ restartWith('slow');
+ return;
}
mediaShowError(card, t('media_rate_limited_short') || 'Rate limited');
finishSlot();
@@ -4556,15 +4572,17 @@
deliverBlob().finally(finishSlot);
}
};
- async function handleFastFailure(reason) {
+ async function handleFastFailure(reason, status) {
attempt++;
- if (attempt < GH_MAX_RETRIES) {
- // Quiet retry — leave the bar alone.
+ // 404 = file not in repo (won't materialise on retry).
+ // 429 = rate limit (already handled separately).
+ // Skipping retries here protects the 60/h API budget.
+ var skipRetry = (status === 404 || status === 429);
+ if (!skipRetry && attempt < GH_MAX_RETRIES) {
await new Promise(function (r) { setTimeout(r, GH_RETRY_DELAY_MS); });
var retry = new XMLHttpRequest();
retry.responseType = 'blob';
retry.open('GET', url);
- // Reuse the same handlers by swapping the underlying xhr.
retry.onprogress = xhr.onprogress;
retry.onload = xhr.onload;
retry.onerror = xhr.onerror;
@@ -4815,15 +4833,19 @@
function buildMediaActions(msgID, tag) {
var isImage = mediaIsImageTag(tag);
- // On Android the native bridge always handles share, so we always
- // show the share button there — unlike browser path which needs
- // navigator.canShare support.
+ var isPlayable = mediaIsPlayableTag(tag);
var canShare = !!androidBridge || !!(navigator.share && navigator.canShare);
var openTitle = esc(t('media_open') || 'Open');
+ var playTitle = esc(t('media_play') || 'Play');
var saveTitle = esc(t('media_save') || 'Save');
var shareTitle = esc(t('media_share') || 'Share');
var html = '';
- if (!isImage) {
+ if (isPlayable) {
+ // Play icon (▶) — on Android Intent.createChooser picks the
+ // best system video/audio player; on browser the blob opens in a
+ // new tab where the browser's native viewer handles it.
+ html += '';
+ } else if (!isImage) {
html += '';
}
html += '';
@@ -4850,6 +4872,13 @@
} catch (e) { }
return;
}
+ // Browser path: play video/audio inline in a lightbox instead of
+ // opening a blob URL in a new tab — some browsers refuse to render
+ // blob:video and download it as a hash-named file with no extension.
+ if (mediaIsPlayableTag(tag)) {
+ showMediaPlayer(entry, tag);
+ return;
+ }
try {
var a = document.createElement('a');
a.href = entry.url;
@@ -4862,6 +4891,36 @@
} catch (e) { }
}
+ function showMediaPlayer(entry, tag) {
+ var existing = document.getElementById('mediaLightbox');
+ if (existing) existing.remove();
+ var isAudio = (tag === '[AUDIO]' || tag === '[VOICE]');
+ var mime = entry.mime || (isAudio ? 'audio/mpeg' : 'video/mp4');
+ var element = isAudio
+ ? ''
+ : '';
+ var overlay = document.createElement('div');
+ overlay.id = 'mediaLightbox';
+ overlay.className = 'media-lightbox';
+ overlay.innerHTML = '' + element;
+ var close = function () {
+ overlay.removeEventListener('click', onClick);
+ document.removeEventListener('keydown', onKey);
+ var media = overlay.querySelector('audio,video');
+ if (media) { try { media.pause(); } catch (e) { } }
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ };
+ var onClick = function (e) {
+ if (e.target === overlay || (e.target.classList && e.target.classList.contains('media-lightbox-close'))) {
+ close();
+ }
+ };
+ var onKey = function (e) { if (e.key === 'Escape') close(); };
+ overlay.addEventListener('click', onClick);
+ document.addEventListener('keydown', onKey);
+ document.body.appendChild(overlay);
+ }
+
function mediaFilenameFor(msgID, tag, mime) {
var card = document.getElementById('media-' + msgID);
var fname = card ? (card.getAttribute('data-fname') || '') : '';
diff --git a/internal/web/web.go b/internal/web/web.go
index 4002013..44082ad 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -1970,6 +1970,9 @@ func (s *Server) loadProfiles() (*ProfileList, error) {
}
func (s *Server) saveProfiles(pl *ProfileList) error {
+ if err := os.MkdirAll(s.dataDir, 0700); err != nil {
+ return err
+ }
path := filepath.Join(s.dataDir, "profiles.json")
data, err := json.MarshalIndent(pl, "", " ")
if err != nil {