mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 04:14:36 +03:00
fix some bugs!
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 + '">▶</button>';
|
||||
} else if (!isImage) {
|
||||
html += '<button class="media-action-icon" onclick="mediaOpen(\'' + msgID + '\')" title="' + openTitle + '" aria-label="' + openTitle + '">🔗</button>';
|
||||
}
|
||||
html += '<button class="media-action-icon" onclick="mediaSave(\'' + msgID + '\')" title="' + saveTitle + '" aria-label="' + saveTitle + '">💾</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') + '">×</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') || '') : '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user