diff --git a/backend/migrations/versions/e1c7a4b62d90_drop_scheduled_duplicates.py b/backend/migrations/versions/e1c7a4b62d90_drop_scheduled_duplicates.py new file mode 100644 index 0000000..2760dde --- /dev/null +++ b/backend/migrations/versions/e1c7a4b62d90_drop_scheduled_duplicates.py @@ -0,0 +1,70 @@ +"""drop scheduled message duplicates + +Revision ID: e1c7a4b62d90 +Revises: d4a7e2b91f38 +Create Date: 2026-08-13 10:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "e1c7a4b62d90" +down_revision: str | None = "d4a7e2b91f38" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_STAGE = """ +CREATE TEMP TABLE scheduled_keys ON COMMIT DROP AS +SELECT DISTINCT s.account_id, s.chat_id, s.message_id +FROM messages s +WHERE s.raw->>'scheduled' = 'true' + AND NOT EXISTS ( + SELECT 1 FROM messages r + WHERE r.account_id = s.account_id AND r.chat_id = s.chat_id + AND r.message_id = s.message_id + AND r.raw->>'scheduled' IS DISTINCT FROM 'true' + ) +""" + +_DELETE_CHILD = """ +DELETE FROM {table} t +USING scheduled_keys k +WHERE t.account_id = k.account_id AND t.chat_id = k.chat_id + AND t.message_id = k.message_id +""" + +_DELETE_MESSAGES = """ +DELETE FROM messages m +USING scheduled_keys k +WHERE m.account_id = k.account_id AND m.chat_id = k.chat_id + AND m.message_id = k.message_id AND m.raw->>'scheduled' = 'true' +""" + +_RECOUNT = """ +UPDATE chat_stats cs +SET message_count = fresh.message_count +FROM ( + SELECT k.account_id, k.chat_id, + (SELECT count(*) FROM messages m + WHERE m.account_id = k.account_id AND m.chat_id = k.chat_id) + AS message_count + FROM (SELECT DISTINCT account_id, chat_id FROM scheduled_keys) k +) fresh +WHERE cs.account_id = fresh.account_id AND cs.chat_id = fresh.chat_id +""" + +_CHILD_TABLES = ("media", "media_versions", "message_versions", "links", "callbacks") + + +def upgrade() -> None: + op.execute(_STAGE) + for table in _CHILD_TABLES: + op.execute(_DELETE_CHILD.format(table=table)) + op.execute(_DELETE_MESSAGES) + op.execute(_RECOUNT) + + +def downgrade() -> None: + pass diff --git a/backend/migrations/versions/f2b8d3c9a51e_file_shares.py b/backend/migrations/versions/f2b8d3c9a51e_file_shares.py new file mode 100644 index 0000000..53ac5d5 --- /dev/null +++ b/backend/migrations/versions/f2b8d3c9a51e_file_shares.py @@ -0,0 +1,73 @@ +"""file shares + +Revision ID: f2b8d3c9a51e +Revises: e1c7a4b62d90 +Create Date: 2026-08-13 10:30:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "f2b8d3c9a51e" +down_revision: str | None = "e1c7a4b62d90" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_SHARES = """ +CREATE TABLE file_shares ( + id serial PRIMARY KEY, + account_id integer NOT NULL, + token text NOT NULL UNIQUE, + kind text NOT NULL, + storage_key text NOT NULL, + file_name text NOT NULL, + mime text, + file_size bigint, + title text, + chat_id bigint, + message_id bigint, + peer_id bigint, + story_id bigint, + expires_at timestamptz, + max_downloads integer, + download_count integer NOT NULL DEFAULT 0, + last_download_at timestamptz, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +) +""" + +_HITS = """ +CREATE TABLE file_share_hits ( + id bigserial PRIMARY KEY, + share_id integer NOT NULL REFERENCES file_shares (id) ON DELETE CASCADE, + ts timestamptz NOT NULL DEFAULT now(), + method text NOT NULL, + ip text, + user_agent text, + counted boolean NOT NULL DEFAULT false +) +""" + + +def upgrade() -> None: + op.execute(_SHARES) + op.execute( + "CREATE INDEX ix_file_shares_account ON file_shares " + "(account_id, created_at DESC)" + ) + op.execute( + "CREATE INDEX ix_file_shares_subject ON file_shares " + "(account_id, storage_key) WHERE revoked_at IS NULL" + ) + op.execute(_HITS) + op.execute( + "CREATE INDEX ix_file_share_hits_share ON file_share_hits (share_id, ts DESC)" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS file_share_hits") + op.execute("DROP TABLE IF EXISTS file_shares") diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 0f16fe8..ae6eb0d 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -22,6 +22,7 @@ from api.routers import ( custom_emoji, discover, events, + files, folders, media, peers, @@ -29,6 +30,7 @@ from api.routers import ( presence, profile, search, + shares, social, stories, watches, @@ -88,6 +90,8 @@ app.include_router(peers.router) app.include_router(discover.router) app.include_router(annotations.router) app.include_router(watches.router) +app.include_router(shares.router) +app.include_router(files.router) app.mount("/mcp", mcp_app) diff --git a/backend/src/api/routers/files.py b/backend/src/api/routers/files.py new file mode 100644 index 0000000..6825bf7 --- /dev/null +++ b/backend/src/api/routers/files.py @@ -0,0 +1,78 @@ +from datetime import UTC, datetime +from typing import Annotated + +import asyncpg +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.read import shares +from utils.storage import ContentAddressedStorage + +router = APIRouter(tags=["files"], route_class=DishkaRoute) + +_GONE = "This link is no longer available.\n" +_MISSING = "Not found.\n" + +_NO_STORE = {"Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow"} + + +def _client_ip(request: Request) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else None + + +def _expired(row: asyncpg.Record) -> bool: + expires_at = row["expires_at"] + return expires_at is not None and expires_at <= datetime.now(UTC) + + +def _exhausted(row: asyncpg.Record) -> bool: + limit = row["max_downloads"] + 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( + request: Request, + pool: FromDishka[asyncpg.Pool], + storage: FromDishka[ContentAddressedStorage], + token: str, + dl: Annotated[bool, Query()] = False, +) -> FileResponse | PlainTextResponse: + row = await shares.share_by_token(pool, token) + if row is None: + return PlainTextResponse(_MISSING, status_code=404, headers=_NO_STORE) + + method = request.method + agent = request.headers.get("user-agent") + ip = _client_ip(request) + counts = counts_as_download(method, agent, request.headers.get("range")) + + if row["revoked_at"] is not None or _expired(row) or _exhausted(row): + await shares.record_hit(pool, row["id"], method, ip, agent, counted=False) + return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE) + + if not storage.exists(row["storage_key"]): + return PlainTextResponse(_MISSING, status_code=404, headers=_NO_STORE) + + if counts and not await shares.consume_download(pool, row["id"]): + await shares.record_hit(pool, row["id"], method, ip, agent, counted=False) + 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"]) + return FileResponse( + storage.url(row["storage_key"]), + media_type=mime, + headers={ + **_NO_STORE, + "Content-Disposition": content_disposition( + row["file_name"], attachment=attachment + ), + }, + ) diff --git a/backend/src/api/routers/media.py b/backend/src/api/routers/media.py index b66c638..bd6c2c3 100644 --- a/backend/src/api/routers/media.py +++ b/backend/src/api/routers/media.py @@ -6,6 +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.read.media import ( get_media, get_media_version, @@ -17,6 +18,15 @@ from utils.storage import ContentAddressedStorage router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute) +Download = Annotated[bool, Query()] + + +def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]: + return { + **headers, + "Content-Disposition": content_disposition(file_name, attachment=True), + } + @router.get("/{media_id}/meta") async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView: @@ -41,14 +51,20 @@ async def serve_media_version( pool: FromDishka[asyncpg.Pool], storage: FromDishka[ContentAddressedStorage], version_id: int, + download: Download = False, ) -> FileResponse: version = await get_media_version(pool, version_id) if version is None: raise HTTPException(status_code=404, detail="media version not found") + headers = IMMUTABLE_HEADERS + if download: + headers = _attachment( + headers, media_file_name(version.kind, version.mime, version_id) + ) return FileResponse( storage.url(version.storage_key), media_type=version.mime or "application/octet-stream", - headers=IMMUTABLE_HEADERS, + headers=headers, ) @@ -70,6 +86,7 @@ async def serve_media( pool: FromDishka[asyncpg.Pool], storage: FromDishka[ContentAddressedStorage], media_id: int, + download: Download = False, ) -> FileResponse: media = await get_media(pool, media_id) if media is None: @@ -79,8 +96,11 @@ async def serve_media( status_code=409, detail="media not downloaded; enqueue fetch via POST /api/media/fetch", ) + headers = DAY_HEADERS + if download: + 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", - headers=DAY_HEADERS, + headers=headers, ) diff --git a/backend/src/api/routers/shares.py b/backend/src/api/routers/shares.py new file mode 100644 index 0000000..e15f6ea --- /dev/null +++ b/backend/src/api/routers/shares.py @@ -0,0 +1,226 @@ +from typing import Annotated + +import asyncpg +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.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 + +router = APIRouter(prefix="/api/shares", tags=["shares"], route_class=DishkaRoute) + + +class ShareCreate(BaseModel): + account_id: int + kind: str + media_id: int | None = None + version_id: int | None = None + peer_id: int | None = None + story_id: int | None = None + expires_in_seconds: int | None = None + max_downloads: int | None = None + + +class ShareUpdate(BaseModel): + expires_in_seconds: int | None = None + max_downloads: int | None = None + keep_expiry: bool = False + + +class Subject(BaseModel): + storage_key: str + file_name: str + mime: str | None + file_size: int | None + title: str | None + chat_id: int | None = None + message_id: int | None = None + peer_id: int | None = None + story_id: int | None = None + + +_NOT_STORED = "file is not downloaded yet" + + +async def _chat_title(pool: asyncpg.Pool, account_id: int, chat_id: int) -> str | None: + chat = await chats.get_chat(pool, account_id, chat_id) + return chat.title if chat else None + + +async def _media_subject(pool: asyncpg.Pool, media_id: int) -> Subject: + media = await get_media(pool, media_id) + if media is None: + 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) + return Subject( + storage_key=media.storage_key, + file_name=media.file_name or f"media_{media.id}", + mime=media.mime, + file_size=media.file_size, + title=await _chat_title(pool, media.account_id, media.chat_id), + chat_id=media.chat_id, + message_id=media.message_id, + ) + + +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") + return Subject( + storage_key=version.storage_key, + file_name=media_file_name(version.kind, version.mime, version_id), + mime=version.mime, + file_size=version.file_size, + title=None, + chat_id=None, + message_id=None, + ) + + +async def _story_subject( + pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int +) -> Subject: + story = await peers.get_story(pool, account_id, peer_id, story_id) + if story is None: + raise HTTPException(status_code=404, detail="story not found") + if not story.downloaded or story.storage_key is None: + raise HTTPException(status_code=409, detail=_NOT_STORED) + return Subject( + storage_key=story.storage_key, + file_name=story_file_name(peer_id, story_id, story.media_kind), + mime="video/mp4" if story.media_kind == "video" else "image/jpeg", + file_size=None, + title=await _chat_title(pool, account_id, peer_id), + peer_id=peer_id, + story_id=story_id, + ) + + +async def _resolve(pool: asyncpg.Pool, body: ShareCreate) -> Subject: + if body.kind == "media" and body.media_id is not None: + return await _media_subject(pool, body.media_id) + if body.kind == "media_version" and body.version_id is not None: + return await _version_subject(pool, body.version_id) + if body.kind == "story" and body.peer_id is not None and body.story_id is not None: + return await _story_subject(pool, body.account_id, body.peer_id, body.story_id) + raise HTTPException(status_code=422, detail="unsupported share subject") + + +@router.get("") +async def list_shares( + pool: FromDishka[asyncpg.Pool], + account_id: Annotated[int, Query()], + active_only: Annotated[bool, Query()] = False, + limit: Annotated[int, Query()] = DEFAULT_LIMIT, + offset: Annotated[int, Query()] = 0, +) -> list[FileShareView]: + return await shares.list_shares( + pool, account_id, Page(limit=limit, offset=offset), active_only=active_only + ) + + +@router.post("", status_code=201) +async def create_share( + pool: FromDishka[asyncpg.Pool], body: ShareCreate +) -> FileShareView: + subject = await _resolve(pool, body) + existing = await shares.find_active_share( + pool, body.account_id, subject.storage_key + ) + if existing is not None: + return existing + return await shares.create_share( + pool, + body.account_id, + body.kind, + subject.storage_key, + subject.file_name, + mime=subject.mime, + file_size=subject.file_size, + title=subject.title, + chat_id=subject.chat_id, + message_id=subject.message_id, + peer_id=subject.peer_id, + story_id=subject.story_id, + expires_at=expiry_from_seconds(body.expires_in_seconds), + max_downloads=body.max_downloads, + ) + + +@router.get("/lookup") +async def lookup_share( + pool: FromDishka[asyncpg.Pool], + account_id: Annotated[int, Query()], + kind: Annotated[str, Query()], + media_id: Annotated[int | None, Query()] = None, + version_id: Annotated[int | None, Query()] = None, + peer_id: Annotated[int | None, Query()] = None, + story_id: Annotated[int | None, Query()] = None, +) -> FileShareView | None: + body = ShareCreate( + account_id=account_id, + kind=kind, + media_id=media_id, + version_id=version_id, + peer_id=peer_id, + story_id=story_id, + ) + subject = await _resolve(pool, body) + return await shares.find_active_share(pool, account_id, subject.storage_key) + + +@router.get("/{share_id}/hits") +async def share_hits( + pool: FromDishka[asyncpg.Pool], + share_id: int, + limit: Annotated[int, Query()] = DEFAULT_LIMIT, + offset: Annotated[int, Query()] = 0, +) -> list[FileShareHitView]: + return await shares.list_hits(pool, share_id, Page(limit=limit, offset=offset)) + + +@router.patch("/{share_id}") +async def update_share( + pool: FromDishka[asyncpg.Pool], share_id: int, body: ShareUpdate +) -> FileShareView: + current = await shares.get_share(pool, share_id) + if current is None: + raise HTTPException(status_code=404, detail="share not found") + expires_at = ( + current.expires_at + if body.keep_expiry + else expiry_from_seconds(body.expires_in_seconds) + ) + updated = await shares.update_share( + pool, share_id, expires_at=expires_at, max_downloads=body.max_downloads + ) + if updated is None: + raise HTTPException(status_code=404, detail="share not found") + return updated + + +@router.post("/{share_id}/revoke") +async def revoke_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> FileShareView: + share = await shares.revoke_share(pool, share_id) + if share is None: + raise HTTPException(status_code=404, detail="share not found") + return share + + +@router.post("/{share_id}/reissue") +async def reissue_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> FileShareView: + share = await shares.rotate_token(pool, share_id) + if share is None: + raise HTTPException(status_code=404, detail="share not found") + return share + + +@router.delete("/{share_id}", status_code=204) +async def delete_share(pool: FromDishka[asyncpg.Pool], share_id: int) -> None: + if not await shares.delete_share(pool, share_id): + raise HTTPException(status_code=404, detail="share not found") diff --git a/backend/src/api/routers/stories.py b/backend/src/api/routers/stories.py index b1e16f4..dde49ac 100644 --- a/backend/src/api/routers/stories.py +++ b/backend/src/api/routers/stories.py @@ -5,6 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse +from utils.files import content_disposition, story_file_name from utils.read import peers from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.storage import ContentAddressedStorage @@ -36,13 +37,20 @@ async def serve_story_media( peer_id: int, story_id: int, account_id: AccountId, + download: Annotated[bool, Query()] = False, ) -> FileResponse: story = await peers.get_story(pool, account_id, peer_id, story_id) if story is None: raise HTTPException(status_code=404, detail="story not found") if not story.downloaded or story.storage_key is None: raise HTTPException(status_code=409, detail="story media not downloaded") + headers = {} + if download: + headers["Content-Disposition"] = content_disposition( + story_file_name(peer_id, story_id, story.media_kind), attachment=True + ) return FileResponse( storage.url(story.storage_key), media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"), + headers=headers, ) diff --git a/backend/src/userbot/handlers/edits.py b/backend/src/userbot/handlers/edits.py index bf5bab1..45aca8a 100644 --- a/backend/src/userbot/handlers/edits.py +++ b/backend/src/userbot/handlers/edits.py @@ -11,7 +11,13 @@ from utils.events import notify_bg_event @PyroClient.on_edited_message() async def on_edited_message(client: PyroClient, message: Message) -> None: ctx = client.capture - if ctx is None or message.empty or message.chat is None or message.date is None: + if ( + ctx is None + or message.empty + or message.scheduled + or message.chat is None + or message.date is None + ): return chat = message.chat chat_id = chat.id or 0 diff --git a/backend/src/userbot/handlers/messages.py b/backend/src/userbot/handlers/messages.py index 30e6f9a..03494a1 100644 --- a/backend/src/userbot/handlers/messages.py +++ b/backend/src/userbot/handlers/messages.py @@ -11,7 +11,13 @@ from utils.events import notify_bg_event @PyroClient.on_message() async def on_message(client: PyroClient, message: Message) -> None: ctx = client.capture - if ctx is None or message.empty or message.chat is None or message.date is None: + if ( + ctx is None + or message.empty + or message.scheduled + or message.chat is None + or message.date is None + ): return meta = meta_from_chat(message.chat, ctx.contacts.ids) await ctx.watches.on_text(meta.chat_id, message.id, message.text or message.caption) diff --git a/backend/src/utils/db/models.py b/backend/src/utils/db/models.py index db72436..5706525 100644 --- a/backend/src/utils/db/models.py +++ b/backend/src/utils/db/models.py @@ -493,3 +493,53 @@ class Dialog(SQLModel, table=True): onupdate=func.now(), ) ) + + +class FileShare(SQLModel, table=True): + __tablename__ = "file_shares" + + id: int | None = Field(default=None, primary_key=True) + account_id: int + token: str = Field(unique=True) + kind: str + storage_key: str + file_name: str + mime: str | None = None + file_size: int | None = Field(default=None, sa_column=Column(BigInteger)) + title: str | None = None + chat_id: int | None = Field(default=None, sa_column=Column(BigInteger)) + message_id: int | None = Field(default=None, sa_column=Column(BigInteger)) + peer_id: int | None = Field(default=None, sa_column=Column(BigInteger)) + story_id: int | None = Field(default=None, sa_column=Column(BigInteger)) + expires_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + max_downloads: int | None = None + download_count: int = 0 + last_download_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + revoked_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + ) + + +class FileShareHit(SQLModel, table=True): + __tablename__ = "file_share_hits" + + id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True)) + share_id: int = Field(foreign_key="file_shares.id") + ts: datetime = Field( + sa_column=Column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + ) + method: str + ip: str | None = None + user_agent: str | None = None + counted: bool = False diff --git a/backend/src/utils/files.py b/backend/src/utils/files.py new file mode 100644 index 0000000..746561a --- /dev/null +++ b/backend/src/utils/files.py @@ -0,0 +1,134 @@ +import re +from datetime import UTC, datetime, timedelta +from urllib.parse import quote + +_MIME_EXTENSIONS = { + "application/pdf": ".pdf", + "application/x-tgsticker": ".tgs", + "application/zip": ".zip", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "text/plain": ".txt", + "video/mp4": ".mp4", + "video/quicktime": ".mov", + "video/webm": ".webm", +} + +_KIND_EXTENSIONS = { + "animation": ".mp4", + "audio": ".mp3", + "gif": ".mp4", + "photo": ".jpg", + "sticker": ".webp", + "video": ".mp4", + "video_note": ".mp4", + "voice": ".ogg", +} + +_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/") +_INLINE_MIMES = {"application/pdf", "application/json"} + +_PREVIEW_AGENTS = ( + "telegrambot", + "twitterbot", + "facebookexternalhit", + "whatsapp", + "discordbot", + "slackbot", + "skypeuripreview", + "vkshare", + "redditbot", + "linkedinbot", + "embedly", + "quora link preview", + "googlebot", + "bingbot", + "applebot", + "yandexbot", + "duckduckbot", + "petalbot", + "ahrefsbot", + "semrushbot", + "headlesschrome", + "python-requests", +) + +_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') +_SPACES = re.compile(r"\s+") +_NAME_LIMIT = 120 + + +def extension_for(kind: str | None, mime: str | None) -> str: + if mime and mime in _MIME_EXTENSIONS: + return _MIME_EXTENSIONS[mime] + if kind and kind in _KIND_EXTENSIONS: + return _KIND_EXTENSIONS[kind] + if mime and "/" in mime: + tail = mime.rsplit("/", 1)[1].split(";")[0].strip() + if tail.isalnum(): + return f".{tail}" + return ".bin" + + +def sanitize_name(name: str) -> str: + cleaned = _SPACES.sub(" ", _UNSAFE_CHARS.sub("_", name)).strip(" .") + if len(cleaned) > _NAME_LIMIT: + head, dot, tail = cleaned.rpartition(".") + cleaned = ( + f"{head[: _NAME_LIMIT - len(tail) - 1]}{dot}{tail}" + if dot and len(tail) < 12 # noqa: PLR2004 + else cleaned[:_NAME_LIMIT] + ) + return cleaned or "file" + + +def media_file_name( + kind: str | None, mime: str | None, message_id: int, original: str | None = None +) -> str: + if original: + name = sanitize_name(original) + return name if "." in name else f"{name}{extension_for(kind, mime)}" + return f"{kind or 'media'}_{message_id}{extension_for(kind, mime)}" + + +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 is_inline_mime(mime: str | None) -> bool: + if not mime: + return False + return mime in _INLINE_MIMES or mime.startswith(_INLINE_MIME_PREFIXES) + + +def content_disposition(file_name: str, *, attachment: bool) -> str: + kind = "attachment" if attachment else "inline" + ascii_name = file_name.encode("ascii", "replace").decode("ascii").replace('"', "_") + return f"{kind}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(file_name)}" + + +def is_preview_agent(user_agent: str | None) -> bool: + if not user_agent: + return False + lowered = user_agent.lower() + return any(marker in lowered for marker in _PREVIEW_AGENTS) + + +def counts_as_download( + method: str, user_agent: str | None, range_header: str | None +) -> bool: + if method.upper() != "GET" or is_preview_agent(user_agent): + return False + if range_header is None: + return True + return range_header.replace(" ", "").startswith("bytes=0-") + + +def expiry_from_seconds(seconds: int | None) -> datetime | None: + if seconds is None or seconds <= 0: + return None + return datetime.now(UTC) + timedelta(seconds=seconds) diff --git a/backend/src/utils/read/media.py b/backend/src/utils/read/media.py index 101333f..d8231ad 100644 --- a/backend/src/utils/read/media.py +++ b/backend/src/utils/read/media.py @@ -1,18 +1,41 @@ import asyncpg +from utils.files import media_file_name from utils.read.message_view import load_raw from utils.read.models import MediaVersionView, MediaView -_MEDIA_COLS = ( - "id, account_id, chat_id, message_id, kind, storage_key, file_size, " - "mime, ttl_seconds, downloaded, extracted_text, created_at" +MEDIA_COLS = ( + "m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, " + "m.file_size, m.mime, m.ttl_seconds, m.downloaded, m.extracted_text, " + "m.created_at, src.original_name" ) +ORIGINAL_NAME_JOIN = """ +LEFT JOIN LATERAL ( + SELECT msg.raw->m.kind->>'file_name' AS original_name + FROM messages msg + WHERE msg.account_id = m.account_id AND msg.chat_id = m.chat_id + AND msg.message_id = m.message_id + ORDER BY msg.date DESC LIMIT 1 +) src ON true +""" + _VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at" _WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio") +def media_view(row: asyncpg.Record) -> MediaView: + fields = dict(row) + original = fields.pop("original_name", None) + return MediaView( + **fields, + file_name=media_file_name( + fields["kind"], fields["mime"], fields["message_id"], original + ), + ) + + async def _web_page_media_stub( pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int ) -> MediaView | None: @@ -47,29 +70,32 @@ async def _web_page_media_stub( downloaded=False, extracted_text=None, created_at=row["date"], + file_name=media_file_name( + kind, obj.get("mime_type"), message_id, obj.get("file_name") + ), ) async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None: row = await pool.fetchrow( - f"SELECT {_MEDIA_COLS} FROM media WHERE id = $1", # noqa: S608 + f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} WHERE m.id = $1", # noqa: S608 media_id, ) - return MediaView(**dict(row)) if row else None + return media_view(row) if row else None async def get_message_media( pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int ) -> MediaView | None: row = await pool.fetchrow( - f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608 - "WHERE account_id = $1 AND chat_id = $2 AND message_id = $3", + f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608 + "WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3", account_id, chat_id, message_id, ) if row is not None: - return MediaView(**dict(row)) + return media_view(row) return await _web_page_media_stub(pool, account_id, chat_id, message_id) diff --git a/backend/src/utils/read/message_view.py b/backend/src/utils/read/message_view.py index 509e31d..0c547c8 100644 --- a/backend/src/utils/read/message_view.py +++ b/backend/src/utils/read/message_view.py @@ -5,6 +5,7 @@ from typing import Any import asyncpg from pydantic import ValidationError +from utils.files import media_file_name from utils.read.models import ( ContactView, EntityView, @@ -344,6 +345,7 @@ def media_ref_from( obj = obj if isinstance(obj, dict) else {} width = obj.get("width") or obj.get("length") height = obj.get("height") or obj.get("length") + mime = (media_row["mime"] if media_row else None) or obj.get("mime_type") return MediaRef( message_id=message_id, id=media_row["id"] if media_row else None, @@ -352,11 +354,12 @@ def media_ref_from( width=width, height=height, duration=obj.get("duration"), - mime=(media_row["mime"] if media_row else None) or obj.get("mime_type"), + mime=mime, file_size=(media_row["file_size"] if media_row else None) or obj.get("file_size"), ttl_seconds=media_row["ttl_seconds"] if media_row else None, extracted_text=media_row["extracted_text"] if media_row else None, + file_name=media_file_name(kind, mime, message_id, obj.get("file_name")), ) diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index 6411e57..624c1e2 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -92,6 +92,7 @@ class MediaRef(BaseModel): file_size: int | None = None ttl_seconds: int | None = None extracted_text: str | None = None + file_name: str | None = None class ReactionCount(BaseModel): @@ -222,6 +223,7 @@ class MediaView(BaseModel): downloaded: bool extracted_text: str | None created_at: datetime + file_name: str | None = None class MediaVersionView(BaseModel): @@ -391,3 +393,32 @@ class AlertView(BaseModel): payload: dict[str, Any] seen: bool created_at: datetime + + +class FileShareView(BaseModel): + id: int + account_id: int + token: str + kind: str + file_name: str + mime: str | None + file_size: int | None + title: str | None + chat_id: int | None + message_id: int | None + peer_id: int | None + story_id: int | None + expires_at: datetime | None + max_downloads: int | None + download_count: int + last_download_at: datetime | None + revoked_at: datetime | None + created_at: datetime + + +class FileShareHitView(BaseModel): + ts: datetime + method: str + ip: str | None + user_agent: str | None + counted: bool diff --git a/backend/src/utils/read/profile.py b/backend/src/utils/read/profile.py index 095e962..5f54bff 100644 --- a/backend/src/utils/read/profile.py +++ b/backend/src/utils/read/profile.py @@ -3,28 +3,24 @@ from datetime import datetime, timedelta import asyncpg from utils.read.accounts import self_user_id +from utils.read.media import MEDIA_COLS, ORIGINAL_NAME_JOIN, media_view from utils.read.models import ChatLinkView, DayCount, MediaView, MessageAt, Page -_MEDIA_COLS = ( - "id, account_id, chat_id, message_id, kind, storage_key, file_size, " - "mime, ttl_seconds, downloaded, extracted_text, created_at" -) - async def chat_media( pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page ) -> list[MediaView]: rows = await pool.fetch( - f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608 - "WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) " - "ORDER BY message_id DESC LIMIT $4 OFFSET $5", + f"SELECT {MEDIA_COLS} FROM media m {ORIGINAL_NAME_JOIN} " # noqa: S608 + "WHERE m.account_id = $1 AND m.chat_id = $2 AND m.kind = ANY($3) " + "ORDER BY m.message_id DESC LIMIT $4 OFFSET $5", account_id, chat_id, kinds, page.capped_limit, page.offset, ) - return [MediaView(**dict(row)) for row in rows] + return [media_view(row) for row in rows] async def chat_links( diff --git a/backend/src/utils/read/shares.py b/backend/src/utils/read/shares.py new file mode 100644 index 0000000..e1c5eb2 --- /dev/null +++ b/backend/src/utils/read/shares.py @@ -0,0 +1,204 @@ +import secrets +from datetime import datetime + +import asyncpg + +from utils.read.models import FileShareHitView, FileShareView, Page + +_COLS = ( + "id, account_id, token, kind, file_name, mime, file_size, title, " + "chat_id, message_id, peer_id, story_id, expires_at, max_downloads, " + "download_count, last_download_at, revoked_at, created_at" +) + +_TOKEN_BYTES = 12 + +_SERVE = """ +SELECT id, storage_key, file_name, mime, expires_at, max_downloads, + download_count, revoked_at +FROM file_shares WHERE token = $1 +""" + +_INSERT = ( + "INSERT INTO file_shares " # noqa: S608 + "(account_id, token, kind, storage_key, file_name, mime, file_size, title, " + "chat_id, message_id, peer_id, story_id, expires_at, max_downloads) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) " + f"RETURNING {_COLS}" +) + +_ACTIVE = "revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())" + +_LOOKUP = ( + f"SELECT {_COLS} FROM file_shares " # noqa: S608 + f"WHERE account_id = $1 AND storage_key = $2 AND {_ACTIVE} " + "AND (max_downloads IS NULL OR download_count < max_downloads) " + "ORDER BY created_at DESC LIMIT 1" +) + +_CONSUME = """ +UPDATE file_shares +SET download_count = download_count + 1, last_download_at = now() +WHERE id = $1 AND (max_downloads IS NULL OR download_count < max_downloads) +RETURNING download_count +""" + + +def new_token() -> str: + return secrets.token_urlsafe(_TOKEN_BYTES) + + +def _view(row: asyncpg.Record) -> FileShareView: + return FileShareView(**dict(row)) + + +async def list_shares( + pool: asyncpg.Pool, account_id: int, page: Page, *, active_only: bool = False +) -> list[FileShareView]: + where = "account_id = $1" + if active_only: + where += f" AND {_ACTIVE}" + rows = await pool.fetch( + f"SELECT {_COLS} FROM file_shares WHERE {where} " # noqa: S608 + "ORDER BY created_at DESC LIMIT $2 OFFSET $3", + account_id, + page.capped_limit, + page.offset, + ) + return [_view(row) for row in rows] + + +async def get_share(pool: asyncpg.Pool, share_id: int) -> FileShareView | None: + row = await pool.fetchrow( + f"SELECT {_COLS} FROM file_shares WHERE id = $1", # noqa: S608 + share_id, + ) + return _view(row) if row else None + + +async def find_active_share( + pool: asyncpg.Pool, account_id: int, storage_key: str +) -> FileShareView | None: + row = await pool.fetchrow(_LOOKUP, account_id, storage_key) + return _view(row) if row else None + + +async def create_share( # noqa: PLR0913 + pool: asyncpg.Pool, + account_id: int, + kind: str, + storage_key: str, + file_name: str, + *, + mime: str | None = None, + file_size: int | None = None, + title: str | None = None, + chat_id: int | None = None, + message_id: int | None = None, + peer_id: int | None = None, + story_id: int | None = None, + expires_at: datetime | None = None, + max_downloads: int | None = None, +) -> FileShareView: + row = await pool.fetchrow( + _INSERT, + account_id, + new_token(), + kind, + storage_key, + file_name, + mime, + file_size, + title, + chat_id, + message_id, + peer_id, + story_id, + expires_at, + max_downloads, + ) + return _view(row) + + +async def update_share( + pool: asyncpg.Pool, + share_id: int, + *, + expires_at: datetime | None, + max_downloads: int | None, +) -> FileShareView | None: + row = await pool.fetchrow( + "UPDATE file_shares SET expires_at = $2, max_downloads = $3 " # noqa: S608 + f"WHERE id = $1 RETURNING {_COLS}", + share_id, + expires_at, + max_downloads, + ) + return _view(row) if row else None + + +async def revoke_share(pool: asyncpg.Pool, share_id: int) -> FileShareView | None: + row = await pool.fetchrow( + "UPDATE file_shares SET revoked_at = now() " # noqa: S608 + f"WHERE id = $1 AND revoked_at IS NULL RETURNING {_COLS}", + share_id, + ) + if row is not None: + return _view(row) + return await get_share(pool, share_id) + + +async def delete_share(pool: asyncpg.Pool, share_id: int) -> bool: + result = await pool.execute("DELETE FROM file_shares WHERE id = $1", share_id) + return result.endswith("1") + + +async def rotate_token(pool: asyncpg.Pool, share_id: int) -> FileShareView | None: + row = await pool.fetchrow( + "UPDATE file_shares SET token = $2, revoked_at = NULL " # noqa: S608 + f"WHERE id = $1 RETURNING {_COLS}", + share_id, + new_token(), + ) + return _view(row) if row else None + + +async def share_by_token(pool: asyncpg.Pool, token: str) -> asyncpg.Record | None: + return await pool.fetchrow(_SERVE, token) + + +async def consume_download(pool: asyncpg.Pool, share_id: int) -> bool: + return await pool.fetchval(_CONSUME, share_id) is not None + + +async def record_hit( # noqa: PLR0913 + pool: asyncpg.Pool, + share_id: int, + method: str, + ip: str | None, + user_agent: str | None, + *, + counted: bool, +) -> None: + await pool.execute( + "INSERT INTO file_share_hits (share_id, method, ip, user_agent, counted) " + "VALUES ($1, $2, $3, $4, $5)", + share_id, + method, + ip, + user_agent, + counted, + ) + + +async def list_hits( + pool: asyncpg.Pool, share_id: int, page: Page +) -> list[FileShareHitView]: + rows = await pool.fetch( + "SELECT ts, method, ip, user_agent, counted FROM file_share_hits " + "WHERE share_id = $1 ORDER BY ts DESC LIMIT $2 OFFSET $3", + share_id, + page.capped_limit, + page.offset, + ) + return [FileShareHitView(**dict(row)) for row in rows] diff --git a/frontend/src/lib/api/download.ts b/frontend/src/lib/api/download.ts new file mode 100644 index 0000000..4991335 --- /dev/null +++ b/frontend/src/lib/api/download.ts @@ -0,0 +1,61 @@ +import { accounts } from "$lib/stores/accounts.svelte"; +import { auth } from "$lib/stores/auth.svelte"; + +const BASE = import.meta.env.VITE_API_BASE ?? "/api"; +const FILENAME_STAR = /filename\*=UTF-8''([^;]+)/i; +const FILENAME_PLAIN = /filename="?([^";]+)"?/i; + +function nameFromHeader(header: string | null, fallback: string): string { + if (!header) { + return fallback; + } + const encoded = FILENAME_STAR.exec(header); + if (encoded) { + return decodeURIComponent(encoded[1]); + } + const plain = FILENAME_PLAIN.exec(header); + return plain ? plain[1] : fallback; +} + +function saveBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +async function save(path: string, fallback: string): Promise { + const response = await fetch(`${BASE}${path}`, { + headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {}, + }); + if (!response.ok) { + throw new Error(`download failed: ${response.status}`); + } + const fileName = nameFromHeader( + response.headers.get("content-disposition"), + fallback + ); + saveBlob(await response.blob(), fileName); +} + +export function downloadMedia(mediaId: number): Promise { + return save(`/media/${mediaId}?download=true`, `media_${mediaId}`); +} + +export function downloadMediaVersion(versionId: number): Promise { + return save( + `/media/version/${versionId}?download=true`, + `media_${versionId}` + ); +} + +export function downloadStory(peerId: number, storyId: number): Promise { + return save( + `/stories/${peerId}/${storyId}/media?download=true&account_id=${accounts.selectedId}`, + `story_${peerId}_${storyId}` + ); +} diff --git a/frontend/src/lib/api/media.ts b/frontend/src/lib/api/media.ts index 964aa15..3d55cfa 100644 --- a/frontend/src/lib/api/media.ts +++ b/frontend/src/lib/api/media.ts @@ -20,6 +20,7 @@ export type InlineMedia = export interface ViewerItem { downloaded: boolean; + fileName: string | null; kind: string; mediaId: number | null; messageId: number; @@ -30,13 +31,16 @@ export function viewerItemsFrom( media: MediaRef[] ): ViewerItem[] { if (media.length === 0) { - return [{ messageId, mediaId: null, kind: "", downloaded: false }]; + return [ + { messageId, mediaId: null, kind: "", downloaded: false, fileName: null }, + ]; } return media.map((item) => ({ messageId: item.message_id, mediaId: item.id, kind: item.kind, downloaded: item.downloaded, + fileName: item.file_name, })); } diff --git a/frontend/src/lib/api/shares.ts b/frontend/src/lib/api/shares.ts new file mode 100644 index 0000000..a76a649 --- /dev/null +++ b/frontend/src/lib/api/shares.ts @@ -0,0 +1,119 @@ +import { request } from "$lib/api/client"; +import type { FileShare, FileShareHit, ShareSubject } from "$lib/api/types"; +import { accounts } from "$lib/stores/accounts.svelte"; + +export interface ShareSettings { + expiresInSeconds: number | null; + keepExpiry?: boolean; + maxDownloads: number | null; +} + +function subjectQuery(subject: ShareSubject): Record { + const query: Record = { kind: subject.kind }; + if (subject.mediaId !== undefined) { + query.media_id = subject.mediaId; + } + if (subject.versionId !== undefined) { + query.version_id = subject.versionId; + } + if (subject.peerId !== undefined) { + query.peer_id = subject.peerId; + } + if (subject.storyId !== undefined) { + query.story_id = subject.storyId; + } + return query; +} + +export function listShares(activeOnly = false): Promise { + return request("/shares", { + account: true, + query: { active_only: activeOnly, limit: 200 }, + }); +} + +export function lookupShare(subject: ShareSubject): Promise { + return request("/shares/lookup", { + account: true, + query: subjectQuery(subject), + }); +} + +export function createShare( + subject: ShareSubject, + settings: ShareSettings +): Promise { + return request("/shares", { + method: "POST", + body: { + account_id: accounts.selectedId, + kind: subject.kind, + media_id: subject.mediaId ?? null, + version_id: subject.versionId ?? null, + peer_id: subject.peerId ?? null, + story_id: subject.storyId ?? null, + expires_in_seconds: settings.expiresInSeconds, + max_downloads: settings.maxDownloads, + }, + }); +} + +export function updateShare( + id: number, + settings: ShareSettings +): Promise { + return request(`/shares/${id}`, { + method: "PATCH", + body: { + expires_in_seconds: settings.expiresInSeconds, + keep_expiry: settings.keepExpiry ?? false, + max_downloads: settings.maxDownloads, + }, + }); +} + +export function revokeShare(id: number): Promise { + return request(`/shares/${id}/revoke`, { method: "POST" }); +} + +export function reissueShare(id: number): Promise { + return request(`/shares/${id}/reissue`, { method: "POST" }); +} + +export function deleteShare(id: number): Promise { + return request(`/shares/${id}`, { method: "DELETE" }); +} + +export function listShareHits(id: number): Promise { + return request(`/shares/${id}/hits`, { + query: { limit: 50 }, + }); +} + +const TRAILING_SLASH = /\/$/; + +export function shareUrl(share: FileShare): string { + const origin = + typeof window === "undefined" + ? "" + : window.location.origin.replace(TRAILING_SLASH, ""); + return `${origin}/f/${share.token}`; +} + +export function shareState( + share: FileShare +): "active" | "revoked" | "expired" | "exhausted" { + if (share.revoked_at) { + return "revoked"; + } + if (share.expires_at && Date.parse(share.expires_at) <= Date.now()) { + return "expired"; + } + if ( + share.max_downloads !== null && + share.download_count >= share.max_downloads + ) { + return "exhausted"; + } + return "active"; +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c8a819a..2f73a97 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -92,6 +92,7 @@ export interface ForwardView { export interface MediaRef { downloaded: boolean; duration: number | null; + file_name: string | null; file_size: number | null; height: number | null; id: number | null; @@ -242,6 +243,7 @@ export interface MediaView { created_at: string; downloaded: boolean; extracted_text: string | null; + file_name: string | null; file_size: number | null; id: number; kind: string; @@ -506,3 +508,40 @@ export type LiveEvent = | LiveDeleteEvent | LivePresenceEvent | LiveReceiptEvent; + +export interface FileShare { + account_id: number; + chat_id: number | null; + created_at: string; + download_count: number; + expires_at: string | null; + file_name: string; + file_size: number | null; + id: number; + kind: string; + last_download_at: string | null; + max_downloads: number | null; + message_id: number | null; + mime: string | null; + peer_id: number | null; + revoked_at: string | null; + story_id: number | null; + title: string | null; + token: string; +} + +export interface FileShareHit { + counted: boolean; + ip: string | null; + method: string; + ts: string; + user_agent: string | null; +} + +export interface ShareSubject { + kind: "media" | "media_version" | "story"; + mediaId?: number; + peerId?: number; + storyId?: number; + versionId?: number; +} diff --git a/frontend/src/lib/components/MediaViewer.svelte b/frontend/src/lib/components/MediaViewer.svelte index d72944a..6fb4035 100644 --- a/frontend/src/lib/components/MediaViewer.svelte +++ b/frontend/src/lib/components/MediaViewer.svelte @@ -2,12 +2,17 @@ import { Dialog } from "bits-ui"; import { untrack } from "svelte"; 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 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 { poster } from "$lib/media/poster"; + import { shareUi } from "$lib/stores/shares.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; interface Props { @@ -26,8 +31,11 @@ let kind = $state(""); let messageId = $state(null); + let currentMediaId = $state(null); + let fileName = $state(null); let result = $state(null); let loading = $state(false); + let saving = $state(false); let token = 0; const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); @@ -39,6 +47,10 @@ mime.startsWith("audio/") || kind === "voice" || kind === "audio" ); const hasNav = $derived(items.length > 1); + const canSave = $derived( + currentMediaId !== null && result?.state === "ready" + ); + const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); function revoke() { if (result?.state === "ready") { @@ -52,21 +64,27 @@ result = null; kind = item.kind; messageId = item.messageId; + currentMediaId = null; + fileName = item.fileName; const current = ++token; try { let mediaId = item.mediaId; let downloaded = item.downloaded; + let name = item.fileName; if (mediaId === null) { const meta = await getMessageMedia(chatId, item.messageId); mediaId = meta.id; downloaded = meta.downloaded; kind = meta.kind; + name = meta.file_name; } const next = downloaded ? await requestMedia(mediaId) : ({ state: "not-downloaded" } as MediaResult); if (current === token) { result = next; + currentMediaId = mediaId; + fileName = name; } } catch { if (current === token) { @@ -85,9 +103,29 @@ } try { await fetchMedia(chatId, messageId); - toasts.success("Download queued"); + toasts.success("Скачивание поставлено в очередь"); } catch { - toasts.error("Failed to queue download"); + toasts.error("Не удалось поставить в очередь"); + } + } + + async function save() { + if (currentMediaId === null || saving) { + return; + } + saving = true; + try { + await downloadMedia(currentMediaId); + } catch { + toasts.error("Не удалось скачать файл"); + } finally { + saving = false; + } + } + + function share() { + if (currentMediaId !== null) { + shareUi.share({ kind: "media", mediaId: currentMediaId }, title); } } @@ -135,10 +173,43 @@ - {kind || "Media"} - - - + {title} +
+ {#if canSave} + + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + + Скачать файл + + + Доступ по ссылке + + {/snippet} + + + {/if} + + + +
{#if hasNav} {index + 1} / {items.length} {/if} @@ -162,19 +233,19 @@ {:else if result?.state === "ready"} - + {:else if result?.state === "not-downloaded"}
-

This media has not been downloaded yet.

+

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

{:else if result?.state === "missing"} -

Media not found.

+

Файл не найден.

{/if} {#if hasNav} @@ -222,14 +293,18 @@ :global(.media-title) { position: absolute; - top: 1rem; + top: 1.25rem; left: 1.25rem; + overflow: hidden; + max-width: min(24rem, calc(100% - 14rem)); margin: 0; + font-size: 1rem; font-weight: var(--font-weight-medium); color: var(--color-white); - text-transform: capitalize; + text-overflow: ellipsis; + white-space: nowrap; } .media-counter { @@ -244,9 +319,6 @@ :global(.media-close) { cursor: pointer; - position: absolute; - top: 0.75rem; - right: 1rem; display: flex; align-items: center; @@ -287,8 +359,43 @@ align-items: center; gap: 0.5rem; + padding: 0.625rem 1.25rem; + border: 0; + border-radius: 1.5rem; + + font-family: inherit; + font-size: 0.9375rem; color: var(--color-white); - text-decoration: none; + + cursor: pointer; + background-color: rgba(255, 255, 255, 0.12); + } + + .media-actions { + position: absolute; + top: 0.75rem; + right: 1rem; + + display: flex; + align-items: center; + gap: 0.375rem; + z-index: 1; + } + + .media-action { + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: 2.5rem; + height: 2.5rem; + border: 0; + border-radius: 50%; + + color: var(--color-white); + background-color: rgba(255, 255, 255, 0.1); } .media-nav { diff --git a/frontend/src/lib/components/MessageMedia.svelte b/frontend/src/lib/components/MessageMedia.svelte index 8e48cda..8fcd66e 100644 --- a/frontend/src/lib/components/MessageMedia.svelte +++ b/frontend/src/lib/components/MessageMedia.svelte @@ -1,5 +1,6 @@ @@ -102,7 +130,7 @@ {#if message.is_self_destruct} {:else if !loaded}
@@ -161,18 +189,18 @@ {:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded"} {:else} {/if} @@ -186,7 +214,18 @@ onselect={() => ui.openMessagePanel("versions", message.message_id)} >Версии медиа - Скачать + {#if storedId === null} + + Скачать в архив + + {:else} + + Скачать файл + + + Доступ по ссылке + + {/if} {/snippet}
diff --git a/frontend/src/lib/components/RightColumn.svelte b/frontend/src/lib/components/RightColumn.svelte index 5420cd0..97293d3 100644 --- a/frontend/src/lib/components/RightColumn.svelte +++ b/frontend/src/lib/components/RightColumn.svelte @@ -6,6 +6,7 @@ import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte"; import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte"; import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte"; + import SharesPanel from "$lib/components/shares/SharesPanel.svelte"; import CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte"; import LinksPanel from "$lib/components/social/LinksPanel.svelte"; import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte"; @@ -31,6 +32,7 @@ policy: "Политика захвата", watches: "Отслеживания", alerts: "Алерты", + shares: "Файлы по ссылке", }; @@ -74,6 +76,8 @@ {:else if ui.rightPanel === "annotations"} + {:else if ui.rightPanel === "shares"} + {/if} diff --git a/frontend/src/lib/components/profile/SharedMedia.svelte b/frontend/src/lib/components/profile/SharedMedia.svelte index 2784e5b..802f8de 100644 --- a/frontend/src/lib/components/profile/SharedMedia.svelte +++ b/frontend/src/lib/components/profile/SharedMedia.svelte @@ -1,6 +1,7 @@ + + + + + +
+ Доступ по ссылке + + + +
+ +
+ {#if loading} +
+ {:else if share} +
+ +
+ {share.file_name} + {formatShareLimits(share)} +
+
+ + + +
+ +
+ {#if showQr} +
+ +
+ {/if} + +

+ Ссылка отдаёт файл как есть — + curl -O {shareUrl(share)} + скачает его без авторизации. Просмотры превью в Telegram не + списывают скачивания. +

+ +
+ Лимит скачиваний +
+ {#each limitChoices as choice (choice.label)} + + {/each} +
+
+ +
+ Срок жизни ссылки +
+ {#each liveExpiryChoices as choice (choice.label)} + + {/each} +
+
+ {:else} +
+ +
+ {label} + Сейчас доступен только вам +
+
+ +
+ Срок жизни ссылки +
+ {#each expiryChoices as choice (choice.label)} + + {/each} +
+
+ +
+ Лимит скачиваний +
+ {#each limitChoices as choice (choice.label)} + + {/each} +
+
+ +

+ Файл откроется всем, у кого есть ссылка. Отозвать можно в любой + момент. +

+ {/if} +
+ + {#if !loading} +
+ {#if share} + + + {:else} + + {/if} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/shares/ShareLinkField.svelte b/frontend/src/lib/components/shares/ShareLinkField.svelte new file mode 100644 index 0000000..4113da3 --- /dev/null +++ b/frontend/src/lib/components/shares/ShareLinkField.svelte @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/lib/components/shares/SharesPanel.svelte b/frontend/src/lib/components/shares/SharesPanel.svelte new file mode 100644 index 0000000..651f97c --- /dev/null +++ b/frontend/src/lib/components/shares/SharesPanel.svelte @@ -0,0 +1,472 @@ + + +
+
+ + +
+ +
+ +{#if loading && items.length === 0} +
+{:else if visible.length === 0} + +{:else} +
    + {#each visible as share (share.id)} + {@const state = shareState(share)} +
  • + + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + copy(share)}> + Копировать ссылку + + {#if share.message_id !== null || share.peer_id !== null} + jump(share)}> + Перейти к сообщению + + {/if} + {#if state === "active"} + revoke(share)} + > + Отозвать + + {:else} + reissue(share)}> + Выдать новую ссылку + + forget(share)}> + Убрать из списка + + {/if} + {/snippet} + + + {#if expanded === share.id} +
    + + {#if share.last_download_at} +

    + Последнее скачивание: {formatFull(share.last_download_at)} +

    + {/if} +
    + {#if share.message_id !== null || share.peer_id !== null} + + {/if} + {#if state === "active"} + + {:else} + + {/if} +
    +
    + {/if} +
  • + {/each} +
+

+ {visible.length} + {plural(visible.length, "ссылка", "ссылки", "ссылок")} + · превью-краулеры не списывают скачивания +

+{/if} + + diff --git a/frontend/src/lib/components/stories/AllStoriesArchive.svelte b/frontend/src/lib/components/stories/AllStoriesArchive.svelte index 8629afb..89f5184 100644 --- a/frontend/src/lib/components/stories/AllStoriesArchive.svelte +++ b/frontend/src/lib/components/stories/AllStoriesArchive.svelte @@ -8,6 +8,7 @@ } from "$lib/api/endpoints"; import { loadStoryMedia } from "$lib/api/stories"; import type { StoryView } from "$lib/api/types"; + import StoryTile from "$lib/components/stories/StoryTile.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte"; import Button from "$lib/components/ui/Button.svelte"; @@ -211,34 +212,11 @@ {#if expanded[group.peerId]}
{#each group.stories as item, index (item.story_id)} - + openViewer(group, index)} + /> {/each}
{/if} diff --git a/frontend/src/lib/components/stories/StoriesArchive.svelte b/frontend/src/lib/components/stories/StoriesArchive.svelte index 86f1229..cbcbcf2 100644 --- a/frontend/src/lib/components/stories/StoriesArchive.svelte +++ b/frontend/src/lib/components/stories/StoriesArchive.svelte @@ -4,6 +4,7 @@ import { enqueueStoriesBackfill, getStories } from "$lib/api/endpoints"; import { loadStoryMedia } from "$lib/api/stories"; import type { StoryView } from "$lib/api/types"; + import StoryTile from "$lib/components/stories/StoryTile.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import Button from "$lib/components/ui/Button.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte"; @@ -161,39 +162,11 @@ {:else}
{#each items as item, index (item.story_id)} - + openViewer(index)} + /> {/each}
diff --git a/frontend/src/lib/components/stories/StoryTile.svelte b/frontend/src/lib/components/stories/StoryTile.svelte new file mode 100644 index 0000000..e228e07 --- /dev/null +++ b/frontend/src/lib/components/stories/StoryTile.svelte @@ -0,0 +1,171 @@ + + + + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + + Открыть + + {#if story.downloaded} + + Скачать + + + Доступ по ссылке + + {/if} + + Профиль автора + + {/snippet} + + + diff --git a/frontend/src/lib/components/stories/StoryViewer.svelte b/frontend/src/lib/components/stories/StoryViewer.svelte index 4cad6b0..c88b284 100644 --- a/frontend/src/lib/components/stories/StoryViewer.svelte +++ b/frontend/src/lib/components/stories/StoryViewer.svelte @@ -1,11 +1,16 @@ - + - + Сторис
{#each items as item, i (item.story_id)} @@ -119,6 +195,7 @@ {#if ready && !isVideo}
@@ -146,6 +223,29 @@ {/if} + {#if canSave} + + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + + Скачать + + + Доступ по ссылке + + {/snippet} + + {/if} @@ -158,6 +258,7 @@ {:else if url && isVideo}
+ shareUi.touch()} +/> +