From e42646e09efede99aef66ff77ee75586150d1cae Mon Sep 17 00:00:00 2001 From: Mahdi Dibaiee Date: Sat, 19 Sep 2026 15:34:17 +0330 Subject: [PATCH] Publish snippets and Markdown posts through authenticated uploads --- .github/workflows/publish.yml | 4 + _includes/head.html | 1 + photo-upload/Containerfile | 2 +- photo-upload/README.md | 106 ++++++++++++++++++ photo-upload/app.py | 97 ++++++++++++++-- photo-upload/host-nginx.conf.example | 3 +- photo-upload/posts.py | 161 +++++++++++++++++++++++++++ photo-upload/publisher.py | 46 +++++++- photo-upload/requirements.txt | 1 + photo-upload/tests/test_posts.py | 81 ++++++++++++++ photo-upload/tests/test_upload.py | 56 ++++++++++ scripts/photo-manifest.py | 4 + scripts/tests/test_deployment.py | 4 +- snippets.md | 2 + 14 files changed, 553 insertions(+), 15 deletions(-) create mode 100644 photo-upload/posts.py create mode 100644 photo-upload/tests/test_posts.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4d59ad6..578d48c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,10 @@ jobs: persist-credentials: false - name: Test deployment scripts run: python3 -m unittest discover -s scripts/tests -v + - name: Test publishing service + run: | + python3 -m pip install -r photo-upload/requirements.txt 'pytest>=8,<10' + python3 -m pytest -q photo-upload/tests - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1 with: ruby-version: '3.1.2' diff --git a/_includes/head.html b/_includes/head.html index bafd179..807e62b 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -2,6 +2,7 @@ + {% if page.upload_revision %}{% endif %} {% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %} diff --git a/photo-upload/Containerfile b/photo-upload/Containerfile index 80fe5e4..147d03c 100644 --- a/photo-upload/Containerfile +++ b/photo-upload/Containerfile @@ -3,6 +3,6 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends git openssh-client && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -COPY app.py publisher.py ./ +COPY app.py publisher.py posts.py ./ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 GIT_TERMINAL_PROMPT=0 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "2", "--timeout", "180", "app:create_app()"] diff --git a/photo-upload/README.md b/photo-upload/README.md index a9d8358..de3d6ce 100644 --- a/photo-upload/README.md +++ b/photo-upload/README.md @@ -155,6 +155,112 @@ The token is visible in the shortcut editor; remove it before sharing the shortc Rotate it by replacing the token file and recreating the upload container with `podman compose up -d --force-recreate upload`. +## Snippets shortcut + +The same service and token also publish to `https://theread.me/snippets/`. +Text stays literal (including line breaks and Persian text); standalone HTTP/HTTPS +URL lines become clickable links. Images use the same optimization and metadata +removal as photo uploads. Each submission is prepended below the introduction, +with a separator from the previous snippet. Existing snippets are preserved. + +Create **Publish snippet**: + +1. Enable **Show in Share Sheet**, accepting **Text**, **URLs**, and **Images**. +2. **Repeat with Each** item in **Shortcut Input**. Use **Get Type** on the repeat + item. If it is **Image**, add the item to a variable called **Snippet Images**. + Otherwise use **Get Text from Input** on the item and add its result to + **Snippet Text**. These variables accumulate the shared items during this run. +3. **Combine Text** from **Snippet Text**, separated by **New Lines**. For an + image-only share, leave the text empty. Don't fetch the contents of shared URLs: + send the link itself. +4. **Ask for Input** (Text): “Note (optional)”. Keep its output separate from the + shared text. You can also run the shortcut without shared input and use a text + prompt as **Snippet Text** to write a new snippet. +5. **Get Contents of URL**: `https://theread.me/api/snippets`, method **POST**, + request body **Form**: + - Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos). + - `text`: **Text**, the combined shared text. + - `caption`: **Text**, the optional note. + - `photos`: **File**, **Snippet Images**, when sharing images. For text-only + requests, omit this field. Use an **If** on whether there are images to choose + between the form with `photos` and the text-only form. + - Delete any blank header rows. Do not manually set `Content-Type`. +6. **Show Result** using the actual response. A response with `error` means the + upload failed; don't show a fixed success message. `queued` means accepted; + publishing happens afterwards. The returned `status_url` can be polled with the + same authorization header; `published` confirms deployment. + +For the simplest initial shortcut, accept just Text and URLs and omit the image +branch and `photos` field; add the image branch when that works on your phone. + +`POST /api/snippets` accepts up to 20,000 characters of `text`, an optional +4,000-character `caption`, and up to 20 images in repeated `photos` parts, within +the existing 100 MiB request limit. At least text or an image is required. +It returns `id`, `count` (image count), `url`, `status_url`, and `status`. +`GET /api/snippets/` checks publication, and +`POST /api/snippets//retry` retries failed Git publication. Identical text, +note, and original images in the same order are deduplicated. + +Keep `` in `snippets.md`: it marks the insertion point. +The publisher fails safely if that marker is removed. GitHub Actions includes +rendered snippet IDs in `photo-publication.json`, alongside photo IDs. + +## Markdown blog-post shortcut + +Create **Publish blog post** and enable **Show in Share Sheet**, accepting **Files**. +Share one UTF-8 `.md` or `.markdown` file, up to 1 MiB. In **Get Contents of URL**: + +- URL: `https://theread.me/api/posts`; method **POST**; request body **Form**. +- Header: `Authorization` = `Bearer YOUR_TOKEN` (no empty header rows). +- Form field `file`: type **File**, value **Shortcut Input**. +- Let Shortcuts set `Content-Type` automatically. + +Show the actual response, and poll `https://theread.me` plus its `status_url` +with the same header. Matching happens in the publisher: `202` only means the +file was accepted, not that a new post or update was published. The status +response supplies the final `url`, `post_id`, and `action` (`created` or `updated`). +`failed` includes an error; `published` confirms the deployed revision. An older +upload can become `superseded` when a newer revision replaces it. + +No front matter is required. The first `# Heading` becomes the title, or the +filename is used if there is no heading. Optional YAML front matter supports +`title`, `subtitle`, `lang`, `description`, `categories`, `tags`, `toc`, `math`, +`date`, and `permalink` (a path such as `/my-essay/`). Markdown formatting is +preserved; Liquid template evaluation is disabled for imported post bodies. +The original file is retained privately in the service volume. + +### Automatic updates by content + +The publisher compares the body against existing Markdown posts, ignoring front +matter and whitespace changes. It updates a unique match with at least 92% word +sequence similarity, at least 200 characters in both texts, and a lead of at +least 8 percentage points over the next plausible match. An exact body match of +at least 40 characters also qualifies. These scores are a conservative heuristic, +not a probability. Renaming the file does not prevent matching; reusing a filename +for unrelated writing does not overwrite its previous post. + +If several posts match closely, the upload fails without changing Git. If no +post meets the threshold, it creates a new post. Large rewrites and very short +posts may therefore need an explicit ID. For a known imported post, add the +returned `post_id` to its front matter to target it directly: + +```yaml +--- +title: My revised essay +post_id: THE_ID_FROM_THE_UPLOAD_STATUS +--- +``` + +For an older Git-authored post, first add a unique `post_id` to that existing +post's front matter and use the same ID in the uploaded file. Updates preserve +the original publication date and public URL, and remain reversible through Git. +Identical file retries are deduplicated and do not roll back a newer revision. + +The upload takes the Markdown file only. Relative links to images or attachments +in an export folder will not upload those files; use existing public image URLs. +Export Markdown from the source app before sharing; proprietary note files, +PDFs, ZIP bundles, and rich-text files are not Markdown uploads. + ## API, failures, and backups All API routes require the bearer token: diff --git a/photo-upload/app.py b/photo-upload/app.py index 5cb4e2a..6f19eb5 100644 --- a/photo-upload/app.py +++ b/photo-upload/app.py @@ -17,6 +17,7 @@ from flask import Flask, abort, jsonify, request from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError from pillow_heif import register_heif_opener from werkzeug.exceptions import HTTPException +from posts import parse_upload register_heif_opener() Image.MAX_IMAGE_PIXELS = 60_000_000 @@ -67,7 +68,8 @@ def web_image(raw, destination): def create_app(data_dir=None, token=None): app = Flask(__name__) app.config.update(MAX_CONTENT_LENGTH=100 * 1024 * 1024, - MAX_FORM_MEMORY_SIZE=64 * 1024, MAX_FORM_PARTS=40) + # Must exceed Werkzeug's 64 KiB multipart read buffer plus headers. + MAX_FORM_MEMORY_SIZE=512 * 1024, MAX_FORM_PARTS=40) data = Path(data_dir or os.environ.get("PHOTO_DATA", "/data")) if token is None: token = Path(os.environ.get("PHOTO_TOKEN_FILE", "/run/secrets/photo_token")).read_text().strip() @@ -92,22 +94,78 @@ def create_app(data_dir=None, token=None): def health(): return {"status": "ok"} - def response_for(job): + def response_for(job, kind="photo"): manifest_path = data / "submissions" / job / "manifest.json" if not re.fullmatch(r"[a-f0-9]{64}", job) or not manifest_path.is_file(): abort(404) manifest = json.loads(manifest_path.read_text()) + if manifest.get("kind", "photo") != kind: + abort(404) status_path = data / "status" / (job + ".json") state = json.loads(status_path.read_text()) if status_path.exists() else {"status": "queued"} if state["status"] == "pushed": deployed = Path(os.environ.get("PHOTO_DEPLOYMENT_FILE", "/published/current/photo-publication.json")) try: - if job in json.loads(deployed.read_text())["photos"]: + if job in json.loads(deployed.read_text()).get(kind + "s", []): state["status"] = "published" except (OSError, ValueError, KeyError): pass # A successful push is not yet a successful deployment. - return dict(id=job, count=manifest["count"], **state, - status_url=f"/api/photos/{job}", url=f"/art#photo-{job}") + page = "art" if kind == "photo" else "snippets/" + result = dict(id=job, count=manifest["count"], + status_url=f"/api/{kind}s/{job}", + url=None if kind == "post" else f"/{page}#{kind}-{job}") + result.update(state) + return result + + @app.get("/api/posts/") + def post_status(job): + return response_for(job, "post") + + @app.post("/api/posts//retry") + def post_retry(job): + current = response_for(job, "post") + if current["status"] == "failed": + atomic_json(data / "status" / (job + ".json"), {"status": "queued"}) + return response_for(job, "post"), 202 + + @app.post("/api/posts") + def upload_post(): + files = request.files.getlist("file") + if len(files) != 1: + abort(400, "Send one Markdown file in the file form field") + raw = files[0].read(1024 * 1024 + 1) + try: + incoming = parse_upload(files[0].filename, raw) + except ValueError as error: + abort(400, str(error)) + job = hashlib.sha256(b"post\0" + incoming["key"].encode() + b"\0" + raw).hexdigest() + temporary = Path(tempfile.mkdtemp(dir=data / "temporary")) + try: + atomic_json(temporary / "post.json", incoming) + (temporary / "original.md").write_bytes(raw) + atomic_json(temporary / "manifest.json", dict(id=job, kind="post", count=1, + date=datetime.now(timezone.utc).isoformat())) + try: + temporary.rename(data / "submissions" / job) + created = True + except OSError: + if not (data / "submissions" / job / "manifest.json").is_file(): + raise + created = False + return response_for(job, "post"), 202 if created else 200 + finally: + shutil.rmtree(temporary, ignore_errors=True) + + @app.get("/api/snippets/") + def snippet_status(job): + return response_for(job, "snippet") + + @app.post("/api/snippets//retry") + def snippet_retry(job): + current = response_for(job, "snippet") + if current["status"] == "failed": + atomic_json(data / "status" / (job + ".json"), {"status": "queued"}) + return response_for(job, "snippet"), 202 @app.get("/api/photos/") def status(job): @@ -121,10 +179,17 @@ def create_app(data_dir=None, token=None): return response_for(job), 202 @app.post("/api/photos") + @app.post("/api/snippets") def upload(): + kind = "snippet" if request.path == "/api/snippets" else "photo" photos = request.files.getlist("photos") - if not 1 <= len(photos) <= 20: + if not (0 if kind == "snippet" else 1) <= len(photos) <= 20: abort(400, "Send between 1 and 20 files in the photos form field") + text = request.form.get("text", "").strip() if kind == "snippet" else "" + if len(text) > 20000: + abort(400, "Snippet text must be at most 20000 characters") + if kind == "snippet" and not text and not photos: + abort(400, "Send text or images for the snippet") caption = request.form.get("caption", "").strip() if len(caption) > 4000: abort(400, "Caption must be at most 4000 characters") @@ -133,6 +198,8 @@ def create_app(data_dir=None, token=None): abort(400, "Highlight name must be at most 200 characters") # Ordered byte hashes + caption make retries idempotent, even across restarts. digest = hashlib.sha256(caption.encode()) + if kind == "snippet": + digest.update(b"\0snippet\0" + text.encode()) temporary = Path(tempfile.mkdtemp(dir=data / "temporary")) try: (temporary / "originals").mkdir() @@ -154,6 +221,8 @@ def create_app(data_dir=None, token=None): date = datetime.now(timezone.utc).isoformat() manifest = {"id": job, "date": date, "caption": caption, "highlight": highlight, "count": len(photos)} + if kind == "snippet": + manifest.update(kind=kind, text=text) atomic_json(temporary / "manifest.json", manifest) content = ["---", "layout: post", f'title: "photo-{job}"', f'date: "{date}"', "categories: art"] @@ -169,6 +238,20 @@ def create_app(data_dir=None, token=None): if caption: content.append('' + public_text(caption).replace("\n", "
") + "
") + if kind == "snippet": + content = [f'
', ""] + for paragraph in (text, caption): + if paragraph: + safe = public_text(paragraph) + # Link standalone URL lines; all other shared text stays literal. + lines = [f'{line}' + if re.fullmatch(r"https?://[^\s<>]+", line) else line + for line in safe.splitlines()] + content.append('

