Member invites, gallery resync, content and operator docs
- Invite flow: admin issue/revoke on join requests, public single-use claim links (hash-only tokens, 7-day expiry, no state reveal), claim creates the Member account and marks the request accepted - Gallery: admin status page plus resync endpoint; sync refreshes thumbnails whose content changed; tools/gallery contract and checker - Docs: public content page, admin-only operator runbook, api.md invite/gallery sections, all routes in the live API docs, docs reachability gate test - Screenshots cover the new pages; version 1.0.18
This commit is contained in:
@@ -384,3 +384,45 @@ environments, docker, env-files, serving, gallery, test, git, code,
|
|||||||
docs, and handover laws are preserved as dated `muse-spark` entries
|
docs, and handover laws are preserved as dated `muse-spark` entries
|
||||||
above. New teachings use the skeleton under [Lesson format](#lesson-format).
|
above. New teachings use the skeleton under [Lesson format](#lesson-format).
|
||||||
No lesson content may be deleted when the format evolves.
|
No lesson content may be deleted when the format evolves.
|
||||||
|
|
||||||
|
### L-2026-10-05-13: Restart reload-based dev servers after every pull
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------------|-------|
|
||||||
|
| date | 2026-10-05 |
|
||||||
|
| agent | muse-spark |
|
||||||
|
| status | proposed |
|
||||||
|
| dpp_template | no |
|
||||||
|
| dpp_reason | proposed; Grok folds into template deploy section |
|
||||||
|
|
||||||
|
File-watching dev servers restart on the first changed file they notice.
|
||||||
|
A version-control update writes files one by one, so the restart can
|
||||||
|
fire mid-checkout and boot workers from a mixed old/new tree with no
|
||||||
|
further restart once the checkout completes (observed: health reported
|
||||||
|
the previous version while new code was partially loaded).
|
||||||
|
|
||||||
|
After every pull or checkout on a host running reload-based servers,
|
||||||
|
restart those servers (or their containers) and verify the reported
|
||||||
|
version matches the checked-out tree before declaring the deploy done.
|
||||||
|
Production targets without reload are unaffected but still verify.
|
||||||
|
|
||||||
|
### L-2026-10-05-14: Autonomous development rule
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------------|-------|
|
||||||
|
| date | 2026-10-05 |
|
||||||
|
| agent | muse-spark |
|
||||||
|
| status | proposed |
|
||||||
|
| dpp_template | no |
|
||||||
|
| dpp_reason | proposed; agent operating rule, Grok decides the template fold |
|
||||||
|
|
||||||
|
Standing instruction from retoor, recorded as a clear rule:
|
||||||
|
|
||||||
|
- Move one way only: forward. Do the maximum-effort option that adds
|
||||||
|
no degradation and deletes no functionality.
|
||||||
|
- Whatever is unclear after the user's answers is resolved by online
|
||||||
|
deep research first; the agent then picks the research-backed option
|
||||||
|
and continues autonomously instead of stalling on questions.
|
||||||
|
- Research grounds the choice (example: invite tokens use hash-only
|
||||||
|
storage, single use and expiry per current security practice), it
|
||||||
|
never replaces verification against the repo's own code and tests.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ STAGING_ADMIN_PASSWORD ?= staging-admin-pass-1
|
|||||||
PYTEST := $(PY) -m pytest
|
PYTEST := $(PY) -m pytest
|
||||||
|
|
||||||
.PHONY: install dev prod test test-headed test-unit test-api test-e2e test-fast test-failed \
|
.PHONY: install dev prod test test-headed test-unit test-api test-e2e test-fast test-failed \
|
||||||
test-first-failure test-slowest test-cache-clean check-ui preflight coverage coverage-headed \
|
test-first-failure test-slowest test-cache-clean check-ui check-gallery preflight coverage coverage-headed \
|
||||||
coverage-html locust locust-headless screenshots screenshots-staging screenshots-production \
|
coverage-html locust locust-headless screenshots screenshots-staging screenshots-production \
|
||||||
staging-refresh prune prune-dry-run tree tree-loc zip delete-pyc clean
|
staging-refresh prune prune-dry-run tree tree-loc zip delete-pyc clean
|
||||||
|
|
||||||
@@ -73,6 +73,9 @@ test-cache-clean:
|
|||||||
check-ui: $(STAMP)
|
check-ui: $(STAMP)
|
||||||
$(PY) -m $(PKG).gates
|
$(PY) -m $(PKG).gates
|
||||||
|
|
||||||
|
check-gallery: $(STAMP)
|
||||||
|
$(PY) tools/gallery/check.py $(GALLERY_DIR)
|
||||||
|
|
||||||
preflight: $(STAMP)
|
preflight: $(STAMP)
|
||||||
$(PY) -c "import $(PKG).main; print('import ok')"
|
$(PY) -c "import $(PKG).main; print('import ok')"
|
||||||
$(PY) -m pyflakes $(PKG) tests
|
$(PY) -m pyflakes $(PKG) tests
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ make screenshots-staging # stagi
|
|||||||
make screenshots-production SCREENSHOTS_ADMIN_PASSWORD=... # production gallery into ../static/pizdetz/production
|
make screenshots-production SCREENSHOTS_ADMIN_PASSWORD=... # production gallery into ../static/pizdetz/production
|
||||||
```
|
```
|
||||||
|
|
||||||
Each run captures 32 full-page desktop shots (public, docs, admin) plus a 25-cell responsive matrix (home, roll, flyers, join, docs at 1440x900, 768x1024, 390x844, 360x740, 320x568) with a horizontal-overflow check per cell, then rebuilds the hub page at `../static/pizdetz/index.html` from whichever environments exist on disk.
|
Each run captures 40 full-page desktop shots (public, docs, admin; the test gallery adds a live invite-claim shot) plus a 25-cell responsive matrix (home, roll, flyers, join, docs at 1440x900, 768x1024, 390x844, 360x740, 320x568) with a horizontal-overflow check per cell, then rebuilds the hub page at `../static/pizdetz/index.html` from whichever environments exist on disk.
|
||||||
|
|
||||||
Rules, exactly: `test` always wipes `./data/test`, boots its own server there, and seeds one join request; `staging` and `production` shoot a live base URL and never seed. Staging logs in with `STAGING_ADMIN_PASSWORD` (default `staging-admin-pass-1`, must match the compose admin password); production has no default and fails loudly without `SCREENSHOTS_ADMIN_PASSWORD`. Any overflow is reported and exits nonzero. Override the output root with `SCREENSHOTS_DIR=...`.
|
Rules, exactly: `test` always wipes an isolated `./data/.tmp-shots-<pid>`, boots its own server there, and seeds one join request plus one invite; `staging` and `production` shoot a live base URL and never seed. Staging logs in with `STAGING_ADMIN_PASSWORD` (default `staging-admin-pass-1`, must match the compose admin password); production has no default and fails loudly without `SCREENSHOTS_ADMIN_PASSWORD`. Any overflow is reported and exits nonzero. Override the output root with `SCREENSHOTS_DIR=...`.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ NOTIFICATION_TYPES = {
|
|||||||
"system.maintenance": "Maintenance mode changed",
|
"system.maintenance": "Maintenance mode changed",
|
||||||
}
|
}
|
||||||
|
|
||||||
CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile")
|
CRAWLER_PRIVATE_PREFIXES = ("/notifications", "/admin", "/profile", "/invite")
|
||||||
|
|
||||||
CRAWLER_MARKERS = (
|
CRAWLER_MARKERS = (
|
||||||
"bot",
|
"bot",
|
||||||
@@ -94,6 +94,7 @@ SETTINGS_DEFAULTS = {
|
|||||||
"rate_limit_window_seconds": "60",
|
"rate_limit_window_seconds": "60",
|
||||||
"session_max_age_days": "7",
|
"session_max_age_days": "7",
|
||||||
"session_remember_days": "30",
|
"session_remember_days": "30",
|
||||||
|
"invite_expiry_days": "7",
|
||||||
"terms_version": "1",
|
"terms_version": "1",
|
||||||
"friendly_404": "1",
|
"friendly_404": "1",
|
||||||
"audit_retention_days": "365",
|
"audit_retention_days": "365",
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ from molodetz.database.core import (
|
|||||||
table_exists,
|
table_exists,
|
||||||
)
|
)
|
||||||
from molodetz.database.init_db import init_db
|
from molodetz.database.init_db import init_db
|
||||||
|
from molodetz.database.invites import (
|
||||||
|
claim_invite,
|
||||||
|
create_invite,
|
||||||
|
get_invite_by_token,
|
||||||
|
invite_is_live,
|
||||||
|
open_invite_for_join,
|
||||||
|
revoke_open_invites,
|
||||||
|
suggest_username,
|
||||||
|
)
|
||||||
from molodetz.database.joins import (
|
from molodetz.database.joins import (
|
||||||
count_open_join_requests,
|
count_open_join_requests,
|
||||||
get_join_request,
|
get_join_request,
|
||||||
@@ -97,6 +106,8 @@ __all__ = [
|
|||||||
"count_open_join_requests",
|
"count_open_join_requests",
|
||||||
"count_published",
|
"count_published",
|
||||||
"count_users",
|
"count_users",
|
||||||
|
"claim_invite",
|
||||||
|
"create_invite",
|
||||||
"create_session",
|
"create_session",
|
||||||
"create_user",
|
"create_user",
|
||||||
"daily_totals",
|
"daily_totals",
|
||||||
@@ -110,6 +121,7 @@ __all__ = [
|
|||||||
"generate_uid",
|
"generate_uid",
|
||||||
"get_backup",
|
"get_backup",
|
||||||
"get_int_setting",
|
"get_int_setting",
|
||||||
|
"get_invite_by_token",
|
||||||
"get_join_request",
|
"get_join_request",
|
||||||
"get_post",
|
"get_post",
|
||||||
"get_post_by_slug",
|
"get_post_by_slug",
|
||||||
@@ -128,6 +140,7 @@ __all__ = [
|
|||||||
"insert_notification",
|
"insert_notification",
|
||||||
"insert_post",
|
"insert_post",
|
||||||
"invalidate_admins_cache",
|
"invalidate_admins_cache",
|
||||||
|
"invite_is_live",
|
||||||
"list_audit",
|
"list_audit",
|
||||||
"list_backups",
|
"list_backups",
|
||||||
"list_deleted",
|
"list_deleted",
|
||||||
@@ -138,6 +151,7 @@ __all__ = [
|
|||||||
"mark_all_read",
|
"mark_all_read",
|
||||||
"media_by_kind",
|
"media_by_kind",
|
||||||
"now_iso",
|
"now_iso",
|
||||||
|
"open_invite_for_join",
|
||||||
"paginate",
|
"paginate",
|
||||||
"parse_iso",
|
"parse_iso",
|
||||||
"pending_backups",
|
"pending_backups",
|
||||||
@@ -152,11 +166,13 @@ __all__ = [
|
|||||||
"resolve_by_slug",
|
"resolve_by_slug",
|
||||||
"restore",
|
"restore",
|
||||||
"restore_event",
|
"restore_event",
|
||||||
|
"revoke_open_invites",
|
||||||
"set_role",
|
"set_role",
|
||||||
"set_setting",
|
"set_setting",
|
||||||
"shard_path",
|
"shard_path",
|
||||||
"soft_delete",
|
"soft_delete",
|
||||||
"soft_delete_in",
|
"soft_delete_in",
|
||||||
|
"suggest_username",
|
||||||
"sync_local_cache",
|
"sync_local_cache",
|
||||||
"table_exists",
|
"table_exists",
|
||||||
"top_paths",
|
"top_paths",
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ TABLE_EXAMPLES = {
|
|||||||
"deleted_at": None,
|
"deleted_at": None,
|
||||||
"deleted_by": None,
|
"deleted_by": None,
|
||||||
},
|
},
|
||||||
|
"member_invites": {
|
||||||
|
"uid": "x",
|
||||||
|
"token_hash": "x",
|
||||||
|
"join_request_uid": "x",
|
||||||
|
"email": "",
|
||||||
|
"username": "",
|
||||||
|
"created_by": "x",
|
||||||
|
"created_at": now_iso(),
|
||||||
|
"expires_at": now_iso(),
|
||||||
|
"used_at": None,
|
||||||
|
"used_by": None,
|
||||||
|
"revoked_at": None,
|
||||||
|
"revoked_by": None,
|
||||||
|
},
|
||||||
"media_items": {
|
"media_items": {
|
||||||
"uid": "x",
|
"uid": "x",
|
||||||
"filename": "x.jpg",
|
"filename": "x.jpg",
|
||||||
@@ -200,6 +214,9 @@ def init_db():
|
|||||||
_index("CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(user_uid, created_at)")
|
_index("CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(user_uid, created_at)")
|
||||||
_index("CREATE INDEX IF NOT EXISTS idx_posts_trash ON posts(deleted_at) WHERE deleted_at IS NOT NULL")
|
_index("CREATE INDEX IF NOT EXISTS idx_posts_trash ON posts(deleted_at) WHERE deleted_at IS NOT NULL")
|
||||||
|
|
||||||
|
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_member_invites_uid ON member_invites(uid)")
|
||||||
|
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_member_invites_token ON member_invites(token_hash)")
|
||||||
|
_index("CREATE INDEX IF NOT EXISTS idx_member_invites_join ON member_invites(join_request_uid, created_at)")
|
||||||
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_uid ON join_requests(uid)")
|
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_uid ON join_requests(uid)")
|
||||||
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_status ON join_requests(status, created_at)")
|
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_status ON join_requests(status, created_at)")
|
||||||
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_trash ON join_requests(deleted_at) WHERE deleted_at IS NOT NULL")
|
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_trash ON join_requests(deleted_at) WHERE deleted_at IS NOT NULL")
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from molodetz.database.core import db, generate_uid, now_iso, parse_iso
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token):
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_username(name):
|
||||||
|
cleaned = "".join(char.lower() if char.isalnum() else "_" for char in (name or "").strip())
|
||||||
|
cleaned = cleaned.strip("_")
|
||||||
|
if len(cleaned) < 3:
|
||||||
|
return ""
|
||||||
|
return cleaned[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def open_invite_for_join(join_request_uid):
|
||||||
|
if "member_invites" not in db.tables:
|
||||||
|
return None
|
||||||
|
rows = list(
|
||||||
|
db["member_invites"].find(
|
||||||
|
join_request_uid=join_request_uid,
|
||||||
|
used_at=None,
|
||||||
|
revoked_at=None,
|
||||||
|
order_by=["-created_at"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stamp = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
expires = parse_iso(row.get("expires_at"))
|
||||||
|
if expires is not None and expires > stamp:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_invite(join_request_uid, email, username, created_by, days=7):
|
||||||
|
if "member_invites" in db.tables:
|
||||||
|
stamp = now_iso()
|
||||||
|
for row in db["member_invites"].find(join_request_uid=join_request_uid, used_at=None, revoked_at=None):
|
||||||
|
db["member_invites"].update(
|
||||||
|
{"id": row["id"], "revoked_at": stamp, "revoked_by": created_by},
|
||||||
|
["id"],
|
||||||
|
)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(days=max(1, days))
|
||||||
|
row = {
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"token_hash": hash_token(token),
|
||||||
|
"join_request_uid": join_request_uid,
|
||||||
|
"email": (email or "").lower(),
|
||||||
|
"username": username or "",
|
||||||
|
"created_by": created_by,
|
||||||
|
"created_at": now_iso(),
|
||||||
|
"expires_at": expires.isoformat(),
|
||||||
|
"used_at": None,
|
||||||
|
"used_by": None,
|
||||||
|
"revoked_at": None,
|
||||||
|
"revoked_by": None,
|
||||||
|
}
|
||||||
|
db["member_invites"].insert(row)
|
||||||
|
return row, token
|
||||||
|
|
||||||
|
|
||||||
|
def get_invite_by_token(token):
|
||||||
|
if not token or "member_invites" not in db.tables:
|
||||||
|
return None
|
||||||
|
return db["member_invites"].find_one(token_hash=hash_token(token))
|
||||||
|
|
||||||
|
|
||||||
|
def invite_is_live(row):
|
||||||
|
if not row or row.get("used_at") or row.get("revoked_at"):
|
||||||
|
return False
|
||||||
|
expires = parse_iso(row.get("expires_at"))
|
||||||
|
return expires is not None and expires > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_invite(uid, user_uid):
|
||||||
|
db["member_invites"].update({"uid": uid, "used_at": now_iso(), "used_by": user_uid}, ["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_open_invites(join_request_uid, actor):
|
||||||
|
if "member_invites" not in db.tables:
|
||||||
|
return 0
|
||||||
|
stamp = now_iso()
|
||||||
|
count = 0
|
||||||
|
for row in db["member_invites"].find(join_request_uid=join_request_uid, used_at=None, revoked_at=None):
|
||||||
|
db["member_invites"].update({"id": row["id"], "revoked_at": stamp, "revoked_by": actor}, ["id"])
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
@@ -6,7 +6,7 @@ GROUP = {
|
|||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"description": "Administrators only. Writing and publishing posts, join requests, services, trash, settings.",
|
"description": "Administrators only. Writing and publishing posts, join requests, services, trash, settings.",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
endpoint("GET", "/admin", "Overview", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0}),
|
endpoint("GET", "/admin", "Overview", auth="admin", sample={"post_count": 5, "open_joins": 0, "user_count": 1, "deleted_count": 0, "flyer_count": 4, "meme_count": 38}),
|
||||||
endpoint("GET", "/admin/posts", "Posts", auth="admin", sample={"posts": []}),
|
endpoint("GET", "/admin/posts", "Posts", auth="admin", sample={"posts": []}),
|
||||||
endpoint("GET", "/admin/posts/new", "New post", auth="admin", sample={}),
|
endpoint("GET", "/admin/posts/new", "New post", auth="admin", sample={}),
|
||||||
endpoint(
|
endpoint(
|
||||||
@@ -32,6 +32,10 @@ GROUP = {
|
|||||||
endpoint("GET", "/admin/joins", "Join requests", auth="admin", sample={"requests": []}),
|
endpoint("GET", "/admin/joins", "Join requests", auth="admin", sample={"requests": []}),
|
||||||
endpoint("POST", "/admin/joins/{uid}/status", "Join request status", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/unknown/status", probe_body={"status": "open"}),
|
endpoint("POST", "/admin/joins/{uid}/status", "Join request status", auth="admin", fields=[field("uid", required=True, location="path"), field("status", enum=["open", "contacted", "accepted", "declined"])], probe_path="/admin/joins/unknown/status", probe_body={"status": "open"}),
|
||||||
endpoint("POST", "/admin/joins/{uid}/check", "Check repo link", "Fetches the title through the guarded outbound client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/unknown/check"),
|
endpoint("POST", "/admin/joins/{uid}/check", "Check repo link", "Fetches the title through the guarded outbound client.", auth="admin", fields=[field("uid", required=True, location="path")], probe_path="/admin/joins/unknown/check"),
|
||||||
|
endpoint("POST", "/admin/joins/{uid}/invite", "Issue invite", "Issues a single-use claim link, shown once. Revokes the previous open invite.", auth="admin", fields=[field("uid", required=True, location="path")], sample={"ok": True, "redirect": "/admin/joins", "data": {"claim_url": "https://example.com/invite/...", "expires_at": "..."}}, probe_path="/admin/joins/unknown/invite"),
|
||||||
|
endpoint("POST", "/admin/joins/{uid}/invite/revoke", "Revoke invite", "Revokes the open invite without touching the request.", auth="admin", fields=[field("uid", required=True, location="path")], sample={"ok": True, "redirect": "/admin/joins", "data": {"revoked": 1}}, probe_path="/admin/joins/unknown/invite/revoke"),
|
||||||
|
endpoint("GET", "/admin/gallery", "Gallery status", "Live flyer and meme counts plus catalogued files missing from disk.", auth="admin", sample={"flyers": 4, "memes": 38, "missing": []}),
|
||||||
|
endpoint("POST", "/admin/gallery/resync", "Resync gallery", "Re-reads sources, refreshes changed thumbnails, retires removed entries.", auth="admin", sample={"ok": True, "redirect": "/admin/gallery", "data": {"synced": 42, "flyers": 4, "memes": 38, "missing": []}}),
|
||||||
endpoint("GET", "/admin/services", "Services", auth="admin", sample={"services": []}),
|
endpoint("GET", "/admin/services", "Services", auth="admin", sample={"services": []}),
|
||||||
endpoint("GET", "/admin/services/{name}", "Service", auth="admin", fields=[field("name", required=True, location="path", example="backup")], probe_path="/admin/services/backup"),
|
endpoint("GET", "/admin/services/{name}", "Service", auth="admin", fields=[field("name", required=True, location="path", example="backup")], probe_path="/admin/services/backup"),
|
||||||
endpoint("POST", "/admin/services/{name}/command", "Service command", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}),
|
endpoint("POST", "/admin/services/{name}/command", "Service command", auth="admin", fields=[field("name", required=True, location="path"), field("verb", enum=["start", "stop", "run", "clear"])], probe_path="/admin/services/backup/command", probe_body={"verb": "noop"}),
|
||||||
|
|||||||
@@ -21,5 +21,31 @@ GROUP = {
|
|||||||
sample={"ok": True, "redirect": "/join?ok=1", "data": {"uid": "0190..."}},
|
sample={"ok": True, "redirect": "/join?ok=1", "data": {"uid": "0190..."}},
|
||||||
probe_body={"name": "Probe", "contact": "probe@example.com"},
|
probe_body={"name": "Probe", "contact": "probe@example.com"},
|
||||||
),
|
),
|
||||||
|
endpoint(
|
||||||
|
"GET",
|
||||||
|
"/invite/{token}",
|
||||||
|
"Invite claim form",
|
||||||
|
"Shows the claim form for a live invite; unknown, used and expired links answer 404 with the same message.",
|
||||||
|
fields=[field("token", required=True, location="path", example="unknown-token")],
|
||||||
|
sample={"valid": True, "username": "ada", "email": "ada@example.com", "email_locked": True},
|
||||||
|
probe_path="/invite/unknown-token",
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
"POST",
|
||||||
|
"/invite/{token}",
|
||||||
|
"Claim invite",
|
||||||
|
"Creates the Member account, logs in and marks the join request accepted. Single use.",
|
||||||
|
fields=[
|
||||||
|
field("token", required=True, location="path", example="unknown-token"),
|
||||||
|
field("username", required=True, example="ada"),
|
||||||
|
field("email", required=True, example="ada@example.com"),
|
||||||
|
field("password", required=True, example="correct horse battery staple"),
|
||||||
|
field("password_confirm", required=True, example="correct horse battery staple"),
|
||||||
|
field("terms", required=True, type="boolean", example=True),
|
||||||
|
],
|
||||||
|
sample={"ok": True, "redirect": "/", "data": {"username": "ada"}},
|
||||||
|
probe_path="/invite/unknown-token",
|
||||||
|
probe_body={"username": "Probe", "email": "probe@example.com", "password": "probe-pass-1", "password_confirm": "probe-pass-1", "terms": True},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ DOCS_PAGES = [
|
|||||||
{"slug": "join", "title": "Joining", "section": "Start here", "file": "join.md"},
|
{"slug": "join", "title": "Joining", "section": "Start here", "file": "join.md"},
|
||||||
{"slug": "legal", "title": "Terms and privacy", "section": "Start here", "file": "legal.md"},
|
{"slug": "legal", "title": "Terms and privacy", "section": "Start here", "file": "legal.md"},
|
||||||
{"slug": "api", "title": "API and authentication", "section": "Building with the API", "file": "api.md"},
|
{"slug": "api", "title": "API and authentication", "section": "Building with the API", "file": "api.md"},
|
||||||
|
{"slug": "content", "title": "Galleries and content", "section": "Start here", "file": "content.md"},
|
||||||
{"slug": "admin", "title": "Admin and services", "section": "Admin", "file": "admin.md", "admin": True},
|
{"slug": "admin", "title": "Admin and services", "section": "Admin", "file": "admin.md", "admin": True},
|
||||||
|
{"slug": "operator", "title": "Operator runbook", "section": "Admin", "file": "operator.md", "admin": True},
|
||||||
{"slug": "backups", "title": "Backups and maintenance", "section": "Admin", "file": "backups.md", "admin": True},
|
{"slug": "backups", "title": "Backups and maintenance", "section": "Admin", "file": "backups.md", "admin": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -99,7 +99,8 @@ def sync_gallery():
|
|||||||
seen_hashes.append(phash)
|
seen_hashes.append(phash)
|
||||||
thumb_directory = existing.get("thumb_directory") if existing else None
|
thumb_directory = existing.get("thumb_directory") if existing else None
|
||||||
thumb_name = existing.get("thumb_name") if existing else None
|
thumb_name = existing.get("thumb_name") if existing else None
|
||||||
if not thumb_directory or not blob_path(thumb_directory, thumb_name).exists():
|
changed = not existing or existing.get("phash") != str(phash)
|
||||||
|
if changed or not thumb_directory or not blob_path(thumb_directory, thumb_name).exists():
|
||||||
thumb_directory, thumb_name = write_blob(
|
thumb_directory, thumb_name = write_blob(
|
||||||
existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image)
|
existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image)
|
||||||
)
|
)
|
||||||
@@ -108,6 +109,10 @@ def sync_gallery():
|
|||||||
return synced
|
return synced
|
||||||
|
|
||||||
|
|
||||||
|
def missing_sources():
|
||||||
|
return [filename for filename, _, _ in GALLERY if not (config.MEDIA_SOURCE_DIR / filename).exists()]
|
||||||
|
|
||||||
|
|
||||||
def gallery_items(kind):
|
def gallery_items(kind):
|
||||||
items = []
|
items = []
|
||||||
for row in media_by_kind(kind):
|
for row in media_by_kind(kind):
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from molodetz.routers import (
|
|||||||
flyers,
|
flyers,
|
||||||
health,
|
health,
|
||||||
home,
|
home,
|
||||||
|
invite,
|
||||||
join,
|
join,
|
||||||
legacy,
|
legacy,
|
||||||
memes,
|
memes,
|
||||||
@@ -145,6 +146,7 @@ app.include_router(flyers.router, prefix="/flyers")
|
|||||||
app.include_router(memes.router, prefix="/memes")
|
app.include_router(memes.router, prefix="/memes")
|
||||||
app.include_router(people.router, prefix="/people")
|
app.include_router(people.router, prefix="/people")
|
||||||
app.include_router(join.router, prefix="/join")
|
app.include_router(join.router, prefix="/join")
|
||||||
|
app.include_router(invite.router, prefix="/invite")
|
||||||
app.include_router(terms.router, prefix="/terms")
|
app.include_router(terms.router, prefix="/terms")
|
||||||
app.include_router(privacy.router, prefix="/privacy")
|
app.include_router(privacy.router, prefix="/privacy")
|
||||||
app.include_router(health.router, prefix="/health")
|
app.include_router(health.router, prefix="/health")
|
||||||
@@ -439,4 +441,22 @@ async def validation_handler(request: Request, exc: RequestValidationError):
|
|||||||
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Join", "robots": "index,follow"}},
|
{"errors": messages, "form": echoed, "submitted": False, "seo": {"title": "Join", "robots": "index,follow"}},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
if request.url.path.startswith("/invite/"):
|
||||||
|
from molodetz.database import get_invite_by_token
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
echoed = {key: value for key, value in form.items() if isinstance(value, str) and not key.startswith("password")}
|
||||||
|
invite = get_invite_by_token(request.url.path.rsplit("/", 1)[-1])
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"invite.html",
|
||||||
|
{
|
||||||
|
"valid": True,
|
||||||
|
"errors": messages,
|
||||||
|
"form": echoed,
|
||||||
|
"email_locked": bool(invite and invite.get("email")),
|
||||||
|
"seo": {"title": "Accept invite", "robots": "noindex,nofollow"},
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303)
|
return RedirectResponse(safe_next(request.headers.get("referer", "").replace(str(request.base_url).rstrip("/"), ""), "/"), status_code=303)
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ class JoinStatusForm(FormModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class InviteClaimForm(FormModel):
|
||||||
|
username: str = Field(min_length=3, max_length=32)
|
||||||
|
email: str = Field(min_length=3, max_length=255)
|
||||||
|
password: str = Field(min_length=6, max_length=128)
|
||||||
|
password_confirm: str = Field(min_length=6, max_length=128)
|
||||||
|
terms: bool = False
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def check_username(cls, value):
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_-]{3,32}", value or ""):
|
||||||
|
raise ValueError("Use 3 to 32 letters, digits, _ or -")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RoleForm(FormModel):
|
class RoleForm(FormModel):
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
from molodetz.routers.admin import audit, backups, joins, posts, services, settings, stats, trash, users
|
from molodetz.routers.admin import audit, backups, gallery, joins, posts, services, settings, stats, trash, users
|
||||||
from molodetz.routers.admin.index import router
|
from molodetz.routers.admin.index import router
|
||||||
|
|
||||||
router.include_router(posts.router)
|
router.include_router(posts.router)
|
||||||
router.include_router(joins.router)
|
router.include_router(joins.router)
|
||||||
|
router.include_router(gallery.router)
|
||||||
router.include_router(services.router)
|
router.include_router(services.router)
|
||||||
router.include_router(trash.router)
|
router.include_router(trash.router)
|
||||||
router.include_router(settings.router)
|
router.include_router(settings.router)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from molodetz.database import count_media
|
||||||
|
from molodetz.gallery import missing_sources, sync_gallery
|
||||||
|
from molodetz.responses import action_result, respond
|
||||||
|
from molodetz.routers.admin._shared import admin_seo
|
||||||
|
from molodetz.schemas.admin import GalleryStatusOut
|
||||||
|
from molodetz.utils.audit import record
|
||||||
|
from molodetz.utils.auth import require_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _status():
|
||||||
|
return {
|
||||||
|
"flyers": count_media("flyer"),
|
||||||
|
"memes": count_media("meme"),
|
||||||
|
"missing": missing_sources(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gallery")
|
||||||
|
async def gallery_status(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
ctx = dict(_status())
|
||||||
|
ctx["seo"] = admin_seo(request, "Gallery")
|
||||||
|
return respond(request, "admin/gallery.html", ctx, model=GalleryStatusOut)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gallery/resync")
|
||||||
|
async def gallery_resync(request: Request):
|
||||||
|
user = require_admin(request)
|
||||||
|
synced = sync_gallery()
|
||||||
|
data = dict(_status())
|
||||||
|
data["synced"] = synced
|
||||||
|
record(request, "gallery.resync", payload=data, user=user)
|
||||||
|
return action_result(request, "/admin/gallery", data=data)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from molodetz.constants import SOFT_DELETE_TABLES
|
from molodetz.constants import SOFT_DELETE_TABLES
|
||||||
from molodetz.database import all_posts, count_deleted, count_open_join_requests, count_users
|
from molodetz.database import all_posts, count_deleted, count_media, count_open_join_requests, count_users
|
||||||
from molodetz.responses import respond
|
from molodetz.responses import respond
|
||||||
from molodetz.routers.admin._shared import admin_seo
|
from molodetz.routers.admin._shared import admin_seo
|
||||||
from molodetz.schemas.admin import AdminIndexOut
|
from molodetz.schemas.admin import AdminIndexOut
|
||||||
@@ -19,6 +19,8 @@ async def admin_index(request: Request):
|
|||||||
"open_joins": count_open_join_requests(),
|
"open_joins": count_open_join_requests(),
|
||||||
"user_count": count_users(),
|
"user_count": count_users(),
|
||||||
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
|
"deleted_count": sum(count_deleted(name) for name in SOFT_DELETE_TABLES),
|
||||||
|
"flyer_count": count_media("flyer"),
|
||||||
|
"meme_count": count_media("meme"),
|
||||||
"seo": admin_seo(request, "Admin"),
|
"seo": admin_seo(request, "Admin"),
|
||||||
}
|
}
|
||||||
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
|
return respond(request, "admin/index.html", ctx, model=AdminIndexOut)
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
from molodetz.database import get_join_request, list_join_requests, update_join_request
|
from molodetz.database import (
|
||||||
|
create_invite,
|
||||||
|
get_int_setting,
|
||||||
|
get_join_request,
|
||||||
|
list_join_requests,
|
||||||
|
open_invite_for_join,
|
||||||
|
revoke_open_invites,
|
||||||
|
suggest_username,
|
||||||
|
update_join_request,
|
||||||
|
)
|
||||||
from molodetz.dependencies import json_or_form
|
from molodetz.dependencies import json_or_form
|
||||||
from molodetz.link_check import check_repo_link
|
from molodetz.link_check import check_repo_link
|
||||||
from molodetz.models import JoinStatusForm
|
from molodetz.models import JoinStatusForm
|
||||||
from molodetz.responses import action_result, respond
|
from molodetz.responses import action_result, respond, wants_json
|
||||||
from molodetz.routers.admin._shared import admin_seo
|
from molodetz.routers.admin._shared import admin_seo
|
||||||
from molodetz.schemas.joins import JoinListOut
|
from molodetz.schemas.joins import JoinListOut
|
||||||
|
from molodetz.templating import templates
|
||||||
from molodetz.utils.audit import record
|
from molodetz.utils.audit import record
|
||||||
from molodetz.utils.auth import not_found, require_admin
|
from molodetz.utils.auth import not_found, require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_contact_email(contact):
|
||||||
|
contact = (contact or "").strip()
|
||||||
|
if "@" in contact and " " not in contact and "." in contact.split("@")[-1]:
|
||||||
|
return contact.lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/joins")
|
@router.get("/joins")
|
||||||
async def joins_index(request: Request):
|
async def joins_index(request: Request):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
return respond(request, "admin/joins.html", {"requests": list_join_requests(), "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
|
rows = []
|
||||||
|
for row in list_join_requests():
|
||||||
|
item = dict(row)
|
||||||
|
invite = open_invite_for_join(row["uid"])
|
||||||
|
item["invite_open"] = invite is not None
|
||||||
|
item["invite_expires_at"] = invite["expires_at"] if invite else None
|
||||||
|
rows.append(item)
|
||||||
|
return respond(request, "admin/joins.html", {"requests": rows, "seo": admin_seo(request, "Join requests")}, model=JoinListOut)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/joins/{uid}/status")
|
@router.post("/joins/{uid}/status")
|
||||||
@@ -41,3 +65,48 @@ async def joins_check(request: Request, uid: str):
|
|||||||
result = await check_repo_link(row)
|
result = await check_repo_link(row)
|
||||||
record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user)
|
record(request, "join.request.check", payload=result, targets=[("join_request", uid)], user=user)
|
||||||
return action_result(request, "/admin/joins", data=result)
|
return action_result(request, "/admin/joins", data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/joins/{uid}/invite")
|
||||||
|
async def joins_invite(request: Request, uid: str):
|
||||||
|
user = require_admin(request)
|
||||||
|
row = get_join_request(uid)
|
||||||
|
if row is None:
|
||||||
|
not_found("Join request not found")
|
||||||
|
if row.get("status") == "declined":
|
||||||
|
raise HTTPException(409, "Cannot invite a declined request")
|
||||||
|
days = get_int_setting("invite_expiry_days", 7)
|
||||||
|
invite, token = create_invite(
|
||||||
|
uid,
|
||||||
|
_invite_contact_email(row.get("contact")),
|
||||||
|
suggest_username(row.get("name")),
|
||||||
|
user["uid"],
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
claim_url = str(request.base_url).rstrip("/") + f"/invite/{token}"
|
||||||
|
record(
|
||||||
|
request,
|
||||||
|
"join.invite.issued",
|
||||||
|
payload={"expires_at": invite["expires_at"]},
|
||||||
|
targets=[("join_request", uid)],
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
data = {"claim_url": claim_url, "expires_at": invite["expires_at"]}
|
||||||
|
if wants_json(request):
|
||||||
|
return action_result(request, "/admin/joins", data=data)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"admin/invite_issued.html",
|
||||||
|
{"join": row, "claim_url": claim_url, "expires_at": invite["expires_at"], "seo": admin_seo(request, "Invite issued")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/joins/{uid}/invite/revoke")
|
||||||
|
async def joins_invite_revoke(request: Request, uid: str):
|
||||||
|
user = require_admin(request)
|
||||||
|
row = get_join_request(uid)
|
||||||
|
if row is None:
|
||||||
|
not_found("Join request not found")
|
||||||
|
revoked = revoke_open_invites(uid, user["uid"])
|
||||||
|
record(request, "join.invite.revoked", payload={"revoked": revoked}, targets=[("join_request", uid)], user=user)
|
||||||
|
return action_result(request, "/admin/joins", data={"revoked": revoked})
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from molodetz.constants import ROLE_MEMBER
|
||||||
|
from molodetz.database import (
|
||||||
|
claim_invite,
|
||||||
|
create_session,
|
||||||
|
create_user,
|
||||||
|
get_int_setting,
|
||||||
|
get_invite_by_token,
|
||||||
|
get_user_by_email,
|
||||||
|
get_user_by_username,
|
||||||
|
invite_is_live,
|
||||||
|
update_join_request,
|
||||||
|
)
|
||||||
|
from molodetz.dependencies import json_or_form
|
||||||
|
from molodetz.models import InviteClaimForm
|
||||||
|
from molodetz.responses import action_result, json_error, respond, wants_json
|
||||||
|
from molodetz.schemas.joins import InvitePageOut
|
||||||
|
from molodetz.seo import base_seo_context
|
||||||
|
from molodetz.templating import templates
|
||||||
|
from molodetz.utils.audit import record
|
||||||
|
from molodetz.utils.auth import hash_password_async
|
||||||
|
from molodetz.utils.request_meta import is_secure_request
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
INVALID_MESSAGE = "This invite link is invalid or has expired."
|
||||||
|
|
||||||
|
|
||||||
|
def _seo(request):
|
||||||
|
return base_seo_context(request, "Accept invite", robots="noindex,nofollow")
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(request):
|
||||||
|
record(request, "invite.claim.invalid", result="denied")
|
||||||
|
if wants_json(request):
|
||||||
|
return json_error(404, INVALID_MESSAGE)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"invite.html",
|
||||||
|
{"valid": False, "errors": [INVALID_MESSAGE], "form": {}, "email_locked": False, "seo": _seo(request)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_errors(data, invite):
|
||||||
|
errors = []
|
||||||
|
email = (data.email or "").lower()
|
||||||
|
if not EMAIL_PATTERN.match(email):
|
||||||
|
errors.append("Enter a valid email address.")
|
||||||
|
elif invite.get("email") and email != invite["email"]:
|
||||||
|
errors.append("This invite was issued for a different email address.")
|
||||||
|
if get_user_by_username(data.username):
|
||||||
|
errors.append("That name is already taken.")
|
||||||
|
if get_user_by_email(email):
|
||||||
|
errors.append("That email address is already in use.")
|
||||||
|
if data.password != data.password_confirm:
|
||||||
|
errors.append("The passwords do not match.")
|
||||||
|
if not data.terms:
|
||||||
|
errors.append("Accept the house rules to continue.")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{token}")
|
||||||
|
async def invite_page(request: Request, token: str):
|
||||||
|
invite = get_invite_by_token(token)
|
||||||
|
if not invite_is_live(invite):
|
||||||
|
return _invalid(request)
|
||||||
|
ctx = {
|
||||||
|
"valid": True,
|
||||||
|
"username": invite.get("username") or "",
|
||||||
|
"email": invite.get("email") or "",
|
||||||
|
"errors": [],
|
||||||
|
"form": {"username": invite.get("username") or "", "email": invite.get("email") or ""},
|
||||||
|
"email_locked": bool(invite.get("email")),
|
||||||
|
"seo": _seo(request),
|
||||||
|
}
|
||||||
|
return respond(request, "invite.html", ctx, model=InvitePageOut)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{token}")
|
||||||
|
async def invite_claim(request: Request, token: str):
|
||||||
|
invite = get_invite_by_token(token)
|
||||||
|
if not invite_is_live(invite):
|
||||||
|
return _invalid(request)
|
||||||
|
data = await json_or_form(request, InviteClaimForm)
|
||||||
|
errors = _form_errors(data, invite)
|
||||||
|
if errors:
|
||||||
|
record(request, "invite.claim.rejected", result="denied", payload={"errors": len(errors)})
|
||||||
|
ctx = {
|
||||||
|
"valid": True,
|
||||||
|
"errors": errors,
|
||||||
|
"form": {"username": data.username, "email": data.email},
|
||||||
|
"email_locked": bool(invite.get("email")),
|
||||||
|
"seo": _seo(request),
|
||||||
|
}
|
||||||
|
if wants_json(request):
|
||||||
|
return json_error(400, errors[0], errors=errors)
|
||||||
|
return templates.TemplateResponse(request, "invite.html", ctx, status_code=400)
|
||||||
|
email = data.email.lower()
|
||||||
|
password_hash = await hash_password_async(data.password)
|
||||||
|
user = create_user(data.username, email, password_hash, role=ROLE_MEMBER)
|
||||||
|
claim_invite(invite["uid"], user["uid"])
|
||||||
|
update_join_request(invite["join_request_uid"], status="accepted")
|
||||||
|
record(
|
||||||
|
request,
|
||||||
|
"invite.claimed",
|
||||||
|
targets=[("user", user["uid"]), ("join_request", invite["join_request_uid"])],
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
days = get_int_setting("session_max_age_days", 7)
|
||||||
|
max_age = max(1, days) * 86400
|
||||||
|
session_token = create_session(user["uid"], max_age)
|
||||||
|
response = action_result(request, "/", data={"username": user["username"]})
|
||||||
|
response.set_cookie(
|
||||||
|
key="session",
|
||||||
|
value=session_token,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=is_secure_request(request),
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -10,6 +10,14 @@ class AdminIndexOut(OutModel):
|
|||||||
open_joins: int = 0
|
open_joins: int = 0
|
||||||
user_count: int = 0
|
user_count: int = 0
|
||||||
deleted_count: int = 0
|
deleted_count: int = 0
|
||||||
|
flyer_count: int = 0
|
||||||
|
meme_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryStatusOut(OutModel):
|
||||||
|
flyers: int = 0
|
||||||
|
memes: int = 0
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class AdminPostsOut(OutModel):
|
class AdminPostsOut(OutModel):
|
||||||
|
|||||||
@@ -16,7 +16,16 @@ class JoinRequestOut(OutModel):
|
|||||||
link_title: str | None = None
|
link_title: str | None = None
|
||||||
link_status: str | None = None
|
link_status: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
|
invite_open: bool = False
|
||||||
|
invite_expires_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class JoinListOut(OutModel):
|
class JoinListOut(OutModel):
|
||||||
requests: list[JoinRequestOut] = []
|
requests: list[JoinRequestOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class InvitePageOut(OutModel):
|
||||||
|
valid: bool = False
|
||||||
|
username: str = ""
|
||||||
|
email: str = ""
|
||||||
|
email_locked: bool = False
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- retoor <retoor@molodetz.nl> -->
|
||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% block admin_content %}
|
||||||
|
<h1>Gallery</h1>
|
||||||
|
<div class="stat-cards">
|
||||||
|
<div class="stat-card"><span class="value">{{ flyers }}</span><span class="name">Flyers</span></div>
|
||||||
|
<div class="stat-card"><span class="value">{{ memes }}</span><span class="name">Memes</span></div>
|
||||||
|
</div>
|
||||||
|
{% if missing %}
|
||||||
|
<div class="notice error" role="alert"><p>Missing source files:</p><ul>{% for filename in missing %}<li><code>{{ filename }}</code></li>{% endfor %}</ul></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">All catalogued source files are present.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/gallery/resync">
|
||||||
|
<div class="actions"><button class="button" type="submit" data-loading>Resync gallery</button></div>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Resync re-reads the source files, refreshes changed thumbnails and retires removed entries. New works arrive through deploy first.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open join requests</span></div>
|
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open join requests</span></div>
|
||||||
<div class="stat-card"><span class="value">{{ user_count }}</span><span class="name">Accounts</span></div>
|
<div class="stat-card"><span class="value">{{ user_count }}</span><span class="name">Accounts</span></div>
|
||||||
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In trash</span></div>
|
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In trash</span></div>
|
||||||
|
<div class="stat-card"><span class="value">{{ flyer_count }}</span><span class="name">Flyers</span></div>
|
||||||
|
<div class="stat-card"><span class="value">{{ meme_count }}</span><span class="name">Memes</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a></div>
|
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a><a class="button ghost" href="/admin/gallery">Gallery</a></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- retoor <retoor@molodetz.nl> -->
|
||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% block admin_content %}
|
||||||
|
<h1>Invite issued</h1>
|
||||||
|
<p>Send this link to <strong>{{ join.name }}</strong> ({{ join.contact }}). It is shown once and expires {{ local_dt(expires_at) }}.</p>
|
||||||
|
<div class="notice"><p><code>{{ claim_url }}</code></p></div>
|
||||||
|
<p class="muted">Issuing a new invite revokes this one. The request flips to accepted when the link is claimed.</p>
|
||||||
|
<p><a class="button ghost" href="/admin/joins">Back to join requests</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<td>{{ item.contact }}</td>
|
<td>{{ item.contact }}</td>
|
||||||
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">none</span>{% endif %}</td>
|
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">none</span>{% endif %}</td>
|
||||||
<td>{{ item.message }}</td>
|
<td>{{ item.message }}</td>
|
||||||
<td><span class="status {{ item.status }}">{{ item.status }}</span></td>
|
<td><span class="status {{ item.status }}">{{ item.status }}</span>{% if item.invite_open %}<br><span class="muted">invite until {{ local_dt(item.invite_expires_at) }}</span>{% endif %}</td>
|
||||||
<td>{{ local_dt(item.created_at) }}</td>
|
<td>{{ local_dt(item.created_at) }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/admin/joins/{{ item.uid }}/status">
|
<form method="post" action="/admin/joins/{{ item.uid }}/status">
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
<button class="button ghost small" type="submit">Set</button>
|
<button class="button ghost small" type="submit">Set</button>
|
||||||
</form>
|
</form>
|
||||||
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Check link</button></form>{% endif %}
|
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Check link</button></form>{% endif %}
|
||||||
|
{% if item.invite_open %}<form method="post" action="/admin/joins/{{ item.uid }}/invite/revoke"><button class="button ghost small" type="submit">Revoke invite</button></form>
|
||||||
|
{% elif item.status != 'declined' %}<form method="post" action="/admin/joins/{{ item.uid }}/invite"><button class="button ghost small" type="submit" data-loading>Issue invite</button></form>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ curl -H 'Accept: application/json' https://molodetz.nl/roll
|
|||||||
|
|
||||||
Errors have the shape `{"error": {"status": 404, "message": "..."}}`. Validation errors return `422` with `{"error": "validation", "fields": [...], "messages": [...]}`.
|
Errors have the shape `{"error": {"status": 404, "message": "..."}}`. Validation errors return `422` with `{"error": "validation", "fields": [...], "messages": [...]}`.
|
||||||
|
|
||||||
|
## Invites
|
||||||
|
|
||||||
|
Membership starts from a join request plus an admin-issued invite. Admins call `POST /admin/joins/{uid}/invite` (returns `claim_url` and `expires_at` in `data`) and `POST /admin/joins/{uid}/invite/revoke`. The public claim is `GET` and `POST /invite/{token}` with `username`, `email`, `password`, `password_confirm` and `terms`. Claim links are single use and every dead link answers 404 with the same message.
|
||||||
|
|
||||||
|
## Gallery
|
||||||
|
|
||||||
|
`GET /admin/gallery` reports live flyer and meme counts plus catalogued source files missing from disk. `POST /admin/gallery/resync` re-reads the sources and retires removed entries. See [Galleries and content](/docs/content) for how publishing works.
|
||||||
|
|
||||||
## Old paths
|
## Old paths
|
||||||
|
|
||||||
The Dutch paths from before (`/rol`, `/standaard`, `/mensen`, `/binnen`, `/voorwaarden`) answer with a 301 to their English replacement. Query strings are kept.
|
The Dutch paths from before (`/rol`, `/standaard`, `/mensen`, `/binnen`, `/voorwaarden`) answer with a 301 to their English replacement. Query strings are kept.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Galleries and content
|
||||||
|
|
||||||
|
The flyers and the memes are curated galleries. Every work is a source file plus a catalogue entry with its caption. Nothing is uploaded through the site: new works arrive with a deploy, and the database reconciles at boot.
|
||||||
|
|
||||||
|
## Flyers
|
||||||
|
|
||||||
|
Flyers live at [/flyers](/flyers). New flyers are 1080 by 1350 (portrait). The first flyer in the catalogue is the featured one on the home page.
|
||||||
|
|
||||||
|
## Memes
|
||||||
|
|
||||||
|
Memes live at [/memes](/memes). New memes are 1080 by 1080 (square). Some older memes are 1280 by 720 (landscape) from the previous generation; they stay as they are.
|
||||||
|
|
||||||
|
## How publishing works
|
||||||
|
|
||||||
|
1. The source file lands in the media directory and the catalogue entry (filename, kind, caption) lands in the code, both through deploy.
|
||||||
|
2. At boot the sync reads every catalogued source file and upserts the database row: position, dimensions, perceptual hash and a generated webp thumbnail.
|
||||||
|
3. Near-duplicates are skipped: a work whose image hash is within a small distance of an already synced work is logged and left out, so an accidental double never shows twice.
|
||||||
|
4. Removed works are retired, never hard-deleted: their rows are soft-deleted and disappear from the galleries.
|
||||||
|
|
||||||
|
An administrator can also press *Resync* under *Admin, Gallery* after a deploy. Resync re-reads the sources, refreshes thumbnails whose content changed, reports missing source files and retires removed entries. It never invents catalogue entries.
|
||||||
|
|
||||||
|
## Freshness
|
||||||
|
|
||||||
|
Public pages are cached for seconds to minutes (landing, settings, sitemap each have their own short TTL), so a fresh deploy can take a moment to show everywhere. The exact TTLs are operator detail; see the operator runbook under *Admin*.
|
||||||
|
|
||||||
|
Old Dutch paths (`/rol`, `/mensen`, and friends) redirect with 301 to their English replacements; see [API and authentication](/docs/api).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Operator runbook
|
||||||
|
|
||||||
|
This page is only visible to administrators. It covers the flows that move people and content through the system: invites, gallery updates, trash, caches and the environment rules.
|
||||||
|
|
||||||
|
## Member invites
|
||||||
|
|
||||||
|
Join requests end in membership through an invite link. The full flow:
|
||||||
|
|
||||||
|
1. Read the request under *Admin, Join requests*. `Check link` fetches the linked work once and stores the outcome on the request.
|
||||||
|
2. Move the status to `contacted` while talking, `declined` to refuse. A declined request cannot receive an invite.
|
||||||
|
3. Press *Issue invite*. The claim link is shown once on the confirmation page: copy it and send it to the person yourself. There is no email sending.
|
||||||
|
4. The person opens the link, picks a name and password and accepts the house rules. Claiming creates a Member account, logs them in and flips the request to `accepted`.
|
||||||
|
|
||||||
|
Rules the code enforces:
|
||||||
|
|
||||||
|
- One open invite per request. Issuing a new one revokes the previous.
|
||||||
|
- Single use, 7 day expiry by default (`invite_expiry_days` setting).
|
||||||
|
- Only a hash of the token is stored; a database read never yields a usable link.
|
||||||
|
- Unknown, used, revoked and expired links all answer identically (404, same message), so links cannot be probed for state.
|
||||||
|
- If the contact looked like an email address, the claim must use that same address. Handle-style contacts leave the email free for the claimer to fill in.
|
||||||
|
- `Revoke invite` kills the open link without touching the request.
|
||||||
|
|
||||||
|
API: `POST /admin/joins/{uid}/invite` returns `{"claim_url", "expires_at"}` in `data` (JSON only; the HTML form renders the one-time page instead). `POST /admin/joins/{uid}/invite/revoke` returns `{"revoked": n}`. Public claim is `GET` and `POST /invite/{token}`.
|
||||||
|
|
||||||
|
## Gallery updates
|
||||||
|
|
||||||
|
Source files plus catalogue entries arrive through deploy. After deploy, press *Resync* under *Admin, Gallery* (or `POST /admin/gallery/resync`). The status page shows live flyer and meme counts and lists catalogued files missing from disk. Resync refreshes thumbnails whose content changed and retires removed entries. Boot runs the same sync, so a production rebuild never needs the button.
|
||||||
|
|
||||||
|
## Trash
|
||||||
|
|
||||||
|
Soft-deleted rows carry a `deleted_at` stamp (UTC ISO). Restore and purge match the exact stamp across every soft-delete table, so one trash event restores or purges as a unit. Purge is permanent; there is no undo.
|
||||||
|
|
||||||
|
## Sessions and suspension
|
||||||
|
|
||||||
|
Login resolution order is session cookie, `X-API-KEY`, Bearer, Basic; see [API and authentication](/docs/api). Sessions live 7 days, 30 with *remember me* (`session_max_age_days`, `session_remember_days`). A suspended account (`suspended_until` in the future) is refused on every mutating route outside `/auth` with 403. Users whose `terms_version` lags behind the setting are sent to `/terms` on mutating routes (redirect for browsers, 403 with a redirect target for JSON) until they accept.
|
||||||
|
|
||||||
|
## Caches
|
||||||
|
|
||||||
|
Short TTLs everywhere; defaults below, environment variables may override per deploy:
|
||||||
|
|
||||||
|
| Cache | Default |
|
||||||
|
|-------|---------|
|
||||||
|
| landing page | 30 s |
|
||||||
|
| settings | 60 s |
|
||||||
|
| version | 1 s |
|
||||||
|
| unread counts | 10 s |
|
||||||
|
| sitemap | 3600 s |
|
||||||
|
| auth user lookups | 300 s |
|
||||||
|
|
||||||
|
Docs prose is additionally cached in the process for its lifetime: doc text changes need a staging restart or a production rebuild to show.
|
||||||
|
|
||||||
|
## Environments and data
|
||||||
|
|
||||||
|
All runtime data lives under `./data/[env]` (database, blobs, backups, logs). Never anything outside it. Production data is holy: copies flow production to staging only, never back. Staging runs the dev server, so file changes show without restart except for the process-lifetime caches above. Production is a baked image and needs a rebuild for code, prose and media changes.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- retoor <retoor@molodetz.nl> -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="article-header">
|
||||||
|
<span class="label">Invite</span>
|
||||||
|
<h1>Accept your invite</h1>
|
||||||
|
</header>
|
||||||
|
{% if not valid %}
|
||||||
|
<div class="notice error" role="alert"><ul>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
|
||||||
|
<p><a class="button ghost" href="/">Back home</a></p>
|
||||||
|
{% else %}
|
||||||
|
{% if errors %}
|
||||||
|
<div class="notice error" role="alert"><ul>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
|
||||||
|
{% endif %}
|
||||||
|
<form class="form" method="post">
|
||||||
|
<div class="field"><label for="username">Name</label><input id="username" type="text" name="username" required minlength="3" maxlength="32" pattern="[A-Za-z0-9_-]{3,32}" value="{{ (form or {}).username or '' }}" autocomplete="username"><p class="hint">3 to 32 letters, digits, _ or -.</p></div>
|
||||||
|
<div class="field"><label for="email">Email</label><input id="email" type="email" name="email" required maxlength="255" value="{{ (form or {}).email or '' }}" autocomplete="email"{% if email_locked %} readonly{% endif %}>{% if email_locked %}<p class="hint">This invite was issued for this address.</p>{% endif %}</div>
|
||||||
|
<div class="field"><label for="password">Password</label><input id="password" type="password" name="password" required minlength="6" maxlength="128" autocomplete="new-password"></div>
|
||||||
|
<div class="field"><label for="password_confirm">Password again</label><input id="password_confirm" type="password" name="password_confirm" required minlength="6" maxlength="128" autocomplete="new-password"></div>
|
||||||
|
<label class="check"><input type="checkbox" name="terms" value="true"> I accept the <a href="/terms" target="_blank" rel="noopener">house rules</a></label>
|
||||||
|
<div class="actions"><button class="button" type="submit" data-loading>Create my account</button></div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "molodetz"
|
name = "molodetz"
|
||||||
version = "1.0.17"
|
version = "1.0.18"
|
||||||
description = "Molodetz, a calm community blog roll."
|
description = "Molodetz, a calm community blog roll."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+32
-3
@@ -34,14 +34,18 @@ PUBLIC_SHOTS = [
|
|||||||
("terms", "/terms", "Terms"),
|
("terms", "/terms", "Terms"),
|
||||||
("privacy", "/privacy", "Privacy"),
|
("privacy", "/privacy", "Privacy"),
|
||||||
("login", "/auth/login", "Log in"),
|
("login", "/auth/login", "Log in"),
|
||||||
|
("invite-invalid", "/invite/gallery-sample-expired", "Invite expired"),
|
||||||
]
|
]
|
||||||
|
|
||||||
DOCS_SHOTS = [
|
DOCS_SHOTS = [
|
||||||
("docs", "/docs", "Docs"),
|
("docs", "/docs", "Docs"),
|
||||||
("docs-welcome", "/docs/welcome", "Docs welcome"),
|
("docs-welcome", "/docs/welcome", "Docs welcome"),
|
||||||
|
("docs-writing", "/docs/writing", "Docs writing"),
|
||||||
|
("docs-join", "/docs/join", "Docs join"),
|
||||||
|
("docs-legal", "/docs/legal", "Docs legal"),
|
||||||
|
("docs-content", "/docs/content", "Docs content"),
|
||||||
("docs-api", "/docs/api", "Docs API index"),
|
("docs-api", "/docs/api", "Docs API index"),
|
||||||
("docs-api-content", "/docs/api/content", "Docs API content"),
|
("docs-api-content", "/docs/api/content", "Docs API content"),
|
||||||
("docs-component-code", "/docs/component-code", "Docs component code"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ADMIN_SHOTS = [
|
ADMIN_SHOTS = [
|
||||||
@@ -57,6 +61,10 @@ ADMIN_SHOTS = [
|
|||||||
("admin-audit", "/admin/audit", "Admin audit"),
|
("admin-audit", "/admin/audit", "Admin audit"),
|
||||||
("admin-stats", "/admin/stats", "Admin stats"),
|
("admin-stats", "/admin/stats", "Admin stats"),
|
||||||
("admin-trash", "/admin/trash", "Admin trash"),
|
("admin-trash", "/admin/trash", "Admin trash"),
|
||||||
|
("admin-gallery", "/admin/gallery", "Admin gallery"),
|
||||||
|
("docs-admin", "/docs/admin", "Docs admin"),
|
||||||
|
("docs-operator", "/docs/operator", "Docs operator"),
|
||||||
|
("docs-backups", "/docs/backups", "Docs backups"),
|
||||||
("notifications", "/notifications", "Notifications"),
|
("notifications", "/notifications", "Notifications"),
|
||||||
("profile-api-key", "/profile/api-key", "Profile API key"),
|
("profile-api-key", "/profile/api-key", "Profile API key"),
|
||||||
("docs-api-admin", "/docs/api/admin", "Docs API admin"),
|
("docs-api-admin", "/docs/api/admin", "Docs API admin"),
|
||||||
@@ -178,6 +186,22 @@ def seed_join(base):
|
|||||||
raise RuntimeError(f"seed join failed: {response.status_code}")
|
raise RuntimeError(f"seed join failed: {response.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_invite(base):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
with httpx.Client(base_url=base, timeout=15.0) as client:
|
||||||
|
login = client.post("/auth/login", data={"username": ADMIN_USERNAME, "password": BOOT_ADMIN_PASSWORD})
|
||||||
|
if login.status_code not in (302, 303):
|
||||||
|
raise RuntimeError(f"seed invite login failed: {login.status_code}")
|
||||||
|
rows = client.get("/admin/joins", headers={"Accept": "application/json"}).json()["requests"]
|
||||||
|
uid = next(row["uid"] for row in rows if row["name"] == "Gallery Writer")
|
||||||
|
issued = client.post(f"/admin/joins/{uid}/invite", headers={"Accept": "application/json"})
|
||||||
|
if issued.status_code != 200:
|
||||||
|
raise RuntimeError(f"seed invite issue failed: {issued.status_code}")
|
||||||
|
claim_url = issued.json()["data"]["claim_url"]
|
||||||
|
return "/invite/" + claim_url.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
def first_post_slug(base):
|
def first_post_slug(base):
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -202,7 +226,7 @@ def check_overflow(page, path, viewport):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def capture_full(browser, out_dir, base, password, records, counter):
|
def capture_full(browser, out_dir, base, password, records, counter, invite_path=None):
|
||||||
anon = browser.new_context(viewport=FULL_DESKTOP)
|
anon = browser.new_context(viewport=FULL_DESKTOP)
|
||||||
page = anon.new_page()
|
page = anon.new_page()
|
||||||
page.set_default_timeout(60000)
|
page.set_default_timeout(60000)
|
||||||
@@ -212,6 +236,9 @@ def capture_full(browser, out_dir, base, password, records, counter):
|
|||||||
slug = first_post_slug(base)
|
slug = first_post_slug(base)
|
||||||
records.append(capture(page, out_dir, base, counter, "post", f"/posts/{slug}", "Post", "Public", "full", True))
|
records.append(capture(page, out_dir, base, counter, "post", f"/posts/{slug}", "Post", "Public", "full", True))
|
||||||
counter += 1
|
counter += 1
|
||||||
|
if invite_path:
|
||||||
|
records.append(capture(page, out_dir, base, counter, "invite-claim", invite_path, "Invite claim", "Public", "full", True))
|
||||||
|
counter += 1
|
||||||
for name, path, title in DOCS_SHOTS:
|
for name, path, title in DOCS_SHOTS:
|
||||||
records.append(capture(page, out_dir, base, counter, name, path, title, "Docs", "full", True))
|
records.append(capture(page, out_dir, base, counter, name, path, title, "Docs", "full", True))
|
||||||
counter += 1
|
counter += 1
|
||||||
@@ -441,6 +468,7 @@ def main():
|
|||||||
log = None
|
log = None
|
||||||
records = []
|
records = []
|
||||||
failures = []
|
failures = []
|
||||||
|
invite_path = None
|
||||||
try:
|
try:
|
||||||
if boot:
|
if boot:
|
||||||
if REPO_ROOT / "data" not in SHOTS_DATA_DIR.parents:
|
if REPO_ROOT / "data" not in SHOTS_DATA_DIR.parents:
|
||||||
@@ -449,6 +477,7 @@ def main():
|
|||||||
SHOTS_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
process, log = start_server(SHOTS_DATA_DIR)
|
process, log = start_server(SHOTS_DATA_DIR)
|
||||||
seed_join(base)
|
seed_join(base)
|
||||||
|
invite_path = seed_invite(base)
|
||||||
else:
|
else:
|
||||||
wait_live(base)
|
wait_live(base)
|
||||||
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
|
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
|
||||||
@@ -456,7 +485,7 @@ def main():
|
|||||||
clear_output(out_dir)
|
clear_output(out_dir)
|
||||||
with sync_playwright() as playwright:
|
with sync_playwright() as playwright:
|
||||||
browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo)
|
browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo)
|
||||||
counter = capture_full(browser, out_dir, base, password, records, 1)
|
counter = capture_full(browser, out_dir, base, password, records, 1, invite_path)
|
||||||
capture_responsive(browser, out_dir, base, records, failures, counter)
|
capture_responsive(browser, out_dir, base, records, failures, counter)
|
||||||
browser.close()
|
browser.close()
|
||||||
checked = len(RESPONSIVE_PAGES) * len(RESPONSIVE_VIEWPORTS)
|
checked = len(RESPONSIVE_PAGES) * len(RESPONSIVE_VIEWPORTS)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
JSON = {"Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_status_requires_admin(anon, member):
|
||||||
|
assert anon.get("/admin/gallery", headers=JSON).status_code == 401
|
||||||
|
assert member.get("/admin/gallery", headers=JSON).status_code == 403
|
||||||
|
assert anon.post("/admin/gallery/resync", headers=JSON).status_code == 401
|
||||||
|
assert member.post("/admin/gallery/resync", headers=JSON).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_status_reports_counts(admin):
|
||||||
|
response = admin.get("/admin/gallery", headers=JSON)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["flyers"] >= 1
|
||||||
|
assert body["memes"] >= 1
|
||||||
|
assert body["missing"] == []
|
||||||
|
page = admin.get("/admin/gallery")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "Resync gallery" in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_resync_reconciles(admin):
|
||||||
|
response = admin.post("/admin/gallery/resync", headers=JSON)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()["data"]
|
||||||
|
assert data["synced"] == data["flyers"] + data["memes"]
|
||||||
|
assert data["missing"] == []
|
||||||
|
form = admin.post("/admin/gallery/resync")
|
||||||
|
assert form.status_code in (302, 303)
|
||||||
|
assert form.headers["location"] == "/admin/gallery"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from molodetz.docs_prose import DOCS_PAGES, PROSE_DIR, page_source, render_page
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_docs_page_has_source_and_renders():
|
||||||
|
for page in DOCS_PAGES:
|
||||||
|
assert (PROSE_DIR / page["file"]).is_file(), page["slug"]
|
||||||
|
assert page_source(page["slug"]).strip(), page["slug"]
|
||||||
|
assert render_page(page["slug"]).strip(), page["slug"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("page", DOCS_PAGES, ids=lambda page: page["slug"])
|
||||||
|
def test_docs_page_reachability_matches_gate(page, anon, admin):
|
||||||
|
response = admin.get(f"/docs/{page['slug']}")
|
||||||
|
assert response.status_code == 200, page["slug"]
|
||||||
|
assert page["title"] in response.text
|
||||||
|
anon_response = anon.get(f"/docs/{page['slug']}")
|
||||||
|
if page.get("admin"):
|
||||||
|
assert anon_response.status_code == 404, page["slug"]
|
||||||
|
else:
|
||||||
|
assert anon_response.status_code == 200, page["slug"]
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
JSON = {"Accept": "application/json"}
|
||||||
|
INVALID_MESSAGE = "This invite link is invalid or has expired."
|
||||||
|
|
||||||
|
|
||||||
|
def _join_uid(admin, name, contact):
|
||||||
|
response = admin.post("/join", json={"name": name, "contact": contact}, headers=JSON)
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
|
||||||
|
return next(row for row in rows if row["name"] == name)["uid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(admin, uid):
|
||||||
|
response = admin.post(f"/admin/joins/{uid}/invite", headers=JSON)
|
||||||
|
assert response.status_code == 200, response.text[:300]
|
||||||
|
return response.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _claim(anon, token, payload):
|
||||||
|
return anon.post(f"/invite/{token}", json=payload, headers=JSON)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_full_claim_flow(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee One", "invitee.one@example.invalid")
|
||||||
|
issued = _issue(admin, uid)
|
||||||
|
token = issued["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
page = anon.get(f"/invite/{token}")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "Accept your invite" in page.text
|
||||||
|
claimed = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_one",
|
||||||
|
"email": "invitee.one@example.invalid",
|
||||||
|
"password": "secret-one-1",
|
||||||
|
"password_confirm": "secret-one-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert claimed.status_code == 200, claimed.text[:300]
|
||||||
|
assert claimed.json()["data"]["username"] == "invitee_one"
|
||||||
|
assert "session" in anon.cookies
|
||||||
|
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
|
||||||
|
row = next(row for row in rows if row["uid"] == uid)
|
||||||
|
assert row["status"] == "accepted"
|
||||||
|
assert row["invite_open"] is False
|
||||||
|
again = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_other",
|
||||||
|
"email": "invitee.one@example.invalid",
|
||||||
|
"password": "secret-one-2",
|
||||||
|
"password_confirm": "secret-one-2",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert again.status_code == 404
|
||||||
|
assert again.json()["error"]["message"] == INVALID_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_issue_requires_admin(anon, member, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Two", "invitee.two@example.invalid")
|
||||||
|
assert anon.post(f"/admin/joins/{uid}/invite", headers=JSON).status_code == 401
|
||||||
|
assert member.post(f"/admin/joins/{uid}/invite", headers=JSON).status_code == 403
|
||||||
|
assert anon.post(f"/admin/joins/{uid}/invite/revoke", headers=JSON).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_declined_request_is_refused(admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Three", "invitee.three@example.invalid")
|
||||||
|
declined = admin.post(f"/admin/joins/{uid}/status", json={"status": "declined"}, headers=JSON)
|
||||||
|
assert declined.status_code == 200
|
||||||
|
refused = admin.post(f"/admin/joins/{uid}/invite", headers=JSON)
|
||||||
|
assert refused.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_wrong_email_is_rejected(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Four", "invitee.four@example.invalid")
|
||||||
|
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
response = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_four",
|
||||||
|
"email": "someone.else@example.invalid",
|
||||||
|
"password": "secret-four-1",
|
||||||
|
"password_confirm": "secret-four-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "different email address" in response.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_expired_token_reveals_nothing(anon, admin):
|
||||||
|
from molodetz.database import db
|
||||||
|
|
||||||
|
uid = _join_uid(admin, "Invitee Five", "invitee.five@example.invalid")
|
||||||
|
issued = _issue(admin, uid)
|
||||||
|
token = issued["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||||
|
with db:
|
||||||
|
db.query("UPDATE member_invites SET expires_at = :past WHERE join_request_uid = :uid", past=past, uid=uid)
|
||||||
|
page = anon.get(f"/invite/{token}")
|
||||||
|
assert page.status_code == 404
|
||||||
|
assert INVALID_MESSAGE in page.text
|
||||||
|
response = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_five",
|
||||||
|
"email": "invitee.five@example.invalid",
|
||||||
|
"password": "secret-five-1",
|
||||||
|
"password_confirm": "secret-five-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["error"]["message"] == INVALID_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_unknown_token_reveals_nothing(anon):
|
||||||
|
page = anon.get("/invite/does-not-exist")
|
||||||
|
assert page.status_code == 404
|
||||||
|
assert INVALID_MESSAGE in page.text
|
||||||
|
response = _claim(
|
||||||
|
anon,
|
||||||
|
"does-not-exist",
|
||||||
|
{"username": "ghost_user", "email": "ghost@example.invalid", "password": "secret-1", "password_confirm": "secret-1", "terms": True},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["error"]["message"] == INVALID_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_revoke_kills_token(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Six", "invitee.six@example.invalid")
|
||||||
|
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
revoked = admin.post(f"/admin/joins/{uid}/invite/revoke", headers=JSON)
|
||||||
|
assert revoked.status_code == 200
|
||||||
|
assert revoked.json()["data"]["revoked"] == 1
|
||||||
|
response = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_six",
|
||||||
|
"email": "invitee.six@example.invalid",
|
||||||
|
"password": "secret-six-1",
|
||||||
|
"password_confirm": "secret-six-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_reissue_revokes_previous(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Seven", "invitee.seven@example.invalid")
|
||||||
|
first = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
second = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
assert first != second
|
||||||
|
stale = _claim(
|
||||||
|
anon,
|
||||||
|
first,
|
||||||
|
{
|
||||||
|
"username": "invitee_seven",
|
||||||
|
"email": "invitee.seven@example.invalid",
|
||||||
|
"password": "secret-seven-1",
|
||||||
|
"password_confirm": "secret-seven-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert stale.status_code == 404
|
||||||
|
fresh = _claim(
|
||||||
|
anon,
|
||||||
|
second,
|
||||||
|
{
|
||||||
|
"username": "invitee_seven",
|
||||||
|
"email": "invitee.seven@example.invalid",
|
||||||
|
"password": "secret-seven-1",
|
||||||
|
"password_confirm": "secret-seven-1",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert fresh.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_claim_validation(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Eight", "invitee.eight@example.invalid")
|
||||||
|
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
short = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_eight",
|
||||||
|
"email": "invitee.eight@example.invalid",
|
||||||
|
"password": "short",
|
||||||
|
"password_confirm": "short",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert short.status_code == 422
|
||||||
|
mismatch = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_eight",
|
||||||
|
"email": "invitee.eight@example.invalid",
|
||||||
|
"password": "secret-eight-1",
|
||||||
|
"password_confirm": "secret-eight-2",
|
||||||
|
"terms": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert mismatch.status_code == 400
|
||||||
|
no_terms = _claim(
|
||||||
|
anon,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"username": "invitee_eight",
|
||||||
|
"email": "invitee.eight@example.invalid",
|
||||||
|
"password": "secret-eight-1",
|
||||||
|
"password_confirm": "secret-eight-1",
|
||||||
|
"terms": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert no_terms.status_code == 400
|
||||||
|
assert "house rules" in no_terms.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invite_html_claim_flow(anon, admin):
|
||||||
|
uid = _join_uid(admin, "Invitee Nine", "invitee.nine@example.invalid")
|
||||||
|
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
|
||||||
|
bad_form = anon.post(
|
||||||
|
f"/invite/{token}",
|
||||||
|
data={
|
||||||
|
"username": "x",
|
||||||
|
"email": "invitee.nine@example.invalid",
|
||||||
|
"password": "secret-nine-1",
|
||||||
|
"password_confirm": "secret-nine-1",
|
||||||
|
"terms": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert bad_form.status_code == 400
|
||||||
|
assert "Accept your invite" in bad_form.text
|
||||||
|
response = anon.post(
|
||||||
|
f"/invite/{token}",
|
||||||
|
data={
|
||||||
|
"username": "invitee_nine",
|
||||||
|
"email": "invitee.nine@example.invalid",
|
||||||
|
"password": "secret-nine-1",
|
||||||
|
"password_confirm": "secret-nine-1",
|
||||||
|
"terms": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code in (302, 303)
|
||||||
|
assert "session" in anon.cookies
|
||||||
|
used = anon.get(f"/invite/{token}")
|
||||||
|
assert used.status_code == 404
|
||||||
|
assert INVALID_MESSAGE in used.text
|
||||||
+5
-1
@@ -1,8 +1,12 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
import re
|
||||||
|
|
||||||
|
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
|
||||||
|
|
||||||
|
|
||||||
def test_people_lists_real_accounts_only(anon):
|
def test_people_lists_real_accounts_only(anon):
|
||||||
people = anon.get("/people", headers={"Accept": "application/json"}).json()
|
people = anon.get("/people", headers={"Accept": "application/json"}).json()
|
||||||
names = [person["username"] for person in people.get("people", [])]
|
names = [person["username"] for person in people.get("people", [])]
|
||||||
assert names[0] == "retoor"
|
assert names[0] == "retoor"
|
||||||
assert set(names) <= {"retoor", "lezer"}
|
assert {"retoor", "lezer"} <= set(names)
|
||||||
|
assert all(USERNAME_PATTERN.match(name) for name in names)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Gallery generator contract
|
||||||
|
|
||||||
|
This directory is the home for gallery generator scripts. Generators stay
|
||||||
|
runnable and reviewable here instead of in scratch. The checker
|
||||||
|
(`check.py`) validates sources against the contract below.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
- Filenames: lowercase `flyer-<slug>.jpg` or `meme-<slug>.jpg`, hyphen
|
||||||
|
slugs, no spaces, no dates, no version suffixes.
|
||||||
|
- Sizes: flyers exactly 1080x1350, memes exactly 1080x1080. The 1280x720
|
||||||
|
memes on disk are legacy and grandfathered; new works never use it.
|
||||||
|
- Format: JPEG, sRGB, quality high enough that text stays crisp.
|
||||||
|
- Captions: one English line per work, plain words, no em or en dashes.
|
||||||
|
Captions live in `molodetz/gallery.py` next to the filename entry.
|
||||||
|
|
||||||
|
## Handoff procedure
|
||||||
|
|
||||||
|
1. Generate into a scratch directory, never straight into `static/media`.
|
||||||
|
2. Run `make check-gallery GALLERY_DIR=<scratch>` (or point the checker
|
||||||
|
at the scratch dir directly). Fix every error; warnings are advisory.
|
||||||
|
3. Move the files into `molodetz/static/media`, add the `GALLERY` entries
|
||||||
|
with captions, run `make check-gallery` again with no arguments.
|
||||||
|
4. Commit code plus media together, deploy, then press *Resync gallery*
|
||||||
|
under *Admin, Gallery* (staging) or rely on the boot sync (production
|
||||||
|
rebuild).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never invent catalogue entries outside `GALLERY`: the sync only reads
|
||||||
|
what is catalogued, and the checker fails stray files.
|
||||||
|
- Near-duplicates (perceptual hash distance 2 or less) are skipped by
|
||||||
|
the sync and failed by the checker. Regenerate instead of renaming.
|
||||||
|
- Retired works stay listed in `RETIRED_GALLERY` so every environment
|
||||||
|
converges on the same visible set.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
"""Validate gallery source files against the catalogue contract.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/gallery/check.py [media_dir]
|
||||||
|
|
||||||
|
Checks every flyer-*.jpg and meme-*.jpg in the media directory (default:
|
||||||
|
the repo static/media tree) plus every entry of molodetz.gallery.GALLERY:
|
||||||
|
|
||||||
|
- name is lowercase, starts with flyer- or meme-, ends in .jpg
|
||||||
|
- flyers are 1080x1350, memes are 1080x1080 (1280x720 legacy memes warn)
|
||||||
|
- every source file is catalogued or retired, every catalogue entry has a file (repo tree only; scratch dirs get warnings)
|
||||||
|
- catalogue captions are non-empty English without em or en dashes
|
||||||
|
- no two catalogued works are perceptual near-duplicates (sync would skip one)
|
||||||
|
|
||||||
|
Exit code 0 when clean, 1 when problems are found. Warnings do not fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
import imagehash # noqa: E402
|
||||||
|
|
||||||
|
from molodetz.gallery import DUPLICATE_DISTANCE, GALLERY, RETIRED_GALLERY # noqa: E402
|
||||||
|
|
||||||
|
DIMENSIONS = {"flyer": (1080, 1350), "meme": (1080, 1080)}
|
||||||
|
LEGACY_MEME = (1280, 720)
|
||||||
|
|
||||||
|
|
||||||
|
def check(media_dir, strict=True):
|
||||||
|
errors, warnings = [], []
|
||||||
|
catalogued = {filename: (kind, caption) for filename, kind, caption in GALLERY}
|
||||||
|
retired = set(RETIRED_GALLERY)
|
||||||
|
files = sorted(path for path in media_dir.iterdir() if path.suffix.lower() == ".jpg" and path.stem.split("-")[0].lower() in ("flyer", "meme"))
|
||||||
|
seen = []
|
||||||
|
for path in files:
|
||||||
|
name = path.name
|
||||||
|
if name != name.lower():
|
||||||
|
errors.append(f"{name}: name must be lowercase")
|
||||||
|
kind = "flyer" if name.lower().startswith("flyer-") else "meme"
|
||||||
|
if name not in catalogued and name not in retired:
|
||||||
|
if strict:
|
||||||
|
errors.append(f"{name}: present on disk but missing from GALLERY")
|
||||||
|
continue
|
||||||
|
warnings.append(f"{name}: not catalogued yet, add a GALLERY entry on handoff")
|
||||||
|
caption = ""
|
||||||
|
elif name in retired:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
expected_kind, caption = catalogued[name]
|
||||||
|
if expected_kind != kind:
|
||||||
|
errors.append(f"{name}: catalogued as {expected_kind}, prefix says {kind}")
|
||||||
|
if strict and (not caption or not caption.strip()):
|
||||||
|
errors.append(f"{name}: catalogue caption is empty")
|
||||||
|
elif "\u2014" in caption or "\u2013" in caption:
|
||||||
|
errors.append(f"{name}: caption contains an em or en dash")
|
||||||
|
with Image.open(path) as image:
|
||||||
|
size = image.size
|
||||||
|
digest = imagehash.phash(image)
|
||||||
|
expected = DIMENSIONS[kind]
|
||||||
|
if size != expected:
|
||||||
|
if kind == "meme" and size == LEGACY_MEME:
|
||||||
|
warnings.append(f"{name}: legacy 1280x720 size, new memes must be 1080x1080")
|
||||||
|
else:
|
||||||
|
errors.append(f"{name}: size {size[0]}x{size[1]}, expected {expected[0]}x{expected[1]}")
|
||||||
|
for other_name, other_digest in seen:
|
||||||
|
if digest - other_digest <= DUPLICATE_DISTANCE:
|
||||||
|
errors.append(f"{name}: near-duplicate of {other_name}, sync would skip it")
|
||||||
|
seen.append((name, digest))
|
||||||
|
on_disk = {path.name for path in files}
|
||||||
|
if strict:
|
||||||
|
for filename in catalogued:
|
||||||
|
if filename not in on_disk:
|
||||||
|
errors.append(f"{filename}: catalogued in GALLERY but missing from {media_dir}")
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
default_dir = REPO_ROOT / "molodetz" / "static" / "media"
|
||||||
|
media_dir = Path(argv[1]) if len(argv) > 1 else default_dir
|
||||||
|
if not media_dir.is_dir():
|
||||||
|
print(f"missing directory: {media_dir}")
|
||||||
|
return 1
|
||||||
|
strict = media_dir.resolve() == default_dir.resolve()
|
||||||
|
errors, warnings = check(media_dir, strict=strict)
|
||||||
|
for line in warnings:
|
||||||
|
print(f"warn: {line}")
|
||||||
|
for line in errors:
|
||||||
|
print(f"error: {line}")
|
||||||
|
if errors:
|
||||||
|
print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
|
||||||
|
return 1
|
||||||
|
print(f"clean: {media_dir} ({len(warnings)} warning(s))")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
Reference in New Issue
Block a user