fix some bugs!

This commit is contained in:
Sarto
2026-05-01 21:42:37 +03:30
parent c603442d01
commit 68009f5d92
8 changed files with 354 additions and 41 deletions
+21
View File
@@ -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
@@ -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))
+1 -1
View File
@@ -123,7 +123,7 @@ func SanitiseMediaFilename(s string) string {
return ""
}
const maxBase = 24
const maxBase = 64
const maxExt = 8
base, ext := splitFilenameExt(cleaned)
+19 -1
View File
@@ -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,
+143
View File
@@ -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])
}
}
+28 -12
View File
@@ -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, "<!doctype") || strings.HasPrefix(lower, "<html") {
return "(HTML response — GitHub backend issue, retry later)"
}
if len(s) > 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)
+81 -22
View File
@@ -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 += '<button class="media-action-icon" onclick="mediaOpen(\'' + msgID + '\')" title="' + playTitle + '" aria-label="' + playTitle + '">&#9654;</button>';
} else if (!isImage) {
html += '<button class="media-action-icon" onclick="mediaOpen(\'' + msgID + '\')" title="' + openTitle + '" aria-label="' + openTitle + '">&#128279;</button>';
}
html += '<button class="media-action-icon" onclick="mediaSave(\'' + msgID + '\')" title="' + saveTitle + '" aria-label="' + saveTitle + '">&#128190;</button>';
@@ -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
? '<audio class="media-lightbox-audio" controls autoplay src="' + escAttr(entry.url) + '" type="' + escAttr(mime) + '"></audio>'
: '<video class="media-lightbox-video" controls autoplay playsinline src="' + escAttr(entry.url) + '" type="' + escAttr(mime) + '"></video>';
var overlay = document.createElement('div');
overlay.id = 'mediaLightbox';
overlay.className = 'media-lightbox';
overlay.innerHTML = '<button class="media-lightbox-close" type="button" aria-label="' + esc(t('close') || 'Close') + '">&times;</button>' + 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') || '') : '';
+3
View File
@@ -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 {