Compare commits

...

1 Commits

Author SHA1 Message Date
Michal Tuszowski 33ec1c0e35 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 <noreply@anthropic.com>
2026-07-19 22:16:49 +02:00
9 changed files with 437 additions and 10 deletions
+14 -5
View File
@@ -7,9 +7,13 @@ import argparse
import sys import sys
from pathlib import Path from pathlib import Path
# — CONFIGURATION — adjust as needed # — CONFIGURATION — env-overridable; defaults = original bare-metal Pi layout
APACHE_LOG = '/var/log/apache2/share_access.log' # <- point to your share-specific log! # APACHE_LOG must be the share-specific access log (see docker/share-vhost.conf);
SHARE_DIR = Path('/var/www/html/share') # 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' METADATA_FILE = SHARE_DIR / '.downloads.json'
STATE_FILE = SHARE_DIR / '.logpos' STATE_FILE = SHARE_DIR / '.logpos'
# Regex to match lines like: "GET /share/abcdef1234... HTTP/1.1" # 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): def revoke_old(metadata, args):
now = time.time() 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: for tok in to_remove:
link = SHARE_DIR / tok link = SHARE_DIR / tok
try: try:
@@ -78,7 +82,12 @@ def revoke_old(metadata, args):
return bool(to_remove) return bool(to_remove)
def main(): 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') p.add_argument('--debug', action='store_true', help='print debug info to stderr')
args = p.parse_args() args = p.parse_args()
+12 -5
View File
@@ -1,12 +1,18 @@
#!/usr/bin/env python3 #!/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 os
import json import json
import time import time
from pathlib import Path
# Configuration # Configuration (env-overridable; defaults = original bare-metal Pi layout)
SCAN_PATH = '/mnt/shares' SCAN_PATH = os.getenv('CONJURER_SHARE_SCAN_PATH', '/mnt/shares')
OUTPUT_FILE = '/var/log/share_scan.json' OUTPUT_FILE = os.getenv('CONJURER_SHARE_DB', '/var/log/share_scan.json')
def scan_directory(root): def scan_directory(root):
"""Recursively walk `root` and collect metadata.""" """Recursively walk `root` and collect metadata."""
@@ -34,7 +40,8 @@ def main():
'root': SCAN_PATH, 'root': SCAN_PATH,
'entries': scan_directory(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' temp = OUTPUT_FILE + '.tmp'
with open(temp, 'w') as f: with open(temp, 'w') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
+36
View File
@@ -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"]
+43
View File
@@ -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
+67
View File
@@ -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
+14
View File
@@ -15,3 +15,17 @@ CONJURER_MUSIC_FOLDER=/music
# Runtime paths (mounted volume) — playlists/logs the service writes. # Runtime paths (mounted volume) — playlists/logs the service writes.
CONJURER_MUSICIAN_BASE=/data CONJURER_MUSICIAN_BASE=/data
CONJURER_LOGSTORE=/data/logs 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
+49
View File
@@ -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/<token>; revoke_shares.py
# later unlinks it. Nothing is ever copied.
<VirtualHost *:80>
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.
<Directory /var/www/html/share>
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.
<FilesMatch "^\.">
Require all denied
</FilesMatch>
</Directory>
# Nothing outside /share is published. The more specific block above wins
# for the share dir itself.
<Directory /var/www/html>
Options -Indexes -FollowSymLinks
AllowOverride None
Require all denied
</Directory>
# 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
</VirtualHost>
+5
View File
@@ -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` | | **Main bot** | VM-bot | `conjurer-bot` | 5000 (Flask comm) | `docker/Dockerfile.bot` |
| **Librarian** | VM-librarian | `conjurer-librarian` | 5001 | `docker/Dockerfile.librarian` | | **Librarian** | VM-librarian | `conjurer-librarian` | 5001 | `docker/Dockerfile.librarian` |
| **Musician** | VM-musician | `conjurer-musician` | 5000 (+ radio) | `docker/Dockerfile.musician` | | **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: The three talk to each other over HTTP on the Proxmox LAN. Direction of calls:
+197
View File
@@ -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://<host>/share/<token>`.
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 13 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> <keywords>
```
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/<token> | 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.