From e833de245aa411c3c6971d74ba78ff11edc9219d Mon Sep 17 00:00:00 2001 From: h Date: Thu, 13 Aug 2026 03:49:29 +0200 Subject: [PATCH] fix(api,frontend): render files by name without fetching, previewable share links --- backend/src/api/routers/files.py | 44 +++++++-- backend/src/api/routers/media.py | 6 +- backend/src/api/routers/shares.py | 17 +++- backend/src/utils/files.py | 97 +++++++++++++++++++ backend/src/utils/read/models.py | 1 + backend/src/utils/read/shares.py | 11 ++- frontend/src/lib/api/media.ts | 34 ++++++- frontend/src/lib/api/shares.ts | 2 +- frontend/src/lib/api/types.ts | 1 + .../src/lib/components/MediaViewer.svelte | 81 +++++++++++++--- .../src/lib/components/MessageMedia.svelte | 90 +++++++++++++++-- .../lib/components/profile/SharedMedia.svelte | 2 + .../lib/components/shares/ShareDialog.svelte | 7 +- 13 files changed, 348 insertions(+), 45 deletions(-) diff --git a/backend/src/api/routers/files.py b/backend/src/api/routers/files.py index 6825bf7..0035364 100644 --- a/backend/src/api/routers/files.py +++ b/backend/src/api/routers/files.py @@ -6,7 +6,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, Query, Request from fastapi.responses import FileResponse, PlainTextResponse -from utils.files import content_disposition, counts_as_download, is_inline_mime +from utils.files import ( + content_disposition, + counts_as_download, + is_inline_mime, + resolve_mime, +) from utils.read import shares from utils.storage import ContentAddressedStorage @@ -35,13 +40,13 @@ def _exhausted(row: asyncpg.Record) -> bool: return limit is not None and row["download_count"] >= limit -@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None) -async def serve_shared_file( +async def _serve( request: Request, - pool: FromDishka[asyncpg.Pool], - storage: FromDishka[ContentAddressedStorage], + pool: asyncpg.Pool, + storage: ContentAddressedStorage, token: str, - dl: Annotated[bool, Query()] = False, + *, + dl: bool, ) -> FileResponse | PlainTextResponse: row = await shares.share_by_token(pool, token) if row is None: @@ -64,8 +69,8 @@ async def serve_shared_file( return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE) await shares.record_hit(pool, row["id"], method, ip, agent, counted=counts) - mime = row["mime"] or "application/octet-stream" - attachment = dl or not is_inline_mime(row["mime"]) + mime = resolve_mime(row["kind"], row["mime"], row["file_name"]) + attachment = dl or not is_inline_mime(mime) return FileResponse( storage.url(row["storage_key"]), media_type=mime, @@ -76,3 +81,26 @@ async def serve_shared_file( ), }, ) + + +@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None) +async def serve_shared_file( + request: Request, + pool: FromDishka[asyncpg.Pool], + storage: FromDishka[ContentAddressedStorage], + token: str, + dl: Annotated[bool, Query()] = False, +) -> FileResponse | PlainTextResponse: + return await _serve(request, pool, storage, token, dl=dl) + + +@router.api_route("/f/{token}/{name}", methods=["GET", "HEAD"], response_model=None) +async def serve_shared_file_named( + request: Request, + pool: FromDishka[asyncpg.Pool], + storage: FromDishka[ContentAddressedStorage], + token: str, + name: str, # noqa: ARG001 + dl: Annotated[bool, Query()] = False, +) -> FileResponse | PlainTextResponse: + return await _serve(request, pool, storage, token, dl=dl) diff --git a/backend/src/api/routers/media.py b/backend/src/api/routers/media.py index bd6c2c3..38f8626 100644 --- a/backend/src/api/routers/media.py +++ b/backend/src/api/routers/media.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS -from utils.files import content_disposition, media_file_name +from utils.files import content_disposition, media_file_name, resolve_mime from utils.read.media import ( get_media, get_media_version, @@ -63,7 +63,7 @@ async def serve_media_version( ) return FileResponse( storage.url(version.storage_key), - media_type=version.mime or "application/octet-stream", + media_type=resolve_mime(version.kind, version.mime), headers=headers, ) @@ -101,6 +101,6 @@ async def serve_media( headers = _attachment(headers, media.file_name or f"media_{media_id}") return FileResponse( storage.url(media.storage_key), - media_type=media.mime or "application/octet-stream", + media_type=resolve_mime(media.kind, media.mime, media.file_name), headers=headers, ) diff --git a/backend/src/api/routers/shares.py b/backend/src/api/routers/shares.py index e15f6ea..e477756 100644 --- a/backend/src/api/routers/shares.py +++ b/backend/src/api/routers/shares.py @@ -5,7 +5,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel -from utils.files import expiry_from_seconds, media_file_name, story_file_name +from utils.files import ( + expiry_from_seconds, + media_file_name, + resolve_mime, + story_file_name, +) from utils.read import chats, peers, shares from utils.read.media import get_media, get_media_version from utils.read.models import DEFAULT_LIMIT, FileShareHitView, FileShareView, Page @@ -56,10 +61,11 @@ async def _media_subject(pool: asyncpg.Pool, media_id: int) -> Subject: raise HTTPException(status_code=404, detail="media not found") if not media.downloaded or media.storage_key is None: raise HTTPException(status_code=409, detail=_NOT_STORED) + file_name = media.file_name or f"media_{media.id}" return Subject( storage_key=media.storage_key, - file_name=media.file_name or f"media_{media.id}", - mime=media.mime, + file_name=file_name, + mime=resolve_mime(media.kind, media.mime, file_name), file_size=media.file_size, title=await _chat_title(pool, media.account_id, media.chat_id), chat_id=media.chat_id, @@ -71,10 +77,11 @@ async def _version_subject(pool: asyncpg.Pool, version_id: int) -> Subject: version = await get_media_version(pool, version_id) if version is None: raise HTTPException(status_code=404, detail="media version not found") + version_name = media_file_name(version.kind, version.mime, version_id) return Subject( storage_key=version.storage_key, - file_name=media_file_name(version.kind, version.mime, version_id), - mime=version.mime, + file_name=version_name, + mime=resolve_mime(version.kind, version.mime, version_name), file_size=version.file_size, title=None, chat_id=None, diff --git a/backend/src/utils/files.py b/backend/src/utils/files.py index 746561a..0859f4a 100644 --- a/backend/src/utils/files.py +++ b/backend/src/utils/files.py @@ -29,6 +29,42 @@ _KIND_EXTENSIONS = { "voice": ".ogg", } +_EXTENSION_MIMES = { + ".flac": "audio/flac", + ".gif": "image/gif", + ".heic": "image/heic", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".m4a": "audio/mp4", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".pdf": "application/pdf", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain", + ".wav": "audio/wav", + ".webm": "video/webm", + ".webp": "image/webp", +} + +_KIND_MIMES = { + "animation": "video/mp4", + "gif": "video/mp4", + "photo": "image/jpeg", + "video": "video/mp4", + "video_note": "video/mp4", + "voice": "audio/ogg", +} + +_GENERIC_MIMES = { + "application/octet-stream", + "application/binary", + "binary/octet-stream", + "", +} + _INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/") _INLINE_MIMES = {"application/pdf", "application/json"} @@ -57,6 +93,45 @@ _PREVIEW_AGENTS = ( "python-requests", ) +_TRANSLIT = { + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ё": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "y", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "ch", + "ш": "sh", + "щ": "sch", + "ъ": "", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", +} + +_URL_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") +_URL_REPEATS = re.compile(r"_{2,}") + _UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') _SPACES = re.compile(r"\s+") _NAME_LIMIT = 120 @@ -86,6 +161,16 @@ def sanitize_name(name: str) -> str: return cleaned or "file" +def url_slug(file_name: str) -> str: + lowered = "".join( + _TRANSLIT.get(ch, _TRANSLIT.get(ch.lower(), ch)) for ch in file_name + ) + slug = _URL_REPEATS.sub("_", _URL_UNSAFE.sub("_", lowered)).strip("_.") + if not slug: + return "file" + return slug[:_NAME_LIMIT] + + def media_file_name( kind: str | None, mime: str | None, message_id: int, original: str | None = None ) -> str: @@ -99,6 +184,18 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str: return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" +def resolve_mime( + kind: str | None, mime: str | None, file_name: str | None = None +) -> str: + if mime and mime.lower() not in _GENERIC_MIMES: + return mime + if file_name and "." in file_name: + by_extension = _EXTENSION_MIMES.get(f".{file_name.rsplit('.', 1)[1].lower()}") + if by_extension: + return by_extension + return _KIND_MIMES.get(kind or "", mime or "application/octet-stream") + + def is_inline_mime(mime: str | None) -> bool: if not mime: return False diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index 624c1e2..163b579 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -401,6 +401,7 @@ class FileShareView(BaseModel): token: str kind: str file_name: str + url_name: str mime: str | None file_size: int | None title: str | None diff --git a/backend/src/utils/read/shares.py b/backend/src/utils/read/shares.py index e1c5eb2..2a93b3a 100644 --- a/backend/src/utils/read/shares.py +++ b/backend/src/utils/read/shares.py @@ -3,6 +3,7 @@ from datetime import datetime import asyncpg +from utils.files import url_slug from utils.read.models import FileShareHitView, FileShareView, Page _COLS = ( @@ -11,10 +12,11 @@ _COLS = ( "download_count, last_download_at, revoked_at, created_at" ) -_TOKEN_BYTES = 12 +_TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789" # noqa: S105 +_TOKEN_LENGTH = 10 _SERVE = """ -SELECT id, storage_key, file_name, mime, expires_at, max_downloads, +SELECT id, kind, storage_key, file_name, mime, expires_at, max_downloads, download_count, revoked_at FROM file_shares WHERE token = $1 """ @@ -45,11 +47,12 @@ RETURNING download_count def new_token() -> str: - return secrets.token_urlsafe(_TOKEN_BYTES) + return "".join(secrets.choice(_TOKEN_ALPHABET) for _ in range(_TOKEN_LENGTH)) def _view(row: asyncpg.Record) -> FileShareView: - return FileShareView(**dict(row)) + fields = dict(row) + return FileShareView(**fields, url_name=url_slug(fields["file_name"])) async def list_shares( diff --git a/frontend/src/lib/api/media.ts b/frontend/src/lib/api/media.ts index 3d55cfa..4cd6796 100644 --- a/frontend/src/lib/api/media.ts +++ b/frontend/src/lib/api/media.ts @@ -21,9 +21,11 @@ export type InlineMedia = export interface ViewerItem { downloaded: boolean; fileName: string | null; + fileSize: number | null; kind: string; mediaId: number | null; messageId: number; + mime: string | null; } export function viewerItemsFrom( @@ -32,7 +34,15 @@ export function viewerItemsFrom( ): ViewerItem[] { if (media.length === 0) { return [ - { messageId, mediaId: null, kind: "", downloaded: false, fileName: null }, + { + messageId, + mediaId: null, + kind: "", + downloaded: false, + fileName: null, + fileSize: null, + mime: null, + }, ]; } return media.map((item) => ({ @@ -41,9 +51,31 @@ export function viewerItemsFrom( kind: item.kind, downloaded: item.downloaded, fileName: item.file_name, + fileSize: item.file_size, + mime: item.mime, })); } +const PREVIEW_KINDS = new Set([ + "photo", + "video", + "video_note", + "animation", + "gif", + "sticker", + "voice", + "audio", +]); + +const PREVIEW_MIME_PREFIXES = ["image/", "video/", "audio/"]; + +export function isPreviewable(kind: string, mime: string | null): boolean { + if (PREVIEW_KINDS.has(kind)) { + return true; + } + return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix)); +} + export type VisualKind = "image" | "video" | "other"; const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]); diff --git a/frontend/src/lib/api/shares.ts b/frontend/src/lib/api/shares.ts index a76a649..0025f75 100644 --- a/frontend/src/lib/api/shares.ts +++ b/frontend/src/lib/api/shares.ts @@ -97,7 +97,7 @@ export function shareUrl(share: FileShare): string { typeof window === "undefined" ? "" : window.location.origin.replace(TRAILING_SLASH, ""); - return `${origin}/f/${share.token}`; + return `${origin}/f/${share.token}/${share.url_name}`; } export function shareState( diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 2f73a97..a8b67af 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -528,6 +528,7 @@ export interface FileShare { story_id: number | null; title: string | null; token: string; + url_name: string; } export interface FileShareHit { diff --git a/frontend/src/lib/components/MediaViewer.svelte b/frontend/src/lib/components/MediaViewer.svelte index 6fb4035..b40f7c8 100644 --- a/frontend/src/lib/components/MediaViewer.svelte +++ b/frontend/src/lib/components/MediaViewer.svelte @@ -4,13 +4,13 @@ import { type MediaResult, requestMedia } from "$lib/api/client"; import { downloadMedia } from "$lib/api/download"; import { fetchMedia, getMessageMedia } from "$lib/api/endpoints"; - import type { ViewerItem } from "$lib/api/media"; + import { isPreviewable, type ViewerItem } from "$lib/api/media"; import Button from "$lib/components/ui/Button.svelte"; import ContextMenu from "$lib/components/ui/ContextMenu.svelte"; import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import Icon from "$lib/components/ui/Icon.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; - import { mediaKindLabel } from "$lib/format/media"; + import { formatBytes, mediaKindLabel } from "$lib/format/media"; import { poster } from "$lib/media/poster"; import { shareUi } from "$lib/stores/shares.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; @@ -33,6 +33,8 @@ let messageId = $state(null); let currentMediaId = $state(null); let fileName = $state(null); + let fileSize = $state(null); + let fileOnly = $state(false); let result = $state(null); let loading = $state(false); let saving = $state(false); @@ -48,12 +50,12 @@ ); const hasNav = $derived(items.length > 1); const canSave = $derived( - currentMediaId !== null && result?.state === "ready" + currentMediaId !== null && (fileOnly || result?.state === "ready") ); const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); function revoke() { - if (result?.state === "ready") { + if (result?.state === "ready" && result.url) { URL.revokeObjectURL(result.url); } } @@ -66,25 +68,39 @@ messageId = item.messageId; currentMediaId = null; fileName = item.fileName; + fileSize = item.fileSize; + fileOnly = false; const current = ++token; try { let mediaId = item.mediaId; let downloaded = item.downloaded; let name = item.fileName; + let size = item.fileSize; + let mimeType = item.mime; if (mediaId === null) { const meta = await getMessageMedia(chatId, item.messageId); mediaId = meta.id; downloaded = meta.downloaded; kind = meta.kind; name = meta.file_name; + size = meta.file_size; + mimeType = meta.mime; + } + const plainFile = downloaded && !isPreviewable(kind, mimeType); + let next: MediaResult; + if (plainFile) { + next = { state: "ready", url: "", mime: mimeType }; + } else if (downloaded) { + next = await requestMedia(mediaId); + } else { + next = { state: "not-downloaded" } as MediaResult; } - const next = downloaded - ? await requestMedia(mediaId) - : ({ state: "not-downloaded" } as MediaResult); if (current === token) { result = next; currentMediaId = mediaId; fileName = name; + fileSize = size; + fileOnly = plainFile; } } catch { if (current === token) { @@ -233,10 +249,17 @@ {:else if result?.state === "ready"} - +
+ +

{title}

+ {#if fileSize} +

{formatBytes(fileSize)}

+ {/if} + +
{:else if result?.state === "not-downloaded"}

Файл ещё не скачан в архив.

@@ -354,6 +377,42 @@ text-align: center; } + .media-file { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + + max-width: min(24rem, 90vw); + color: var(--color-white); + text-align: center; + } + + .file-glyph { + display: flex; + align-items: center; + justify-content: center; + + width: 4.5rem; + height: 4.5rem; + margin-bottom: 0.5rem; + border-radius: 50%; + + background-color: rgba(255, 255, 255, 0.12); + } + + .file-name { + overflow-wrap: anywhere; + margin: 0; + font-size: 1rem; + } + + .file-size { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.6); + } + .media-download { display: inline-flex; align-items: center; diff --git a/frontend/src/lib/components/MessageMedia.svelte b/frontend/src/lib/components/MessageMedia.svelte index 8fcd66e..c440d49 100644 --- a/frontend/src/lib/components/MessageMedia.svelte +++ b/frontend/src/lib/components/MessageMedia.svelte @@ -4,6 +4,7 @@ import { fetchMedia } from "$lib/api/endpoints"; import { type InlineMedia, + isPreviewable, loadInlineMedia, visualKind, } from "$lib/api/media"; @@ -16,7 +17,7 @@ import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import Icon from "$lib/components/ui/Icon.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; - import { mediaKindLabel } from "$lib/format/media"; + import { formatBytes, mediaKindLabel } from "$lib/format/media"; import { poster } from "$lib/media/poster"; import { shareUi } from "$lib/stores/shares.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; @@ -38,8 +39,13 @@ let queuing = $state(false); let saving = $state(false); + const ref = $derived(message.media[0] ?? null); + const asFile = $derived( + ref !== null && ref.id !== null && !isPreviewable(ref.kind, ref.mime) + ); + const ready = $derived(media?.state === "ready" ? media : null); - const kind = $derived(ready?.kind ?? ""); + const kind = $derived(ready?.kind ?? ref?.kind ?? ""); const mime = $derived(ready?.mime ?? ""); const isImage = $derived(kind === "photo"); const isStaticSticker = $derived( @@ -60,7 +66,10 @@ const label = $derived( media && media.state !== "missing" ? media.kind : "media" ); - const storedId = $derived(ready?.mediaId ?? null); + const storedId = $derived( + ready?.mediaId ?? (asFile ? ref?.id : null) ?? null + ); + const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл"); function delay(ms: number): Promise { return new Promise((resolve) => { @@ -69,6 +78,10 @@ } async function start() { + if (asFile) { + loaded = true; + return; + } media = await loadInlineMedia(message.chat_id, message.message_id); loaded = true; } @@ -116,10 +129,7 @@ function share() { if (storedId !== null) { - shareUi.share( - { kind: "media", mediaId: storedId }, - mediaKindLabel(kind) ?? "Файл" - ); + shareUi.share({ kind: "media", mediaId: storedId }, fileName); } } @@ -132,6 +142,18 @@ Самоуничтожающееся медиа + {:else if asFile} + {:else if !loaded}
{:else if ready && kind === "voice"} @@ -145,7 +167,7 @@ {:else if ready && kind === "video_note"} {:else if ready && kind === "audio"} - + {:else if ready && isImage}