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 {