From 33ec1c0e354bbd5fd963e7d4b5d2cc69b7e51ba5 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Sun, 19 Jul 2026 22:16:49 +0200 Subject: [PATCH] share: dockerize the file-share pipeline and document it The Python half of the short-lived share links lived in the repo; the Apache config and the cron entries that make it work were hand-placed on the host, so the feature could not be rebuilt from a checkout. This adds the missing half. Scripts (defaults unchanged, so existing bare-metal cron keeps working): * scan_shares.py / revoke_shares.py take their paths from CONJURER_SHARE_* instead of hardcoding the Pi layout, and create their parent dirs; * the revoke TTL is now CONJURER_SHARE_TTL_SECONDS. Its --help claimed "2min" while the code used a hardcoded 3600 - the help text now reports the real, configured value. New share service: * docker/Dockerfile.share - Apache + the two jobs, reusing the same scripts rather than forking copies; * docker/share-vhost.conf.tpl - the previously undocumented Apache half. Three settings are load-bearing and commented as such: +FollowSymLinks (the shares ARE symlinks), -Indexes (a listing would expose every live token), and a deny rule for dotfiles (revoke_shares.py keeps .downloads.json - a map of every live token - inside the served directory); * docker/entrypoint.share.sh - renders the vhost, seeds the index on first run, then runs scanner/revoker in sleep loops beside Apache (no cron, so their output shows up in docker logs); * compose + env example, incl. the two volumes that MUST be shared with the musician (it creates the links and reads the index). docs/deployment/FILE_SHARING.md documents the mechanism, both deployment routes (docker and existing host Apache), the Discord command, why each Apache setting matters, verification commands, and the known limitations - notably that a link nobody ever downloads is never revoked, since the TTL starts at first download. Verified: scanner and revoker exercised end-to-end against temp dirs (index built; token recorded from a combined-format log line and the symlink unlinked). Compose file not validated - no docker on this machine. Co-Authored-By: Claude Opus 4.8 --- conjurer_musician/revoke_shares.py | 19 ++- conjurer_musician/scan_shares.py | 17 ++- docker/Dockerfile.share | 36 ++++++ docker/compose.share.yaml | 43 +++++++ docker/entrypoint.share.sh | 67 ++++++++++ docker/env/musician.env.example | 14 ++ docker/share-vhost.conf.tpl | 49 +++++++ docs/deployment/DOCKER_PROXMOX.md | 5 + docs/deployment/FILE_SHARING.md | 197 +++++++++++++++++++++++++++++ 9 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 docker/Dockerfile.share create mode 100644 docker/compose.share.yaml create mode 100755 docker/entrypoint.share.sh create mode 100644 docker/share-vhost.conf.tpl create mode 100644 docs/deployment/FILE_SHARING.md diff --git a/conjurer_musician/revoke_shares.py b/conjurer_musician/revoke_shares.py index a3f19f3..05bca7a 100755 --- a/conjurer_musician/revoke_shares.py +++ b/conjurer_musician/revoke_shares.py @@ -7,9 +7,13 @@ import argparse import sys from pathlib import Path -# — CONFIGURATION — adjust as needed — -APACHE_LOG = '/var/log/apache2/share_access.log' # <- point to your share-specific log! -SHARE_DIR = Path('/var/www/html/share') +# — CONFIGURATION — env-overridable; defaults = original bare-metal Pi layout — +# APACHE_LOG must be the share-specific access log (see docker/share-vhost.conf); +# pointing it at the site-wide log also works but wastes time scanning noise. +APACHE_LOG = os.getenv('CONJURER_APACHE_LOG', '/var/log/apache2/share_access.log') +SHARE_DIR = Path(os.getenv('CONJURER_SHARE_DIR', '/var/www/html/share')) +# Seconds to keep a link alive after its FIRST download (not after creation). +TTL_SECONDS = int(os.getenv('CONJURER_SHARE_TTL_SECONDS', '3600')) METADATA_FILE = SHARE_DIR / '.downloads.json' STATE_FILE = SHARE_DIR / '.logpos' # Regex to match lines like: "GET /share/abcdef1234... HTTP/1.1" @@ -66,7 +70,7 @@ def record_downloads(lines, metadata, args): def revoke_old(metadata, args): now = time.time() - to_remove = [tok for tok, ts in metadata.items() if now - ts >= 3600] + to_remove = [tok for tok, ts in metadata.items() if now - ts >= TTL_SECONDS] for tok in to_remove: link = SHARE_DIR / tok try: @@ -78,7 +82,12 @@ def revoke_old(metadata, args): return bool(to_remove) def main(): - p = argparse.ArgumentParser(description="Revoke share links 2min after download") + p = argparse.ArgumentParser( + description=( + "Revoke share links CONJURER_SHARE_TTL_SECONDS " + f"(currently {TTL_SECONDS}s) after their first download" + ) + ) p.add_argument('--debug', action='store_true', help='print debug info to stderr') args = p.parse_args() diff --git a/conjurer_musician/scan_shares.py b/conjurer_musician/scan_shares.py index dd78636..32d2294 100755 --- a/conjurer_musician/scan_shares.py +++ b/conjurer_musician/scan_shares.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 +"""Build the share index that the musician's /get_share_list endpoint searches. + +Walks the media root and writes a JSON catalogue of every file/dir found. +Meant to run periodically (cron on bare metal, the scheduler loop in the share +container). Paths are environment-overridable; the defaults reproduce the +original Raspberry Pi layout, so existing cron entries keep working unchanged. +""" import os import json import time -from pathlib import Path -# Configuration -SCAN_PATH = '/mnt/shares' -OUTPUT_FILE = '/var/log/share_scan.json' +# Configuration (env-overridable; defaults = original bare-metal Pi layout) +SCAN_PATH = os.getenv('CONJURER_SHARE_SCAN_PATH', '/mnt/shares') +OUTPUT_FILE = os.getenv('CONJURER_SHARE_DB', '/var/log/share_scan.json') def scan_directory(root): """Recursively walk `root` and collect metadata.""" @@ -34,7 +40,8 @@ def main(): 'root': SCAN_PATH, 'entries': scan_directory(SCAN_PATH), } - # Write atomically + # Write atomically (create the target dir first so a fresh volume works) + os.makedirs(os.path.dirname(OUTPUT_FILE) or '.', exist_ok=True) temp = OUTPUT_FILE + '.tmp' with open(temp, 'w') as f: json.dump(data, f, indent=2) diff --git a/docker/Dockerfile.share b/docker/Dockerfile.share new file mode 100644 index 0000000..865a258 --- /dev/null +++ b/docker/Dockerfile.share @@ -0,0 +1,36 @@ +# Conjurer share service: Apache publishing short-lived symlink shares, plus +# the two periodic jobs that build the search index and revoke expired links. +# +# This is the piece that used to live only as hand-placed scripts + hand-edited +# Apache config on the host. Everything it needs is now in the image; the state +# it shares with the musician (the link dir and the index) comes in as volumes. +# +# Build from the repository root: +# docker build -f docker/Dockerfile.share -t conjurer-share . +FROM debian:bookworm-slim + +# apache2 serves the links; python3 runs the scanner/revoker (both stdlib-only, +# so there is nothing to pip install). +RUN apt-get update && apt-get install -y --no-install-recommends \ + apache2 python3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +# Same scripts the bare-metal deployment uses - no forked copies. +COPY conjurer_musician/scan_shares.py ./scan_shares.py +COPY conjurer_musician/revoke_shares.py ./revoke_shares.py +COPY docker/share-vhost.conf.tpl ./share-vhost.conf.tpl +COPY docker/entrypoint.share.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# /mnt/shares - media library (mount read-only; symlink targets) +# /var/www/html/share - the symlinks themselves (SHARED with the musician, +# which is what creates them) +# /srv/share/db - share_scan.json (SHARED with the musician, which +# searches it) +# /var/log/apache2 - access log the revoker tails +VOLUME ["/mnt/shares", "/var/www/html/share", "/srv/share/db", "/var/log/apache2"] + +EXPOSE 80 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/compose.share.yaml b/docker/compose.share.yaml new file mode 100644 index 0000000..49f64ff --- /dev/null +++ b/docker/compose.share.yaml @@ -0,0 +1,43 @@ +# Share VM/host: Apache publishing short-lived file links + the scan/revoke jobs. +# Run from the repository root: +# cp docker/env/share.env.example docker/env/share.env # then edit +# docker compose -f docker/compose.share.yaml up -d --build +# +# IMPORTANT - this service shares two paths with the musician, which is the +# process that actually creates the links: +# +# /srv/share/links -> the symlinks (musician writes, Apache reads) +# /srv/share/db -> share_scan.json (this service writes, musician reads) +# +# So the musician must mount the same two host paths (see the CONJURER_SHARE_* +# block in docker/env/musician.env.example). If the musician runs on a different +# machine, put both on shared storage - otherwise it will happily create links +# that this container cannot see. +# +# The media library must be mounted here at the SAME absolute path that the +# index recorded, because the symlink targets are absolute. Default: /mnt/shares. +services: + conjurer-share: + build: + context: .. + dockerfile: docker/Dockerfile.share + image: conjurer-share:latest + container_name: conjurer-share + restart: unless-stopped + env_file: + - env/share.env + ports: + # Plain HTTP. Terminate TLS for czernobog.pl on your existing reverse + # proxy and forward /share here. + - "8081:80" + volumes: + # Media library the links point at. Read-only: this service only ever + # serves these files, it never writes them. + - /srv/share/media:/mnt/shares:ro + # The published symlinks - shared with the musician. + - /srv/share/links:/var/www/html/share + # The search index - shared with the musician. + - /srv/share/db:/srv/share/db + # Access log the revoker tails; kept on the host so link history survives + # a container rebuild. + - /srv/share/logs:/var/log/apache2 diff --git a/docker/entrypoint.share.sh b/docker/entrypoint.share.sh new file mode 100755 index 0000000..ed57887 --- /dev/null +++ b/docker/entrypoint.share.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Entrypoint for the Conjurer share container: renders the vhost, seeds the +# share index, then runs the two periodic jobs alongside Apache. +# +# Cron is deliberately not used. Both scripts are already written to be safe to +# re-run (the scanner writes atomically, the revoker persists its log position), +# so plain sleep loops give the same result with one less moving part and with +# their output on the container's stdout where `docker logs` can see it. +set -eu + +: "${CONJURER_SHARE_SERVER_NAME:=localhost}" +: "${CONJURER_SHARE_SCAN_PATH:=/mnt/shares}" +: "${CONJURER_SHARE_DB:=/srv/share/db/share_scan.json}" +: "${CONJURER_SHARE_DIR:=/var/www/html/share}" +: "${CONJURER_APACHE_LOG:=/var/log/apache2/share_access.log}" +: "${CONJURER_SHARE_SCAN_INTERVAL:=3600}" +: "${CONJURER_SHARE_REVOKE_INTERVAL:=60}" +: "${CONJURER_SHARE_TTL_SECONDS:=3600}" +export CONJURER_SHARE_SCAN_PATH CONJURER_SHARE_DB CONJURER_SHARE_DIR \ + CONJURER_APACHE_LOG CONJURER_SHARE_TTL_SECONDS + +# Render the vhost (same placeholder pattern as icecast.xml.tpl). +sed "s|__SERVER_NAME__|${CONJURER_SHARE_SERVER_NAME}|g" \ + /app/share-vhost.conf.tpl > /etc/apache2/sites-available/000-default.conf + +mkdir -p "$CONJURER_SHARE_DIR" \ + "$(dirname "$CONJURER_SHARE_DB")" \ + "$(dirname "$CONJURER_APACHE_LOG")" \ + /var/run/apache2 +chown -R www-data:www-data "$CONJURER_SHARE_DIR" + +# revoke_shares.py seeks to a stored byte offset, so the log must exist before +# the first pass or it starts by reporting an error. +touch "$CONJURER_APACHE_LOG" + +# Seed the index immediately when absent - otherwise /get_share_list returns +# nothing until the first scheduled scan, which looks like a broken feature. +if [ ! -f "$CONJURER_SHARE_DB" ]; then + echo "[share] no index at $CONJURER_SHARE_DB - running initial scan of $CONJURER_SHARE_SCAN_PATH" + python3 /app/scan_shares.py || echo "[share] initial scan failed, continuing" +fi + +# Periodic index rebuild. +( + while true; do + sleep "$CONJURER_SHARE_SCAN_INTERVAL" + echo "[share] rescanning $CONJURER_SHARE_SCAN_PATH" + python3 /app/scan_shares.py || echo "[share] scan failed" + done +) & +SCAN_PID=$! + +# Periodic revocation of links whose TTL expired after first download. +( + while true; do + sleep "$CONJURER_SHARE_REVOKE_INTERVAL" + python3 /app/revoke_shares.py || echo "[share] revoke failed" + done +) & +REVOKE_PID=$! + +trap 'kill "$SCAN_PID" "$REVOKE_PID" 2>/dev/null || true' INT TERM + +echo "[share] serving $CONJURER_SHARE_DIR as ${CONJURER_SHARE_SERVER_NAME}/share" +echo "[share] scan every ${CONJURER_SHARE_SCAN_INTERVAL}s, revoke sweep every ${CONJURER_SHARE_REVOKE_INTERVAL}s, TTL ${CONJURER_SHARE_TTL_SECONDS}s after first download" + +exec apache2ctl -D FOREGROUND diff --git a/docker/env/musician.env.example b/docker/env/musician.env.example index f769932..a962d5a 100644 --- a/docker/env/musician.env.example +++ b/docker/env/musician.env.example @@ -15,3 +15,17 @@ CONJURER_MUSIC_FOLDER=/music # Runtime paths (mounted volume) — playlists/logs the service writes. CONJURER_MUSICIAN_BASE=/data CONJURER_LOGSTORE=/data/logs + +# --- File sharing (optional; see docs/deployment/FILE_SHARING.md) -------- +# The musician SEARCHES the index and CREATES the symlinks; the share container +# builds the index, serves the links and revokes them. Both must therefore point +# at the same two host paths — mount them into this container as well: +# - /srv/share/db:/srv/share/db +# - /srv/share/links:/var/www/html/share +# Leave these unset to keep the feature off (the endpoints then just find +# nothing). +# CONJURER_SHARE_DB=/srv/share/db/share_scan.json +# CONJURER_SHARE_DIR=/var/www/html/share +# Public URL prefix handed back to Discord; host half must match the share +# container's CONJURER_SHARE_SERVER_NAME. +# CONJURER_SHARE_BASE_URL=https://czernobog.pl/share diff --git a/docker/share-vhost.conf.tpl b/docker/share-vhost.conf.tpl new file mode 100644 index 0000000..7493d09 --- /dev/null +++ b/docker/share-vhost.conf.tpl @@ -0,0 +1,49 @@ +# Apache vhost for the Conjurer file-share links. +# +# The entrypoint substitutes __SERVER_NAME__ from CONJURER_SHARE_SERVER_NAME and +# writes the result to /etc/apache2/sites-available/000-default.conf (same +# pattern as docker/icecast.xml.tpl). +# +# What this serves: media_search_functions.publish() creates a symlink named +# after a random 32-hex token inside the share dir, pointing at a file in the +# media library. Apache hands that file out at /share/; revoke_shares.py +# later unlinks it. Nothing is ever copied. + + + ServerName __SERVER_NAME__ + DocumentRoot /var/www/html + + # The share links ARE symlinks into the media library, so FollowSymLinks is + # mandatory. SymLinksIfOwnerMatch is deliberately NOT used: the targets are + # owned by whoever owns the media, not by www-data, so it would break every + # link. -Indexes is equally mandatory - a directory listing here would hand + # out every currently live token at once. + + Options +FollowSymLinks -Indexes + AllowOverride None + Require all granted + + # revoke_shares.py keeps its state (.downloads.json, .logpos) in this + # very directory. .downloads.json maps every live token to its download + # time, so serving it would leak the entire active link set. + + Require all denied + + + + # Nothing outside /share is published. The more specific block above wins + # for the share dir itself. + + Options -Indexes -FollowSymLinks + AllowOverride None + Require all denied + + + # Dedicated access log. revoke_shares.py tails exactly this file and matches + # the standard "%r" request line against: + # "\s*GET\s+/share/([0-9a-f]{32})\s+HTTP/ + # so the format must stay `combined` (or any format containing %r). + SetEnvIf Request_URI "^/share/" conjurer_share + CustomLog ${APACHE_LOG_DIR}/share_access.log combined env=conjurer_share + ErrorLog ${APACHE_LOG_DIR}/share_error.log + diff --git a/docs/deployment/DOCKER_PROXMOX.md b/docs/deployment/DOCKER_PROXMOX.md index 02549e8..6d3e038 100644 --- a/docs/deployment/DOCKER_PROXMOX.md +++ b/docs/deployment/DOCKER_PROXMOX.md @@ -7,6 +7,11 @@ Runbook for running Conjurer as Docker containers across Proxmox VMs: | **Main bot** | VM-bot | `conjurer-bot` | 5000 (Flask comm) | `docker/Dockerfile.bot` | | **Librarian** | VM-librarian | `conjurer-librarian` | 5001 | `docker/Dockerfile.librarian` | | **Musician** | VM-musician | `conjurer-musician` | 5000 (+ radio) | `docker/Dockerfile.musician` | +| **Share** (optional) | VM-musician or its own | `conjurer-share` | 8081 → 80 | `docker/Dockerfile.share` | + +The share service publishes short-lived file links over Apache and is documented +separately in [FILE_SHARING.md](FILE_SHARING.md) — it shares two volumes with the +musician, so set it up after the musician is running. The three talk to each other over HTTP on the Proxmox LAN. Direction of calls: diff --git a/docs/deployment/FILE_SHARING.md b/docs/deployment/FILE_SHARING.md new file mode 100644 index 0000000..c10dbf0 --- /dev/null +++ b/docs/deployment/FILE_SHARING.md @@ -0,0 +1,197 @@ +# File sharing (short-lived share links) + +Publishes a file from the media library behind a random, unguessable URL that +stops working shortly after someone downloads it. Driven from Discord. + +Historically only the Python half of this lived in the repo; the Apache config +and the cron entries were placed on the host by hand. This document plus +`docker/Dockerfile.share` close that gap. + +## What it actually does + +1. A **scanner** walks the media library and writes a JSON index of every path. +2. From Discord you search that index and pick files. +3. **Publishing** creates a `symlink` named after a random token + (`uuid.uuid4().hex`, 32 hex chars) inside the share directory, pointing at the + real file. Nothing is copied. +4. **Apache** serves that symlink at `https:///share/`. +5. A **revoker** tails Apache's access log, records when each token was *first* + downloaded, and unlinks it once the TTL expires. + +Two things worth being precise about, because the names suggest otherwise: + +- The token is **random, not a hash** of the file. Nothing about the content is + recoverable from the URL, and the same file shared twice gets two URLs. +- The "database" is a **JSON file**, not a SQL database. + (`utils/import_to_mysql.py` is an unrelated, unfinished scratch script.) + +## The pieces + +| Path | Role | Runs in | +|---|---|---| +| `conjurer_musician/scan_shares.py` | Builds the JSON index from the media root | share container (or cron) | +| `conjurer_musician/revoke_shares.py` | Tails the access log, unlinks expired tokens | share container (or cron) | +| `conjurer_musician/media_search_functions.py` | Search + publish helpers used by the service | musician | +| `conjurer_musician.py` → `/get_share_list`, `/get_share_links` | HTTP API, both behind `X-Conjurer-Api-Key` | musician | +| `file_search_functions.py` | Bot-side HTTP client | bot | +| `file_search_commands.py` | Discord cog: search UI + Accept/Cancel | bot | +| `docker/share-vhost.conf.tpl` | Apache vhost (symlinks, no indexes, dedicated log) | share container | +| `conjurer_musician/search_and_share.py` | Standalone CLI doing steps 1–3 by hand | anywhere (optional) | + +Flow: + +``` + media library ──► scan_shares.py ──► share_scan.json ──┐ + │ (reads) +Discord ──► bot cog ──► musician /get_share_list ─────────┘ + └─► musician /get_share_links ──► symlink in share dir + │ (serves) + Apache vhost ─────────────────────┘ + │ (logs) + └──► share_access.log ──► revoke_shares.py ──► unlink +``` + +The share directory and the index are **shared state between the musician and +the share service**: the musician creates links and reads the index, the share +service serves links and writes the index. Both must see the same two paths. + +## Enabling it — Docker (recommended) + +```bash +cp docker/env/share.env.example docker/env/share.env +# edit: CONJURER_SHARE_SERVER_NAME, and the TTL/intervals if you want +docker compose -f docker/compose.share.yaml up -d --build +``` + +Host layout the compose file expects: + +| Host path | Contents | +|---|---| +| `/srv/share/media` | the media library (mounted read-only) | +| `/srv/share/links` | the published symlinks — **also mount into the musician** | +| `/srv/share/db` | `share_scan.json` — **also mount into the musician** | +| `/srv/share/logs` | Apache logs, incl. `share_access.log` | + +Then point the musician at the same two paths. In `docker/env/musician.env`: + +```ini +CONJURER_SHARE_DB=/srv/share/db/share_scan.json +CONJURER_SHARE_DIR=/var/www/html/share +CONJURER_SHARE_BASE_URL=https://czernobog.pl/share +``` + +and add the matching mounts to `docker/compose.musician.yaml`: + +```yaml + - /srv/share/db:/srv/share/db + - /srv/share/links:/var/www/html/share +``` + +Finally forward `/share` from your public TLS endpoint to the container's port +(`8081` by default). The container speaks plain HTTP on purpose — TLS for +`czernobog.pl` stays on the reverse proxy you already run. + +> **The media library must be mounted at the same absolute path the index +> recorded** (`/mnt/shares` by default). The symlinks are absolute, so if the +> scanner saw `/mnt/shares/x.mkv` and Apache has the library somewhere else, every +> link 404s. + +The container does not use cron: the entrypoint runs the scanner and the revoker +in sleep loops next to Apache, so their output lands in `docker logs`. + +## Enabling it — existing Apache on the host + +If you keep serving from the host's Apache instead: + +1. Install the vhost. Take `docker/share-vhost.conf.tpl`, replace + `__SERVER_NAME__`, and drop it into `/etc/apache2/sites-available/`, then + `a2ensite` + `systemctl reload apache2`. The three settings that matter are + explained in the Security section below — do not simplify them away. + +2. Schedule both jobs. The scripts default to the original bare-metal paths, so + plain cron entries work with no environment at all: + + ```cron + */5 * * * * /usr/bin/python3 /opt/conjurer/conjurer_musician/revoke_shares.py + 17 * * * * /usr/bin/python3 /opt/conjurer/conjurer_musician/scan_shares.py + ``` + + Defaults: media root `/mnt/shares`, index `/var/log/share_scan.json`, links + `/var/www/html/share`, log `/var/log/apache2/share_access.log`, TTL 3600s. + Override any of them with the `CONJURER_SHARE_*` variables from + `docker/env/share.env.example`. + +3. Point the musician at the same paths via `CONJURER_SHARE_DB`, + `CONJURER_SHARE_DIR` and `CONJURER_SHARE_BASE_URL`. + +## Using it + +``` +$tajna_biblioteka_inkwizycji <1-10> +``` + +Restricted to the **Jarl** and **Thane** roles. It replies with the matches, you +select from the dropdown and press Accept; the bot answers with one URL per file. +(The docstring in the cog still says `!findfiles` — that name is stale.) + +## Security + +The vhost is not boilerplate; three of its settings are load-bearing: + +- **`Options +FollowSymLinks`** — the shares *are* symlinks, so without it every + link 403s. `SymLinksIfOwnerMatch` cannot be substituted: the targets are owned + by whoever owns the media, not by `www-data`. +- **`Options -Indexes`** — a directory listing of the share dir would hand out + every currently live token in one request. +- **Deny dotfiles** — `revoke_shares.py` keeps `.downloads.json` (a map of every + live token to its download time) and `.logpos` *inside the served directory*. + Serving `.downloads.json` would leak the entire active link set. + +Also worth knowing: + +- Both HTTP endpoints call `_authorize_request()`, so they require + `X-Conjurer-Api-Key` whenever `CONJURER_API_KEY` is set. The bot sends it via + `service_headers()`. +- Anyone with the URL can download the file until it expires — the token is the + only credential. +- Mount the media read-only, as the compose file does. This service never needs + to write to the library. + +## Verifying + +```bash +# index built? +docker compose -f docker/compose.share.yaml exec conjurer-share \ + python3 -c "import json;d=json.load(open('/srv/share/db/share_scan.json'));print(len(d['entries']),'entries')" + +# a link resolves? (create one from Discord first) +curl -sI https://czernobog.pl/share/ | head -1 + +# the listing must NOT work, and neither must the state file +curl -sI https://czernobog.pl/share/ | head -1 # expect 403 +curl -sI https://czernobog.pl/share/.downloads.json | head -1 # expect 403 + +# revoker seeing downloads? +docker compose -f docker/compose.share.yaml logs --tail=20 conjurer-share +``` + +Common failures: + +| Symptom | Cause | +|---|---| +| Search finds nothing | index missing/empty — check the scanner ran and `CONJURER_SHARE_SCAN_PATH` is right | +| Every link 404s | media not mounted at the path the index recorded | +| Every link 403s | `FollowSymLinks` missing on the share directory | +| Links never expire | revoker not running, or reading a log the vhost isn't writing | +| Bot gets 401 | `CONJURER_API_KEY` set on the musician but not on the bot | + +## Known limitations + +- **A link nobody downloads is never revoked.** The TTL starts at the first + download, so unused symlinks accumulate in the share directory forever. Prune + it periodically if that matters. +- **Log rotation confuses the revoker.** It stores a byte offset into the access + log; when the file is rotated the offset points into the new, shorter file and + entries can be skipped. Prefer a long rotation interval, or clear + `.logpos` after rotating. +- **The index is a full rescan**, not incremental — cost grows with library size.