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")
+1
View File
@@ -1,4 +1,5 @@
fastapi
httpx
Pillow
uvicorn[standard]
email-validator
+96 -1
View File
@@ -4,10 +4,12 @@ import os
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from PIL import Image
from httpx import ReadTimeout, RemoteProtocolError
from supabase import PostgrestAPIError
from backend import main
@@ -79,6 +81,99 @@ class ArticleMediaTests(unittest.TestCase):
self.assertEqual(downloaded.headers["x-content-type-options"], "nosniff")
self.assertIn("attachment", downloaded.headers["content-disposition"])
def article_payload(self):
return {
"title": "An editable article", "excerpt": "An introduction with enough detail for readers.",
"published_at": "2026-09-12", "read_time": 3, "tags": ["Custom topic"],
"content": {"type": "doc", "content": [{"type": "paragraph", "content": [
{"type": "text", "text": "An original article body with enough text to validate and publish."}
]}]},
}
def test_admin_role_and_protected_crud(self):
self.assertEqual(self.client.post("/api/auth/login", json={"password": "wrong"}).status_code, 401)
session = self.client.get("/api/auth/session", headers=self.headers)
self.assertEqual(session.json(), {"authenticated": True, "role": "admin"})
payload = self.article_payload()
self.assertEqual(self.client.post("/api/posts", json=payload).status_code, 401)
created = self.client.post("/api/posts", json=payload, headers=self.headers).json()
path = f'/api/posts/{created["slug"]}'
for headers in ({}, {"Authorization": "Bearer 1.invalid"}):
self.assertEqual(self.client.put(path, json=payload, headers=headers).status_code, 401)
self.assertEqual(self.client.delete(path, headers=headers).status_code, 401)
self.assertEqual(self.client.get(path).json()["title"], payload["title"])
payload["title"] = "The updated article title"
payload["tags"] = ["Updated topic"]
payload["attachments"] = [self.upload(b"updated notes", name="notes.txt").json()]
updated = self.client.put(path, json=payload, headers=self.headers)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["slug"], created["slug"])
self.assertEqual(self.client.get(path).json()["attachments"], payload["attachments"])
self.assertEqual(self.client.get("/api/posts").json()[0]["title"], payload["title"])
invalid = {**payload, "content": {"type": "doc", "content": []}}
self.assertEqual(self.client.put(path, json=invalid, headers=self.headers).status_code, 422)
self.assertEqual(self.client.put("/api/posts/missing", json=payload, headers=self.headers).status_code, 404)
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 200)
self.assertEqual(self.client.get(path).status_code, 404)
self.assertEqual(self.client.get("/api/posts").json(), [])
self.assertEqual(json.loads(main.POSTS_PATH.read_text()), [])
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 404)
def test_supabase_update_delete_are_scoped_and_report_failures(self):
client = MagicMock()
table = client.table.return_value
payload = self.article_payload()
table.update.return_value.eq.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
table.delete.return_value.eq.return_value.execute.return_value.data = [{"slug": "original-url"}]
with patch.object(main, "get_supabase", return_value=client):
updated = self.client.put("/api/posts/original-url", json=payload, headers=self.headers)
self.assertEqual(updated.status_code, 200)
table.update.return_value.eq.assert_called_once_with("slug", "original-url")
self.assertEqual(self.client.delete("/api/posts/original-url", headers=self.headers).status_code, 200)
table.delete.return_value.eq.assert_called_once_with("slug", "original-url")
table.update.return_value.eq.return_value.execute.return_value.data = []
self.assertEqual(self.client.put("/api/posts/missing", json=payload, headers=self.headers).status_code, 404)
table.delete.return_value.eq.return_value.execute.return_value.data = []
self.assertEqual(self.client.delete("/api/posts/missing", headers=self.headers).status_code, 404)
table.update.return_value.eq.return_value.execute.side_effect = RuntimeError("offline")
self.assertEqual(self.client.put("/api/posts/original-url", json=payload, headers=self.headers).status_code, 502)
table.delete.return_value.eq.return_value.execute.side_effect = RuntimeError("offline")
self.assertEqual(self.client.delete("/api/posts/original-url", headers=self.headers).status_code, 502)
def test_journal_reads_retry_transient_errors_but_remain_public(self):
client = MagicMock()
query = MagicMock()
query.retry.return_value = query
client.table.return_value.select.return_value.order.return_value = query
client.table.return_value.select.return_value.eq.return_value.limit.return_value = query
article = {**self.article_payload(), "slug": "test-article", "accent": "mint"}
response = MagicMock(data=[article])
with patch.object(main, "get_supabase", return_value=client), patch.object(main.time, "sleep"):
for path in ("/api/posts", "/api/posts/test-article"):
for failure in (
RemoteProtocolError("connection ended"),
ReadTimeout("read timed out"),
PostgrestAPIError({"code": "503", "message": "unavailable"}),
):
query.execute.reset_mock()
query.execute.side_effect = [failure, response]
result = self.client.get(path) # No admin token: reading stays public.
self.assertEqual(result.status_code, 200, result.text)
self.assertEqual(query.execute.call_count, 2)
query.execute.reset_mock()
query.execute.side_effect = ReadTimeout("offline")
self.assertEqual(self.client.get(path).status_code, 502)
self.assertEqual(query.execute.call_count, 2)
query.execute.reset_mock()
query.execute.side_effect = PostgrestAPIError({"code": "42P01", "message": "missing table"})
self.assertEqual(self.client.get(path).status_code, 502)
self.assertEqual(query.execute.call_count, 1)
query.execute.side_effect = None
query.execute.return_value = MagicMock(data=[])
self.assertEqual(self.client.get("/api/posts").json(), [])
self.assertEqual(self.client.get("/api/posts/missing").status_code, 404)
if __name__ == "__main__":
unittest.main()