Configure Vercel deployment and preserve deployed theme support

This commit is contained in:
StormRunner06106
2026-09-13 14:56:10 -07:00
parent d52495945b
commit cb8d062481
27 changed files with 610 additions and 60 deletions
+4
View File
@@ -17,6 +17,10 @@ FRONTEND_ORIGINS=http://localhost:5173
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
# Vercel's daily media cleanup job authenticates with this server-only secret.
# Generate a unique random value of at least 32 characters for production.
CRON_SECRET=
# Persistent upload directory; defaults to backend/data/uploads.
# Mount durable storage here when deploying to a host with an ephemeral filesystem.
# JOURNAL_UPLOAD_DIR=/var/lib/portfolio/uploads
+10
View File
@@ -40,6 +40,16 @@ def upload(data: bytes, upload_id: str, name: str) -> str:
raise HTTPException(502, "Dropbox could not store this file. Please retry.") from exc
def temporary_link(file_id: str) -> str:
"""Serve existing large files directly, outside the function payload limit."""
try:
return get_dropbox().files_get_temporary_link(file_id).link
except AuthError as exc:
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
except (DropboxException, RequestException) as exc:
raise HTTPException(502, "This file is temporarily unavailable from Dropbox. Please retry.") from exc
def download(file_id: str, limit: int) -> bytes:
try:
metadata, response = get_dropbox().files_download(file_id)
+24 -2
View File
@@ -22,7 +22,7 @@ from typing import Any, Literal
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from PIL import Image, UnidentifiedImageError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
from httpx import TransportError
@@ -44,6 +44,10 @@ MAX_UPLOAD_BYTES = 20 * 1024 * 1024
logger = logging.getLogger("uvicorn.error")
def on_vercel() -> bool:
return os.getenv("VERCEL") == "1"
class ArticleMedia(BaseModel):
url: str = Field(pattern=r"^/api/uploads/[a-f0-9]{32}$")
name: str = Field(min_length=1, max_length=200)
@@ -193,6 +197,8 @@ def tiptap_plain_text(document: dict[str, Any]) -> str:
def journal_credentials() -> tuple[str, str]:
if on_vercel() and get_supabase() is None:
raise HTTPException(503, "Configure Supabase before publishing on Vercel.")
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
if not password or not token_secret:
@@ -258,6 +264,15 @@ def health() -> dict[str, str]:
}
@app.get("/api/cron/media-cleanup", include_in_schema=False)
def scheduled_media_cleanup(authorization: str | None = Header(default=None)):
secret = os.getenv("CRON_SECRET", "")
if not secret or not hmac.compare_digest(authorization or "", f"Bearer {secret}"):
raise HTTPException(401, "Unauthorized")
media_cleanup.cleanup_pending()
return {"status": "completed"}
@app.get("/api/profile")
def profile() -> dict[str, Any]:
return read_data("profile.json")
@@ -344,6 +359,8 @@ def save_media_metadata(upload_id: str, metadata: dict[str, Any]) -> None:
def store_upload(data: bytes, upload_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
storage = os.getenv("JOURNAL_MEDIA_STORAGE", "dropbox").strip().lower()
if on_vercel() and (storage != "dropbox" or get_supabase() is None):
raise HTTPException(503, "Vercel uploads require Dropbox and Supabase storage.")
if storage == "dropbox":
metadata = {**metadata, "storage": "dropbox", "dropbox_file_id": dropbox_storage.upload(data, upload_id, metadata["name"])}
save_media_metadata(upload_id, metadata)
@@ -364,6 +381,8 @@ async def upload_media(
_: None = Depends(require_admin),
) -> dict[str, Any]:
limit = 8 * 1024 * 1024 if purpose == "banner" else MAX_UPLOAD_BYTES
if on_vercel():
limit = min(limit, 4 * 1024 * 1024)
data = bytearray()
async for chunk in request.stream():
data.extend(chunk)
@@ -389,6 +408,9 @@ async def upload_media(
def download_media(upload_id: str):
media = uploaded_media(upload_id)
if media.get("storage") == "dropbox":
if on_vercel() and media["size"] > 4 * 1024 * 1024:
return RedirectResponse(dropbox_storage.temporary_link(media["dropbox_file_id"]),
status_code=307, headers={"Cache-Control": "no-store"})
data = dropbox_storage.download(media["dropbox_file_id"], MAX_UPLOAD_BYTES)
disposition = "inline" if media["media_type"].startswith("image/") else "attachment"
return Response(data, media_type=media["media_type"], headers={
@@ -656,7 +678,7 @@ async def contact(payload: ContactPayload) -> dict[str, str]:
return {"status": "accepted", "message": "Thanks — your note is on its way."}
if FRONTEND_DIST.is_dir():
if FRONTEND_DIST.is_dir() and not on_vercel():
@app.get("/{path:path}", include_in_schema=False)
def frontend(path: str) -> FileResponse:
+6
View File
@@ -130,6 +130,12 @@ def cleanup_pending():
@asynccontextmanager
async def lifespan(app):
from backend import main
if main.on_vercel():
# Serverless instances can stop between requests. Cron retries the
# durable queue; article mutations still attempt cleanup immediately.
yield
return
async def retry():
while True:
await run_in_threadpool(cleanup_pending)
+1 -7
View File
@@ -1,7 +1 @@
fastapi
httpx
dropbox==12.2.1
Pillow
uvicorn[standard]
email-validator
supabase==2.31.0
-r ../requirements.txt
+72
View File
@@ -0,0 +1,72 @@
"""Serverless persistence, payload limits, and scheduled maintenance checks."""
import os
import unittest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
from backend import main
class VercelTests(unittest.TestCase):
def setUp(self):
self.client = TestClient(main.app)
environment = patch.dict(os.environ, {"VERCEL": "1", "CRON_SECRET": "test-cron-secret"})
environment.start()
self.addCleanup(environment.stop)
def test_no_publishing_without_durable_database(self):
with patch.object(main, "get_supabase", return_value=None):
response = self.client.post("/api/auth/login", json={"password": "example"})
self.assertEqual(response.status_code, 503)
def test_no_local_uploads_even_with_database(self):
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "local"}), \
patch.object(main, "get_supabase", return_value=MagicMock()), \
patch.object(main.Path, "mkdir") as mkdir:
with self.assertRaises(HTTPException) as error:
main.store_upload(b"file", "a" * 32, {"name": "file.txt"})
self.assertEqual(error.exception.status_code, 503)
mkdir.assert_not_called()
def test_oversized_upload_is_rejected_before_storage(self):
main.app.dependency_overrides[main.require_admin] = lambda: None
try:
with patch.object(main, "store_upload") as store:
response = self.client.post("/api/uploads?name=large.txt",
content=b"x" * (4 * 1024 * 1024 + 1))
self.assertEqual(response.status_code, 413)
store.assert_not_called()
finally:
main.app.dependency_overrides.pop(main.require_admin, None)
def test_large_existing_download_bypasses_function_payload(self):
media = {"storage": "dropbox", "size": 5 * 1024 * 1024, "dropbox_file_id": "id:abc"}
with patch.object(main, "uploaded_media", return_value=media), \
patch.object(main.dropbox_storage, "temporary_link", return_value="https://example.com/file"), \
patch.object(main.dropbox_storage, "download") as download:
response = self.client.get("/api/uploads/" + "a" * 32, follow_redirects=False)
self.assertEqual(response.status_code, 307)
self.assertEqual(response.headers["cache-control"], "no-store")
download.assert_not_called()
def test_cron_requires_secret_and_runs_cleanup(self):
with patch.object(main.media_cleanup, "cleanup_pending") as cleanup:
for headers in ({}, {"Authorization": "Bearer wrong"}):
self.assertEqual(self.client.get("/api/cron/media-cleanup", headers=headers).status_code, 401)
cleanup.assert_not_called()
response = self.client.get("/api/cron/media-cleanup",
headers={"Authorization": "Bearer test-cron-secret"})
self.assertEqual(response.status_code, 200)
cleanup.assert_called_once()
def test_vercel_lifespan_does_not_start_background_loop(self):
with patch.object(main.media_cleanup.asyncio, "create_task") as create_task:
with TestClient(main.app) as client:
self.assertEqual(client.get("/api/profile").status_code, 200)
create_task.assert_not_called()
if __name__ == "__main__":
unittest.main()