Files
retoor 9fb4d65787 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
2026-10-06 02:56:08 +02:00

132 lines
6.3 KiB
Python

# retoor <retoor@molodetz.nl>
import io
import logging
import imagehash
from PIL import Image
from molodetz import config
from molodetz.attachments import blob_path, blob_url, write_blob
from molodetz.database import generate_uid, media_by_kind, upsert_media
from molodetz.database.media import get_media_by_file, retire_media
logger = logging.getLogger(__name__)
THUMB_WIDTH = 720
DUPLICATE_DISTANCE = 2
GALLERY = (
("flyer-elite-twice.jpg", "flyer", "Elite twice. Know the code. Use the machine. Read every line it writes."),
("flyer-taste.jpg", "flyer", "Taste does not autocomplete."),
("flyer-demo.jpg", "flyer", "A demo is not a product."),
("flyer-hands.jpg", "flyer", "Your hands are not the craft."),
("meme-elite-galaxy-brain.jpg", "meme", "Getting elite twice: know every line before the AI writes it, review every line after, ship by lunch."),
("meme-classic-autocomplete.jpg", "meme", "I don't need autocomplete. Hand-typed. Artisanal. Not shipped."),
("meme-vibe-prompt-and-pray.jpg", "meme", "Prompt and pray: the same TypeError pasted into the chat 31 times. Suggested fix: learn what a function is."),
("meme-elite-choose-your-fighter.jpg", "meme", "Choose your fighter: the classic, the vibe coder, or the elite who writes code, uses AI and reads every diff."),
("meme-classic-starter-pack.jpg", "meme", "The 'AI is just fancy autocomplete' starter pack: 36 tmux panes, a vimrc older than the intern, zero pull requests."),
("meme-vibe-starter-pack.jpg", "meme", "The 'built it in a weekend' starter pack: tab, tab, accept all, API key committed to main, works on my machine."),
("meme-elite-build-log.jpg", "meme", "Elite twice. The model types. You decide. 1,184 lines drafted, every line read, 212 rejected."),
("meme-classic-chart.jpg", "meme", "Free-range, hand-typed code: keystrokes up, features shipped flat, opinions about AI off the chart."),
("meme-vibe-galaxy-brain.jpg", "meme", "Vibe debugging: read the error, paste the error, tab tab accept all, delete the repo and prompt a new one."),
("meme-elite-take.jpg", "meme", "AI replaced the part of me that typed. The part that thinks got a raise."),
("meme-classic-same-guy.jpg", "meme", "1987: compilers make programmers lazy. 2026: AI makes programmers lazy. Same guy. Still not shipping."),
("meme-vibe-git-blame.jpg", "meme", "Git blame says it was you. You say it was the vibes."),
("meme-elite-curve.jpg", "meme", "Pick your curve: classic stays flat, the vibe coder crashes at the first prod incident, elite plus AI keeps climbing."),
("meme-classic-certificate.jpg", "meme", "Certificate of manual labour. Time saved: 0 hours. Moral high ground: maximum."),
("meme-vibe-weekend.jpg", "meme", "A vibe coder's weekend: a SaaS on Friday, a public database by Sunday, a course about it on Monday."),
("meme-elite-achievement.jpg", "meme", "Achievement unlocked: this developer embraced AI and is now elite twice."),
("meme-classic-family.jpg", "meme", "Three generations of refusing the tool. Three generations of getting outshipped."),
("meme-vibe-priorities.jpg", "meme", "Vibe coder priorities: learning what a for loop does, nah. Typing 'make it work, no bugs, please' 30 times, yep."),
("meme-compiles.jpg", "meme", "It compiles. Ship it, king."),
("meme-senior.jpg", "meme", "My copilot is the senior."),
("meme-trophy.jpg", "meme", "Peak performance, 6 out of 10."),
("meme-todo.jpg", "meme", "Side projects, a love story."),
)
RETIRED_GALLERY = (
"flyer-keuzes.jpg",
"flyer-chat.jpg",
"flyer-bijna.jpg",
"flyer-assistent.jpg",
"meme-vloer.jpg",
"meme-split.jpg",
"meme-cursus.jpg",
"meme-zes.jpg",
"meme-gesprek.jpg",
"meme-product.jpg",
"meme-bijna.jpg",
"poster-thuis.jpg",
"poster-vloer.jpg",
"poster-cursus.jpg",
"poster-luiheid.jpg",
"poster-spel.jpg",
"story.jpg",
"take-chatlog.jpg",
"take-copilot.jpg",
"take-coping.jpg",
)
def _thumbnail_bytes(image):
copy = image.convert("RGB")
copy.thumbnail((THUMB_WIDTH, THUMB_WIDTH * 2))
buffer = io.BytesIO()
copy.save(buffer, format="WEBP", quality=82, method=4)
return buffer.getvalue()
def retire_gallery():
return sum(1 for filename in RETIRED_GALLERY if retire_media(filename))
def sync_gallery():
retire_gallery()
seen_hashes = []
synced = 0
for position, (filename, kind, caption) in enumerate(GALLERY):
source = config.MEDIA_SOURCE_DIR / filename
if not source.exists():
logger.warning("gallery source missing: %s", source)
continue
existing = get_media_by_file(filename)
with Image.open(source) as image:
width, height = image.size
phash = imagehash.phash(image)
if any(phash - other <= DUPLICATE_DISTANCE for other in seen_hashes):
logger.info("gallery duplicate skipped: %s", filename)
continue
seen_hashes.append(phash)
thumb_directory = existing.get("thumb_directory") if existing else None
thumb_name = existing.get("thumb_name") if existing else None
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(
existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image)
)
upsert_media(filename, kind, caption, position, width, height, str(phash), thumb_directory, thumb_name)
synced += 1
return synced
def missing_sources():
return [filename for filename, _, _ in GALLERY if not (config.MEDIA_SOURCE_DIR / filename).exists()]
def gallery_items(kind):
items = []
for row in media_by_kind(kind):
thumb = blob_url(row["thumb_directory"], row["thumb_name"]) if row.get("thumb_directory") else None
items.append(
{
"uid": row["uid"],
"filename": row["filename"],
"caption": row["caption"],
"width": row["width"],
"height": row["height"],
"source_path": f"/static/media/{row['filename']}",
"thumb_url": thumb,
}
)
return items