Recompress uploaded images to thumbnailed AVIF via mediapreview
PUT /_api/files now runs raster uploads through mediapreview.dispatch (temp file for format routing: pyvips, ffmpeg for HEIC/HEIF/AVIF), storing the untouched original as <hash>.orig<ext> and serving the AVIF derivative <hash>.avif in links. SVG/GIF and failed conversions fall back to plain <hash><ext> storage. FileStore.delete removes the whole hash pair. Adds mediapreview[standard] dependency.
This commit is contained in:
@@ -14,7 +14,7 @@ Siblings order by the fractional `Node.order` key: a moved item gets a fresh key
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching; the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
|
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except SVG/GIF) are stored as a pair: the untouched original under `<hash>.orig<ext>` and a mediapreview-recompressed AVIF derivative (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`) which is the externally linked file; deleting either name removes the pair. the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
|
||||||
|
|
||||||
## Banners
|
## Banners
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
||||||
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
||||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
||||||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Raster images (not SVG/GIF) are recompressed via mediapreview: the original is kept as `/_f/{hash}.orig{ext}` while pages link the thumbnailed AVIF derivative `/_f/{hash}.avif`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
||||||
|
|
||||||
## Page structure and navigation
|
## Page structure and navigation
|
||||||
|
|
||||||
|
|||||||
+65
-5
@@ -20,6 +20,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
import tempfile
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -42,6 +43,7 @@ from fastapi import (
|
|||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
from kanta import Kanta
|
from kanta import Kanta
|
||||||
|
from mediapreview import dispatch
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
@@ -78,6 +80,11 @@ analytics_store = analytics.Store(ANALYTICS_PATH)
|
|||||||
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
|
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
|
||||||
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
|
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
|
||||||
|
|
||||||
|
# Uploaded raster images are thumbnailed to this size and recompressed to
|
||||||
|
# AVIF; the untouched original is kept alongside as ``<hash>.orig<ext>``.
|
||||||
|
IMAGE_MAXSIZE = 1920
|
||||||
|
IMAGE_QUALITY = 60
|
||||||
|
|
||||||
# Live WebSocket clients for the analytics stream.
|
# Live WebSocket clients for the analytics stream.
|
||||||
_analytics_ws_clients: set[WebSocket] = set()
|
_analytics_ws_clients: set[WebSocket] = set()
|
||||||
_analytics_broadcast_task: asyncio.Task | None = None
|
_analytics_broadcast_task: asyncio.Task | None = None
|
||||||
@@ -181,10 +188,14 @@ BUILD_DIR = Path(__file__).with_name("frontend-build")
|
|||||||
frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
|
frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
|
||||||
|
|
||||||
|
|
||||||
|
def _ext(orig: str) -> str:
|
||||||
|
"""Sanitized lowercase extension (with dot) of an original file name."""
|
||||||
|
return "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
|
||||||
|
|
||||||
|
|
||||||
def _hash_name(body: bytes, orig: str) -> str:
|
def _hash_name(body: bytes, orig: str) -> str:
|
||||||
"""Content-addressed file name: blake3 hash prefix + original extension."""
|
"""Content-addressed file name: blake3 hash prefix + original extension."""
|
||||||
ext = "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
|
return blake3.blake3(body).hexdigest()[:12] + _ext(orig)
|
||||||
return blake3.blake3(body).hexdigest()[:12] + ext
|
|
||||||
|
|
||||||
|
|
||||||
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
|
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
|
||||||
@@ -350,9 +361,17 @@ class FileStore:
|
|||||||
self._cache[name] = self._entry(body)
|
self._cache[name] = self._entry(body)
|
||||||
|
|
||||||
def delete(self, name: str) -> None:
|
def delete(self, name: str) -> None:
|
||||||
self._cache.pop(name, None)
|
"""Delete a file plus its derivative/original counterpart, if any.
|
||||||
|
|
||||||
|
An image upload is stored as a pair sharing the hash prefix
|
||||||
|
(``<hash>.orig.<ext>`` + ``<hash>.avif``); deleting either removes
|
||||||
|
both.
|
||||||
|
"""
|
||||||
|
stem = name.partition(".")[0]
|
||||||
|
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
|
||||||
|
self._cache.pop(key, None)
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
(self.path / name).unlink()
|
(self.path / key).unlink()
|
||||||
|
|
||||||
def __contains__(self, name: str) -> bool:
|
def __contains__(self, name: str) -> bool:
|
||||||
return name in self._cache
|
return name in self._cache
|
||||||
@@ -656,19 +675,60 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
|
|||||||
return {"markdown": new_markdown}
|
return {"markdown": new_markdown}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_avif(body: bytes, ext: str) -> bytes | None:
|
||||||
|
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
|
||||||
|
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
|
||||||
|
None if the body is not a decodable image (stored as-is by the caller).
|
||||||
|
Dispatch needs a real file for format routing, so the body goes
|
||||||
|
through a temp file.
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=ext) as tmp:
|
||||||
|
tmp.write(body)
|
||||||
|
tmp.flush()
|
||||||
|
try:
|
||||||
|
avif, _resp = dispatch(
|
||||||
|
Path(tmp.name),
|
||||||
|
quality=IMAGE_QUALITY,
|
||||||
|
maxsize=IMAGE_MAXSIZE,
|
||||||
|
maxzoom=1,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return avif
|
||||||
|
|
||||||
|
|
||||||
@app.put("/_api/files/{name}")
|
@app.put("/_api/files/{name}")
|
||||||
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
||||||
"""Store an upload (image, video...) in the content-addressed store.
|
"""Store an upload (image, video...) in the content-addressed store.
|
||||||
|
|
||||||
The stored name is a blake3 hash prefix + the original extension,
|
The stored name is a blake3 hash prefix + the original extension,
|
||||||
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
|
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
|
||||||
|
|
||||||
|
Raster images are additionally recompressed with mediapreview: the
|
||||||
|
original goes to ``<hash>.orig<ext>`` (kept for reprocessing) while
|
||||||
|
pages link the thumbnailed AVIF derivative ``<hash>.avif``. SVGs and
|
||||||
|
GIFs are stored as-is (vector/animation would be lost). Other content
|
||||||
|
and failed conversions fall back to plain storage.
|
||||||
"""
|
"""
|
||||||
if "/" in name or name in {".", ".."}:
|
if "/" in name or name in {".", ".."}:
|
||||||
raise HTTPException(400, "bad file name")
|
raise HTTPException(400, "bad file name")
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
stored = _hash_name(body, name)
|
if not body:
|
||||||
|
raise HTTPException(400, "empty file")
|
||||||
|
ext = _ext(name)
|
||||||
|
digest = blake3.blake3(body).hexdigest()[:12]
|
||||||
|
avif = (
|
||||||
|
None
|
||||||
|
if ext in {".svg", ".gif"}
|
||||||
|
else await asyncio.to_thread(_to_avif, body, ext)
|
||||||
|
)
|
||||||
|
if avif is None: # not a decodable image: store the body as-is
|
||||||
|
stored = digest + ext
|
||||||
file_store.put(stored, body)
|
file_store.put(stored, body)
|
||||||
return {"path": f"/_f/{stored}"}
|
return {"path": f"/_f/{stored}"}
|
||||||
|
file_store.put(f"{digest}.orig{ext}", body)
|
||||||
|
file_store.put(f"{digest}.avif", avif)
|
||||||
|
return {"path": f"/_f/{digest}.avif"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/_api/files/{name}", status_code=204)
|
@app.delete("/_api/files/{name}", status_code=204)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"markdown-it-py>=4.2.0",
|
"markdown-it-py>=4.2.0",
|
||||||
"maxminddb>=3.1.1",
|
"maxminddb>=3.1.1",
|
||||||
"mdit-py-plugins>=0.6.1",
|
"mdit-py-plugins>=0.6.1",
|
||||||
|
"mediapreview[standard]>=0.2.3",
|
||||||
"platformdirs>=4.11.5",
|
"platformdirs>=4.11.5",
|
||||||
"pygments>=2.20.0",
|
"pygments>=2.20.0",
|
||||||
"python-slugify>=8.0.4",
|
"python-slugify>=8.0.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user