' + '
\n'.join(lines) + '

') + for index in range(len(photos)): + content.append(f'

') + content.extend(["", "----", ""]) (temporary / "entry.md").write_text("\n".join(content) + "\n") # Atomic rename handles simultaneous identical submissions without replacing one. try: @@ -178,7 +261,7 @@ def create_app(data_dir=None, token=None): if not (data / "submissions" / job / "manifest.json").is_file(): raise created = False - return response_for(job), 202 if created else 200 + return response_for(job, kind), 202 if created else 200 finally: shutil.rmtree(temporary, ignore_errors=True) diff --git a/photo-upload/host-nginx.conf.example b/photo-upload/host-nginx.conf.example index 810a2dd..ec5eb05 100644 --- a/photo-upload/host-nginx.conf.example +++ b/photo-upload/host-nginx.conf.example @@ -2,8 +2,9 @@ root /home/mahdi/blog-published/current; # Add this alongside the existing /, /raw/, /_xpanel, and TLS configuration. -location /api/photos { +location ~ ^/api/(photos|snippets|posts)(/|$) { client_max_body_size 100m; + client_body_timeout 900s; proxy_read_timeout 180s; proxy_pass http://127.0.0.1:8088; proxy_set_header Host $host; diff --git a/photo-upload/posts.py b/photo-upload/posts.py new file mode 100644 index 0000000..b96df61 --- /dev/null +++ b/photo-upload/posts.py @@ -0,0 +1,161 @@ +"""Markdown import with conservative content matching and explicit ID overrides.""" +from difflib import SequenceMatcher +from datetime import date, datetime +import hashlib +import re +import unicodedata + +import yaml + + +def split_markdown(text): + text = text.replace("\r\n", "\n").lstrip("\ufeff") + if text.startswith("---\n"): + parts = text.split("\n---", 1) + if len(parts) != 2 or (parts[1] and not parts[1].startswith("\n")): + raise ValueError("Markdown front matter must end with a --- line") + try: + metadata = yaml.safe_load(parts[0][4:]) or {} + except yaml.YAMLError: + raise ValueError("Invalid YAML front matter") from None + if not isinstance(metadata, dict): + raise ValueError("Front matter must contain named fields") + return metadata, parts[1].lstrip("\n") + return {}, text + + +def filename_key(filename): + return unicodedata.normalize("NFC", filename).casefold() + + +def parse_upload(filename, raw): + if not filename or "/" in filename or "\\" in filename or not filename.lower().endswith((".md", ".markdown")): + raise ValueError("Share one .md or .markdown file with a plain filename") + if len(filename) > 240 or len(raw) > 1024 * 1024: + raise ValueError("Markdown files must be at most 1 MiB with filenames up to 240 characters") + try: + text = raw.decode("utf-8-sig") + except UnicodeDecodeError: + raise ValueError("Markdown files must use UTF-8") from None + metadata, body = split_markdown(text) + if not body.strip(): + raise ValueError("The Markdown file is empty") + clean = {} + for name in ("title", "subtitle", "lang", "description", "permalink", "post_id"): + if name in metadata: + if not isinstance(metadata[name], str) or len(metadata[name]) > 1000: + raise ValueError(f"{name} must be text of at most 1000 characters") + clean[name] = metadata[name].strip() + for name in ("categories", "tags"): + if name in metadata: + value = metadata[name] + if isinstance(value, str): + value = value.split() + if not isinstance(value, list) or len(value) > 30 or any(not isinstance(item, str) or len(item) > 100 for item in value): + raise ValueError(f"{name} must be a short list of names") + clean[name] = value + for name in ("toc", "math"): + if name in metadata: + if not isinstance(metadata[name], bool): + raise ValueError(f"{name} must be true or false") + clean[name] = metadata[name] + if "date" in metadata: + value = metadata["date"] + if isinstance(value, (date, datetime)): + value = value.isoformat() + try: + datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + raise ValueError("date must be an ISO date or timestamp") from None + clean["date"] = str(value) + explicit_id = clean.pop("post_id", None) + if explicit_id is not None and not re.fullmatch(r"[a-zA-Z0-9_-]{1,80}", explicit_id): + raise ValueError("post_id must be 1–80 letters, digits, underscores, or hyphens") + if "permalink" in clean and not re.fullmatch(r"/[\w/-]+/", clean["permalink"]): + raise ValueError("permalink must be a local path such as /my-post/") + title = clean.get("title") + if not title: + heading = re.search(r"^#\s+(.+)$", body, re.MULTILINE) + clean["title"] = heading.group(1).strip() if heading else re.sub(r"\.(md|markdown)$", "", filename, flags=re.I) + return dict(filename=filename, key=filename_key(filename), explicit_id=explicit_id, + metadata=clean, body=body) + + +def publish_post(repo, submission, manifest): + import json + incoming = json.loads((submission / "post.json").read_text()) + candidates = [] + scored = [] + records = [] + for path in sorted((repo / "_posts").glob("*")): + if path.suffix.lower() not in {".md", ".markdown"}: + continue + metadata, body = split_markdown(path.read_text()) + records.append((path, metadata)) + if incoming["explicit_id"]: + match = metadata.get("upload_id") == incoming["explicit_id"] or metadata.get("post_id") == incoming["explicit_id"] + else: + match = False + score = content_similarity(incoming["body"], body) + if score >= 0.84: + scored.append((score, path, metadata)) + if match: + candidates.append((path, metadata)) + if not incoming["explicit_id"] and scored: + scored.sort(key=lambda item: item[0], reverse=True) + if scored[0][0] >= 0.92: + if len(scored) > 1 and scored[0][0] - scored[1][0] < 0.08: + raise ValueError("Multiple posts have similar content; add the intended post_id to disambiguate") + candidates.append(scored[0][1:]) + if len(candidates) > 1: + raise ValueError("More than one post matches this post ID") + identity = incoming["explicit_id"] or manifest["id"] + if candidates: + entry, metadata = candidates[0] + identity = metadata.get("upload_id", identity) + # Keep the public URL and original publication date during updates. + metadata.update({key: value for key, value in incoming["metadata"].items() if key not in {"date", "permalink"}}) + permalink = metadata.get("permalink") or "/" + re.sub(r"^\d{4}-\d{2}-\d{2}-", "", entry.stem) + "/" + else: + metadata = dict(incoming["metadata"]) + metadata.setdefault("date", manifest["date"]) + slug = re.sub(r"[^\w-]+", "-", incoming["metadata"]["title"].lower()).strip("-")[:80] or "post" + permalink = metadata.get("permalink", f"/{slug}-{identity[:8]}/") + entry = repo / "_posts" / f"{str(metadata['date'])[:10]}-upload-{identity}.md" + if entry.exists(): + raise ValueError("Post identity conflicts with another file") + # Avoid taking over another page's URL, including non-post pages. + for other in list(repo.glob("*.md")) + list(repo.glob("*.html")): + other_metadata, _ = split_markdown(other.read_text()) + records.append((other, other_metadata)) + for other, other_metadata in records: + url = other_metadata.get("permalink") + if not url and other.parent.name == "_posts": + url = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", other.stem) + if url and str(url).strip("/") == permalink.strip("/"): + raise ValueError("Another page already uses this permalink") + permalink = "/" + str(permalink).strip("/") + "/" + metadata.update(layout="post", permalink=permalink, upload_id=identity, + upload_filename=incoming["key"], upload_revision=manifest["id"], + render_with_liquid=False) + entry.parent.mkdir(parents=True, exist_ok=True) + entry.write_text("---\n" + yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False) + + "---\n\n" + incoming["body"]) + return entry.relative_to(repo), permalink, "updated" if candidates else "created", identity + + +def content_similarity(left, right): + """Ignore whitespace; require substantial text before accepting a fuzzy match.""" + left = " ".join(unicodedata.normalize("NFC", left).split()) + right = " ".join(unicodedata.normalize("NFC", right).split()) + if left == right: + return 1.0 if len(left) >= 40 else 0.0 + if min(len(left), len(right)) < 200: + return 0.0 + # Size bound avoids expensive comparisons to obviously different posts. + if min(len(left), len(right)) / max(len(left), len(right)) < 0.84: + return 0.0 + matcher = SequenceMatcher(None, left.split(), right.split()) + if matcher.quick_ratio() < 0.84: + return 0.0 + return matcher.ratio() diff --git a/photo-upload/publisher.py b/photo-upload/publisher.py index 323617b..3446570 100644 --- a/photo-upload/publisher.py +++ b/photo-upload/publisher.py @@ -10,6 +10,7 @@ import sys import time from app import atomic_json +from posts import publish_post log = logging.getLogger("publisher") @@ -48,6 +49,7 @@ class Publisher: for job in jobs: self.state(job, "publishing") try: + outcomes = {} if not (self.repo / ".git").is_dir(): self.git("clone", "--branch", self.branch, "--single-branch", "--", self.remote, str(self.repo), directory=self.work) @@ -56,9 +58,31 @@ class Publisher: self.git("reset", "--hard", f"origin/{self.branch}") self.git("config", "user.name", os.environ.get("PHOTO_GIT_NAME", "Photo publisher")) self.git("config", "user.email", os.environ.get("PHOTO_GIT_EMAIL", "photos@theread.me")) + # Prepending oldest first leaves the newest snippet at the top. + jobs.sort(key=lambda job: (json.loads((self.data / "submissions" / job / "manifest.json").read_text())["date"], job)) for job in jobs: submission = self.data / "submissions" / job manifest = json.loads((submission / "manifest.json").read_text()) + if manifest.get("kind") == "post": + entry, url, action, identity = publish_post(self.repo, submission, manifest) + self.git("add", "--", str(entry)) + outcomes[job] = dict(url=url, action=action, post_id=identity) + continue + if manifest.get("kind") == "snippet": + entry = self.repo / "snippets.md" + existing = entry.read_text() + marker = "" + if marker not in existing: + raise OSError("snippets.md is missing its upload insertion marker") + if f'
' not in existing: + entry.write_text(existing.replace(marker, marker + "\n\n" + + (submission / "entry.md").read_text(), 1)) + images = Path("img/snippets/uploads") / job + if manifest["count"]: + shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True) + self.git("add", "--", str(images)) + self.git("add", "--", "snippets.md") + continue entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md" images = Path("img/arts/uploads") / job (self.repo / entry).parent.mkdir(parents=True, exist_ok=True) @@ -66,18 +90,30 @@ class Publisher: shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True) self.git("add", "--", str(entry), str(images)) if self.git("diff", "--cached", "--name-only"): - self.git("commit", "-m", f"Publish {len(jobs)} photo submission(s)") + self.git("commit", "-m", f"Publish {len(jobs)} submission(s)") self.git("push", "origin", f"HEAD:refs/heads/{self.branch}") commit = self.git("rev-parse", "HEAD") for job in jobs: - self.state(job, "pushed", commit=commit) + self.state(job, "pushed", commit=commit, **outcomes.get(job, {})) + # A subsequent revision replaces the old one in the rendered site. + for job, outcome in outcomes.items(): + job_date = json.loads((self.data / "submissions" / job / "manifest.json").read_text())["date"] + for status_path in (self.data / "status").glob("*.json"): + previous = json.loads(status_path.read_text()) + if status_path.stem == job or previous.get("post_id") != outcome["post_id"]: + continue + old_manifest = self.data / "submissions" / status_path.stem / "manifest.json" + if old_manifest.exists() and json.loads(old_manifest.read_text())["date"] < job_date: + previous.update(status="superseded", replaced_by=job) + atomic_json(status_path, previous) log.info("Pushed %s submission(s) at %s", len(jobs), commit) - except (OSError, subprocess.SubprocessError) as error: + except (OSError, ValueError, subprocess.SubprocessError) as error: log.error("Git publication failed: %s", error) if isinstance(error, subprocess.CalledProcessError): log.error("%s", error.stderr) for job in jobs: - self.state(job, "failed", error="Git push failed; check publisher logs and retry.") + self.state(job, "failed", error=str(error) if isinstance(error, ValueError) else + "Git push failed; check publisher logs and retry.") raise def run(self, once=False): @@ -86,7 +122,7 @@ class Publisher: while True: try: self.publish() - except (OSError, subprocess.SubprocessError): + except (OSError, ValueError, subprocess.SubprocessError): if once: raise if once: diff --git a/photo-upload/requirements.txt b/photo-upload/requirements.txt index 0fdcd75..08b42b2 100644 --- a/photo-upload/requirements.txt +++ b/photo-upload/requirements.txt @@ -2,3 +2,4 @@ Flask==3.1.3 gunicorn==26.2.0 Pillow==12.3.0 pillow-heif==1.7.0 +PyYAML==6.0.3 diff --git a/photo-upload/tests/test_posts.py b/photo-upload/tests/test_posts.py new file mode 100644 index 0000000..ff51600 --- /dev/null +++ b/photo-upload/tests/test_posts.py @@ -0,0 +1,81 @@ +import io +import json + +from test_upload import AUTH, git, publisher, service +from posts import content_similarity + + +BODY = "\n\n".join(f"Paragraph {i}: I walked beside the river and considered how memory changes our understanding of a place. The details matter more than the names we give them." for i in range(12)) + + +def send(client, text, filename="essay.md"): + return client.post("/api/posts", headers=AUTH, data={"file": (io.BytesIO(text.encode()), filename)}) + + +def test_post_validation(service): + _, client, _ = service + assert client.post("/api/posts").status_code == 401 + assert client.post("/api/posts", headers=AUTH).status_code == 400 + assert send(client, BODY, "../escape.md").status_code == 400 + assert send(client, BODY, "essay.pdf").status_code == 400 + assert send(client, "---\ntitle: [bad\n---\nBody").status_code == 400 + assert send(client, "---\npost_id: ../../escape\n---\nBody").status_code == 400 + assert send(client, " ").status_code == 400 + assert send(client, "x" * (1024 * 1024 + 1)).status_code == 400 + + +def test_content_update_preserves_url_date_and_no_duplicate(publisher, service, monkeypatch, tmp_path): + run, remote, client = publisher + first_text = "---\ntitle: My walk\ndate: 2020-01-02\npermalink: /my-walk/\n---\n\n" + BODY + first = send(client, first_text) + assert first.status_code == 202 + assert run().returncode == 0 + status = client.get(first.json["status_url"], headers=AUTH).json + assert status["action"] == "created" and status["url"] == "/my-walk/" + second = send(client, "# My revised walk\n\n" + BODY.replace("beside the river", "along the river", 1), "renamed.md") + result = run() + assert result.returncode == 0, result.stderr + status2 = client.get(second.json["status_url"], headers=AUTH).json + assert status2["action"] == "updated" + assert status2["url"] == status["url"] + assert status2["post_id"] == status["post_id"] + paths = [p for p in git(remote, "ls-tree", "-r", "--name-only", "master").splitlines() if p.startswith("_posts/")] + assert len(paths) == 1 + saved = git(remote, "show", "master:" + paths[0]) + assert "2020-01-02" in saved and "along the river" in saved + assert "render_with_liquid: false" in saved + assert client.get(first.json["status_url"], headers=AUTH).json["status"] == "superseded" + receipt = tmp_path / "deployed.json" + monkeypatch.setenv("PHOTO_DEPLOYMENT_FILE", str(receipt)) + receipt.write_text(json.dumps({"posts": [second.json["id"]]})) + assert client.get(second.json["status_url"], headers=AUTH).json["status"] == "published" + assert send(client, first_text).status_code == 200 + + +def test_unrelated_same_filename_creates_new_post(publisher): + run, remote, client = publisher + send(client, BODY) + assert run().returncode == 0 + response = send(client, "# Another subject\n\n" + "Astronomy explores stars, galaxies and planetary orbits. " * 20) + assert run().returncode == 0 + assert client.get(response.json["status_url"], headers=AUTH).json["action"] == "created" + assert len([p for p in git(remote, "ls-tree", "-r", "--name-only", "master").splitlines() if p.startswith("_posts/")]) == 2 + + +def test_ambiguous_matches_fail_without_pushing(publisher): + run, remote, client = publisher + for identity in ("one", "two"): + send(client, f"---\npost_id: {identity}\ntitle: {identity}\n---\n" + BODY, identity + ".md") + assert run().returncode == 0 + head = git(remote, "rev-parse", "master") + response = send(client, BODY.replace("details", "small details", 1)) + assert run().returncode != 0 + state = client.get(response.json["status_url"], headers=AUTH).json + assert state["status"] == "failed" and "Multiple posts" in state["error"] + assert git(remote, "rev-parse", "master") == head + + +def test_similarity_requires_substantial_unambiguous_content(): + assert content_similarity("short shared title", "short shared title") == 0 + assert content_similarity(BODY, BODY.replace("river", "lake", 1)) >= 0.92 + assert content_similarity(BODY, "Completely unrelated writing.") == 0 diff --git a/photo-upload/tests/test_upload.py b/photo-upload/tests/test_upload.py index 70597e7..69f1c55 100644 --- a/photo-upload/tests/test_upload.py +++ b/photo-upload/tests/test_upload.py @@ -5,6 +5,7 @@ from pathlib import Path import subprocess import sys import os +import random import pytest from PIL import Image @@ -59,6 +60,16 @@ def test_validation_is_all_or_nothing(service): assert not list((data / "temporary").iterdir()) +def test_multi_megabyte_multipart_file(service): + _, client, _ = service + output = io.BytesIO() + Image.frombytes("RGB", (1800, 1800), random.Random(2).randbytes(1800 * 1800 * 3)).save( + output, "JPEG", quality=95) + assert len(output.getvalue()) > 3 * 1024 * 1024 + response = upload(client, [output.getvalue()]) + assert response.status_code == 202, response.json + + def test_album_caption_order_and_retry_deduplication(service): _, client, data = service caption = ' {% include secret %} {{ site.email }}\nsecond line' @@ -149,6 +160,7 @@ def publisher(tmp_path, service): git(source, "config", "user.name", "Test") git(source, "config", "user.email", "test@example.com") (source / "art.html").write_text("Initial site") + (source / "snippets.md").write_text("---\nlayout: post\n---\nIntroduction\n\n\n\nOld snippet\n") git(source, "add", ".") git(source, "commit", "-m", "Initial") git(source, "remote", "add", "origin", str(remote)) @@ -200,3 +212,47 @@ def test_rejected_push_can_retry(publisher): result = run() assert result.returncode == 0, result.stderr assert client.get(response.json["status_url"], headers=AUTH).json["status"] == "pushed" + + +def test_snippet_intake_validation_and_literal_text(service): + _, client, data = service + assert client.post("/api/snippets", data={"text": "hello"}).status_code == 401 + assert client.post("/api/snippets", headers=AUTH).status_code == 400 + assert client.post("/api/snippets", headers=AUTH, data={"text": "x" * 20001}).status_code == 400 + text = 'Book quote\nفارسی {% include secret %}\nhttps://example.com/?a=1&b=2' + response = client.post("/api/snippets", headers=AUTH, data={"text": text}) + assert response.status_code == 202 + job = response.json["id"] + assert response.json["url"] == f"/snippets/#snippet-{job}" + assert client.get(f"/api/photos/{job}", headers=AUTH).status_code == 404 + entry = (data / "submissions" / job / "entry.md").read_text() + assert '