feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
1 parent
683b9a31a3
commit
3e08698b62
38 files changed
+2889
-134
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user