fix(api,frontend): render files by name without fetching, previewable share links
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user