Skip to content

Commit de0d594

Browse files
committed
feat: implement embed preview functionality
1 parent 2663736 commit de0d594

File tree

5 files changed

+189
-83
lines changed

5 files changed

+189
-83
lines changed

backend/app/embed_preview.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from pathlib import Path
2+
3+
from fastapi import HTTPException, Request, status
4+
from fastapi.templating import Jinja2Templates
5+
from sqlalchemy import select
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from backend.app import models, utils
9+
10+
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
11+
12+
EMBED_BOT_SIGNATURES: tuple[str, ...] = (
13+
"discordbot",
14+
"twitterbot",
15+
"slackbot",
16+
"facebookexternalhit",
17+
"whatsapp",
18+
)
19+
20+
21+
def is_embed_bot(user_agent: str | None) -> bool:
22+
user_agent_lower = (user_agent or "").lower()
23+
return any(bot in user_agent_lower for bot in EMBED_BOT_SIGNATURES)
24+
25+
26+
async def get_token(db: AsyncSession, token_value: str) -> models.UploadToken | None:
27+
stmt = select(models.UploadToken).where((models.UploadToken.token == token_value) | (models.UploadToken.download_token == token_value))
28+
result = await db.execute(stmt)
29+
return result.scalar_one_or_none()
30+
31+
32+
async def render_embed_preview(request: Request, db: AsyncSession, token_row: models.UploadToken, user: bool = False):
33+
uploads_stmt = (
34+
select(models.UploadRecord)
35+
.where(models.UploadRecord.token_id == token_row.id, models.UploadRecord.status == "completed")
36+
.order_by(models.UploadRecord.created_at.desc())
37+
)
38+
uploads_result = await db.execute(uploads_stmt)
39+
uploads: list[models.UploadRecord] = list(uploads_result.scalars().all())
40+
41+
media_files = [upload for upload in uploads if upload.mimetype and utils.is_multimedia(upload.mimetype)]
42+
if not media_files:
43+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No multimedia uploads found")
44+
45+
first_media = media_files[0]
46+
ffprobe_data = first_media.meta_data.get("ffprobe") if isinstance(first_media.meta_data, dict) else None
47+
video_metadata = utils.extract_video_metadata(ffprobe_data)
48+
mime_type = first_media.mimetype or "application/octet-stream"
49+
is_video = mime_type.startswith("video/")
50+
is_audio = mime_type.startswith("audio/")
51+
52+
return templates.TemplateResponse(
53+
request=request,
54+
name="share_preview.html",
55+
context={
56+
"request": request,
57+
"title": first_media.filename or "Shared Media",
58+
"description": f"{len(uploads)} file(s) shared" if len(uploads) > 1 else "Shared file",
59+
"og_type": "video.other" if is_video else "music.song",
60+
"share_url": str(request.url_for("share_page", token=token_row.download_token)),
61+
"media_url": str(request.url_for("download_file", download_token=token_row.download_token, upload_id=first_media.public_id)),
62+
"mime_type": mime_type,
63+
"is_video": is_video,
64+
"is_audio": is_audio,
65+
"width": video_metadata.get("width"),
66+
"height": video_metadata.get("height"),
67+
"duration": video_metadata.get("duration"),
68+
"duration_formatted": utils.format_duration(video_metadata["duration"]) if video_metadata.get("duration") else None,
69+
"file_size": utils.format_file_size(first_media.size_bytes) if first_media.size_bytes else None,
70+
"other_files": [
71+
{
72+
"name": upload.filename or "Unknown",
73+
"size": utils.format_file_size(upload.size_bytes) if upload.size_bytes else "Unknown",
74+
}
75+
for upload in uploads
76+
if upload.public_id != first_media.public_id
77+
],
78+
"is_user": user,
79+
},
80+
status_code=status.HTTP_200_OK,
81+
)

backend/app/main.py

Lines changed: 33 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from fastapi.concurrency import run_in_threadpool
1010
from fastapi.middleware.cors import CORSMiddleware
1111
from fastapi.responses import FileResponse, JSONResponse
12-
from fastapi.templating import Jinja2Templates
1312

1413
from backend.app import version
1514

@@ -22,13 +21,17 @@
2221

2322

2423
def create_app() -> FastAPI:
24+
from .embed_preview import (
25+
get_token,
26+
is_embed_bot,
27+
render_embed_preview,
28+
)
29+
2530
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
2631

2732
Path(settings.storage_path).mkdir(parents=True, exist_ok=True)
2833
Path(settings.config_path).mkdir(parents=True, exist_ok=True)
2934

