mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-21 09:12:09 +00:00
33ec1c0e35
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>
109 lines
3.5 KiB
Python
Executable File
109 lines
3.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import re
|
|
import time
|
|
import json
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# — 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"
|
|
TOKEN_REGEX = re.compile(r'"\s*GET\s+/share/([0-9a-f]{32})\s+HTTP/')
|
|
|
|
def debug(msg, args):
|
|
if args.debug:
|
|
print(f"[DEBUG] {msg}", file=sys.stderr)
|
|
|
|
def load_metadata(args):
|
|
if METADATA_FILE.exists():
|
|
data = json.loads(METADATA_FILE.read_text())
|
|
debug(f"Loaded metadata: {data}", args)
|
|
return data
|
|
return {}
|
|
|
|
def save_metadata(md, args):
|
|
METADATA_FILE.write_text(json.dumps(md))
|
|
debug(f"Saved metadata: {md}", args)
|
|
|
|
def load_state(args):
|
|
if STATE_FILE.exists():
|
|
pos = int(STATE_FILE.read_text())
|
|
debug(f"Loaded last log position: {pos}", args)
|
|
return pos
|
|
return 0
|
|
|
|
def save_state(pos, args):
|
|
STATE_FILE.write_text(str(pos))
|
|
debug(f"Saved new log position: {pos}", args)
|
|
|
|
def scan_log(since_pos, args):
|
|
try:
|
|
with open(APACHE_LOG, 'r') as f:
|
|
f.seek(since_pos)
|
|
lines = f.readlines()
|
|
newpos = f.tell()
|
|
debug(f"Read {len(lines)} new lines", args)
|
|
return lines, newpos
|
|
except Exception as e:
|
|
print(f"Error reading log: {e}", file=sys.stderr)
|
|
return [], since_pos
|
|
|
|
def record_downloads(lines, metadata, args):
|
|
now = time.time()
|
|
for line in lines:
|
|
m = TOKEN_REGEX.search(line)
|
|
if m:
|
|
tok = m.group(1)
|
|
if tok not in metadata:
|
|
metadata[tok] = now
|
|
debug(f"Recorded download of token {tok} at {now}", args)
|
|
return metadata
|
|
|
|
def revoke_old(metadata, args):
|
|
now = time.time()
|
|
to_remove = [tok for tok, ts in metadata.items() if now - ts >= TTL_SECONDS]
|
|
for tok in to_remove:
|
|
link = SHARE_DIR / tok
|
|
try:
|
|
link.unlink()
|
|
debug(f"Unlinked token {tok}", args)
|
|
except FileNotFoundError:
|
|
debug(f"Link {link} not found when revoking", args)
|
|
metadata.pop(tok, None)
|
|
return bool(to_remove)
|
|
|
|
def main():
|
|
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()
|
|
|
|
SHARE_DIR.mkdir(parents=True, exist_ok=True)
|
|
metadata = load_metadata(args)
|
|
state = load_state(args)
|
|
|
|
lines, newpos = scan_log(state, args)
|
|
metadata = record_downloads(lines, metadata, args)
|
|
save_metadata(metadata, args) # always persist new downloads
|
|
|
|
if revoke_old(metadata, args):
|
|
save_metadata(metadata, args) # persist removals
|
|
|
|
save_state(newpos, args)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|