Processed some UI issues on journal

This commit is contained in:
StormRunner06106
2026-09-12 22:10:44 -07:00
parent 737b56e97e
commit 206bbbc5ed
27 changed files with 1245 additions and 327 deletions
+112 -42
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import smtplib
@@ -23,7 +24,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
from supabase import Client, create_client
from httpx import TransportError
from supabase import Client, ClientOptions, PostgrestAPIError, create_client
BASE_DIR = Path(__file__).resolve().parent
@@ -36,6 +38,7 @@ POSTS_LOCK = threading.Lock()
ADMIN_TOKEN_TTL = 60 * 60 * 4
UPLOAD_DIR = Path(os.getenv("JOURNAL_UPLOAD_DIR", str(DATA_DIR / "uploads"))).resolve()
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
logger = logging.getLogger("uvicorn.error")
class ArticleMedia(BaseModel):
@@ -60,7 +63,7 @@ app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
@@ -111,7 +114,11 @@ def get_supabase() -> Client | None:
status_code=500,
detail="Supabase configuration is incomplete.",
)
return create_client(url, key)
return create_client(url, key, options=ClientOptions(
postgrest_client_timeout=8,
auto_refresh_token=False,
persist_session=False,
))
def articles_table() -> str:
@@ -121,6 +128,25 @@ def articles_table() -> str:
return table
def read_article_query(query: Any) -> Any:
# Bound reads to two attempts, including SDK retries. Never retry mutations.
for attempt in range(2):
try:
return query.retry(False).execute()
except Exception as exc:
code = str(getattr(exc, "code", ""))
transient = isinstance(exc, TransportError) or (
isinstance(exc, PostgrestAPIError) and code in {"500", "502", "503", "504", "520", "522", "524"}
)
# Log the error type and code, not credentials, article data, or response bodies.
safe_code = code if re.fullmatch(r"[A-Za-z0-9_]{1,20}", code) else "unknown"
logger.warning("Journal read failed: type=%s code=%s attempt=%s", type(exc).__name__, safe_code, attempt + 1)
if transient and attempt == 0:
time.sleep(0.2)
continue
raise HTTPException(status_code=502, detail="The journal store is temporarily unavailable. Please try again.") from exc
def article_records(include_content: bool = True) -> list[dict[str, Any]]:
client = get_supabase()
if client is None:
@@ -135,18 +161,9 @@ def article_records(include_content: bool = True) -> list[dict[str, Any]]:
columns = "slug,title,excerpt,published_at,read_time,tags,accent,banner,attachments"
if include_content:
columns += ",content"
try:
response = (
client.table(articles_table())
.select(columns)
.order("published_at", desc=True)
.execute()
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="The journal store is temporarily unavailable.",
) from exc
response = read_article_query(
client.table(articles_table()).select(columns).order("published_at", desc=True)
)
return response.data or []
@@ -263,12 +280,13 @@ def admin_login(payload: LoginPayload) -> dict[str, Any]:
"token_type": "bearer",
"expires_at": expires_at,
"expires_in": ADMIN_TOKEN_TTL,
"role": "admin",
}
@app.get("/api/auth/session")
def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]:
return {"authenticated": True}
def admin_session(_: None = Depends(require_admin)) -> dict[str, Any]:
return {"authenticated": True, "role": "admin"}
@app.get("/api/posts")
@@ -349,11 +367,7 @@ def download_media(upload_id: str) -> FileResponse:
)
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
def create_post(
payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
slug = post_slug(payload.title)
def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]:
clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip()))
if not clean_tags:
raise HTTPException(status_code=422, detail="Add at least one topic tag.")
@@ -373,6 +387,25 @@ def create_post(
raise HTTPException(status_code=422, detail="The article body is too short.")
if len(json.dumps(article["content"], ensure_ascii=False)) > 250_000:
raise HTTPException(status_code=422, detail="The formatted article is too large.")
return article
def save_local_posts(records: list[dict[str, Any]]) -> None:
# Call while holding POSTS_LOCK so readers only see a complete replacement.
temporary_path = POSTS_PATH.with_suffix(".json.tmp")
temporary_path.write_text(
json.dumps(records, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary_path.replace(POSTS_PATH)
read_data.cache_clear()
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
def create_post(
payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
slug = post_slug(payload.title)
article = validated_article(payload, slug)
client = get_supabase()
if client is not None:
@@ -409,17 +442,62 @@ def create_post(
detail="A journal post with this title already exists.",
)
current_posts.append(article)
temporary_path = POSTS_PATH.with_suffix(".json.tmp")
temporary_path.write_text(
json.dumps(current_posts, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
temporary_path.replace(POSTS_PATH)
read_data.cache_clear()
save_local_posts(current_posts)
return article
@app.put("/api/posts/{slug}")
def update_post(
slug: str, payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
# Keep published URLs stable when an admin changes the title.
article = validated_article(payload, slug)
client = get_supabase()
if client is not None:
try:
response = client.table(articles_table()).update(article).eq("slug", slug).execute()
except Exception as exc:
raise HTTPException(status_code=502, detail="The article could not be updated. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
return response.data[0]
with POSTS_LOCK:
records = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
for index, existing in enumerate(records):
if existing["slug"] == slug:
records[index] = {**existing, **article}
save_local_posts(records)
return records[index]
raise HTTPException(status_code=404, detail="Post not found")
@app.delete("/api/posts/{slug}")
def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
client = get_supabase()
if client is not None:
try:
response = client.table(articles_table()).delete().eq("slug", slug).execute()
except Exception as exc:
raise HTTPException(status_code=502, detail="The article could not be deleted. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
return {"deleted": True}
with POSTS_LOCK:
records = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
remaining = [post for post in records if post["slug"] != slug]
if len(remaining) == len(records):
raise HTTPException(status_code=404, detail="Post not found")
save_local_posts(remaining)
return {"deleted": True}
@app.get("/api/posts/{slug}")
def post_by_slug(slug: str) -> dict[str, Any]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
@@ -427,19 +505,11 @@ def post_by_slug(slug: str) -> dict[str, Any]:
client = get_supabase()
if client is not None:
try:
response = (
client.table(articles_table())
.select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments")
.eq("slug", slug)
.limit(1)
.execute()
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="The journal store is temporarily unavailable.",
) from exc
response = read_article_query(
client.table(articles_table())
.select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments")
.eq("slug", slug).limit(1)
)
if response.data:
return response.data[0]
raise HTTPException(status_code=404, detail="Post not found")