30-
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
31-
3235
@asynccontextmanager
3336
async def lifespan(app: FastAPI):
3437
"""
@@ -151,94 +154,41 @@ def app_version() -> dict[str, str]:
151154

152155
frontend_dir: Path = Path(settings.frontend_export_path).resolve()
153156

157+
def serve_static():
158+
if frontend_dir.exists():
159+
index_file = frontend_dir / "index.html"
160+
if index_file.exists():
161+
return FileResponse(index_file, status_code=status.HTTP_200_OK)
162+
163+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
164+
165+
@app.get("/e/{token}", name="token_embed")
166+
@app.get("/e/{token}/")
167+
async def token_embed(request: Request, token: str):
168+
"""Render a static embed preview page."""
169+
if not settings.allow_public_downloads:
170+
return serve_static()
171+
172+
from backend.app.db import get_db
173+
174+
async for db in get_db():
175+
if not (token_row := await get_token(db, token)):
176+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
177+
178+
return await render_embed_preview(request, db, token_row, user=True)
179+
154180
@app.get("/f/{token}", name="share_page")
155181
@app.get("/f/{token}/")
156182
async def share_page(token: str, request: Request, user_agent: Annotated[str | None, Header()] = None):
157183
"""Handle /f/{token} with bot detection for embed preview."""
158-
from sqlalchemy import select
159-
160-
from backend.app import models, utils
161184
from backend.app.db import get_db
162185

163-
user_agent_lower: str = (user_agent or "").lower()
164-
is_bot = any(bot in user_agent_lower for bot in ["discordbot", "twitterbot", "slackbot", "facebookexternalhit", "whatsapp"])
165-
166-
if is_bot and settings.allow_public_downloads:
186+
if is_embed_bot(user_agent) and settings.allow_public_downloads:
167187
async for db in get_db():
168-
stmt = select(models.UploadToken).where((models.UploadToken.token == token) | (models.UploadToken.download_token == token))
169-
result = await db.execute(stmt)
170-
token_row = result.scalar_one_or_none()
171-
172-
if token_row:
173-
uploads_stmt = (
174-
select(models.UploadRecord)
175-
.where(models.UploadRecord.token_id == token_row.id, models.UploadRecord.status == "completed")
176-
.order_by(models.UploadRecord.created_at.desc())
177-
)
178-
uploads_result = await db.execute(uploads_stmt)
179-
uploads = uploads_result.scalars().all()
180-
181-
media_files = [u for u in uploads if u.mimetype and utils.is_multimedia(u.mimetype)]
182-
183-
if media_files:
184-
first_media = media_files[0]
185-
186-
is_video = first_media.mimetype.startswith("video/")
187-
ffprobe_data = None
188-
if first_media.meta_data and isinstance(first_media.meta_data, dict):
189-
ffprobe_data = first_media.meta_data.get("ffprobe")
190-
191-
video_metadata = utils.extract_video_metadata(ffprobe_data)
192-
193-
other_files = [
194-
{
195-
"name": u.filename or "Unknown",
196-
"size": utils.format_file_size(u.size_bytes) if u.size_bytes else "Unknown",
197-
}
198-
for u in uploads
199-
if u.public_id != first_media.public_id
200-
]
201-
202-
media_url = str(
203-
request.url_for("download_file", download_token=token_row.download_token, upload_id=first_media.public_id)
204-
)
205-
share_url = str(request.url_for("share_page", token=token))
206-
207-
is_video = first_media.mimetype.startswith("video/")
208-
is_audio = first_media.mimetype.startswith("audio/")
209-
210-
context = {
211-
"request": request,
212-
"title": first_media.filename or "Shared Media",
213-
"description": f"{len(uploads)} file(s) shared" if len(uploads) > 1 else "Shared file",
214-
"og_type": "video.other" if is_video else "music.song",
215-
"share_url": share_url,
216-
"media_url": media_url,
217-
"mime_type": first_media.mimetype,
218-
"is_video": is_video,
219-
"is_audio": is_audio,
220-
"width": video_metadata.get("width"),
221-
"height": video_metadata.get("height"),
222-
"duration": video_metadata.get("duration"),
223-
"duration_formatted": utils.format_duration(video_metadata["duration"])
224-
if video_metadata.get("duration")
225-
else None,
226-
"file_size": utils.format_file_size(first_media.size_bytes) if first_media.size_bytes else None,
227-
"other_files": other_files,
228-
}
229-
230-
return templates.TemplateResponse(
231-
request=request,
232-
name="share_preview.html",
233-
context=context,
234-
status_code=status.HTTP_200_OK,
235-
)
188+
if token_row := await get_token(db, token):
189+
return await render_embed_preview(request, db, token_row)
236190

237-
if frontend_dir.exists():
238-
index_file = frontend_dir / "index.html"
239-
if index_file.exists():
240-
return FileResponse(index_file, status_code=status.HTTP_200_OK)
241-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
191+
return serve_static()
242192

243193
@app.get("/t/{token}", name="upload_page")
244194
@app.get("/t/{token}/")

backend/app/templates/share_preview.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,33 @@
279279
padding: 1.5rem 0 1rem;
280280
}
281281
}
282+
283+
.fullscreen {
284+
position: fixed;
285+
inset: 0;
286+
width: 100dvw;
287+
height: 100dvh;
288+
object-fit: contain;
289+
background: #000;
290+
max-height: unset !important;
291+
}
282292
</style>
283293
</head>
284294

285295
<body>
296+
{% if is_user %}
297+
{% if is_video %}
298+
<video class="fullscreen" controls autoplay preload="metadata" playsinline>
299+
<source src="{{ media_url }}" mime="{{ mime_type }}" />
300+
Your browser does not support the video tag.
301+
</video>
302+
{% elif is_audio %}
303+
<audio class="fullscreen" controls autoplay preload="metadata">
304+
<source src="{{ media_url }}" mime="{{ mime_type }}" />
305+
Your browser does not support the audio tag.
306+
</audio>
307+
{% endif %}
308+
{% else %}
286309
<div class="container">
287310
<div class="header">
288311
<h1>{{ title }}</h1>
@@ -355,6 +378,7 @@ <h2>File Information</h2>
355378
</div>
356379
{% endif %}
357380
</div>
381+
{% endif %}
358382
</body>
359383

360384
</html>

backend/tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
os.environ["FBC_ADMIN_API_KEY"] = "test-admin"
2020
os.environ["FBC_SKIP_MIGRATIONS"] = "1"
2121
os.environ["FBC_SKIP_CLEANUP"] = "1"
22+
os.environ["FBC_ALLOW_PUBLIC_DOWNLOADS"] = "0"
2223

2324
ROOT = Path(__file__).resolve().parent.parent.parent
2425
if str(ROOT) not in sys.path:

backend/tests/test_share_view.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,53 @@ async def test_share_page_bot_preview_with_video(client):
129129
html_content = response.text
130130
assert "og:video" in html_content or "og:type" in html_content, "Should include OpenGraph video metadata"
131131
assert "sample.mp4" in html_content, "Should include video filename"
132+
133+
134+
@pytest.mark.asyncio
135+
async def test_token_embed_page_renders_preview_for_public_token(client):
136+
"""Test that static embed endpoint renders preview HTML for a shared media token."""
137+
with patch("backend.app.security.settings.allow_public_downloads", True):
138+
token_data = await create_token(client, max_uploads=1)
139+
token_value = token_data["token"]
140+
public_token = token_data["download_token"]
141+
142+
video_file = Path(__file__).parent / "fixtures" / "sample.mp4"
143+
file_size = video_file.stat().st_size
144+
145+
from backend.app.main import app
146+
147+
init_resp = await client.post(
148+
app.url_path_for("initiate_upload"),
149+
json={
150+
"filename": "sample.mp4",
151+
"filetype": "video/mp4",
152+
"size_bytes": file_size,
153+
"meta_data": {},
154+
},
155+
params={"token": token_value},
156+
)
157+
assert init_resp.status_code == status.HTTP_201_CREATED, "Upload initiation should succeed"
158+
upload_data = init_resp.json()
159+
upload_id = upload_data["upload_id"]
160+
161+
patch_resp = await client.patch(
162+
app.url_path_for("tus_patch", upload_id=upload_id),
163+
content=video_file.read_bytes(),
164+
headers={
165+
"Content-Type": "application/offset+octet-stream",
166+
"Upload-Offset": "0",
167+
"Content-Length": str(file_size),
168+
},
169+
)
170+
assert patch_resp.status_code == status.HTTP_204_NO_CONTENT, "Video upload should complete"
171+
172+
completed = await wait_for_processing([upload_id], timeout=10.0)
173+
assert completed, "Video processing should complete within timeout"
174+
175+
response = await client.get(app.url_path_for("token_embed", token=public_token))
176+
177+
assert response.status_code == status.HTTP_200_OK, "Embed endpoint should return HTML for valid shared media"
178+
html_content = response.text
179+
assert "og:video" in html_content or "og:type" in html_content, "Embed page should include OpenGraph video metadata"
180+
assert "sample.mp4" in html_content, "Embed page should include video filename"
181+

0 commit comments

Comments
 (0)