Since I do not do the social media thing, sharing photos to a limited subset of people is kind of a pain in the rear. Double so if the other people are random strangers.

The solution was to mass generate user/password groupings, print them on hard copy paper with a link and QR code, and then use some HAProxy configuration magic to prevent widespread sharing. Content is then published via a second instance of the static site generator used for this site to a dedicated sub domain.

Creating Users and QR print outs

genauth.sh:

#!/usr/bin/env bash

set -euo pipefail

usage() {
    echo "$0 <count> [path]" >&2
    echo "  count  number of credentials to generate" >&2
    echo "  path   path appended to \$BASE_URL (default: /)" >&2
    echo "Environment: AUTH_FILE, BASE_URL, HOST" >&2
    exit 1
}

if [[ $# -lt 1 || $# -gt 2 ]]; then
    usage
fi

COUNT="$1"
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
    usage
fi

PATH_PART="${2:-/}"
[[ "$PATH_PART" == /* ]] || PATH_PART="/$PATH_PART"

HOST="${HOST:-photo.lustenberg.org}"
BASE_URL="${BASE_URL:-http://${HOST}}"
STAMP="$(date +%Y-%m-%d)"
AUTH_FILE="${AUTH_FILE:-./auth/${STAMP}}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# lighttpd mod_auth plain backend: user:password
umask 077
touch "$AUTH_FILE"

rand_token() {
    diceware -n "$1"
}

# only this run's credentials get printed
BATCH_LIST=$(mktemp /tmp/authbatch.XXXXXX)
trap 'rm -f "$BATCH_LIST"' EXIT

for ((i = 0; i < COUNT; i++)); do
    USER="$(rand_token 2)"
    PASS="$(rand_token 3)"

    printf '%s:%s\n' "$USER" "$PASS" >>"$BATCH_LIST"
done

cat "$BATCH_LIST" >>"$AUTH_FILE"

"$SCRIPT_DIR/printqr.sh" "$BATCH_LIST" "${BASE_URL}${PATH_PART}"

printqr.sh:

#!/usr/bin/env bash

set -euo pipefail
set -x

usage() {
    echo "$0 <plain-userfile> <URL>" >&2
    echo "  plain-userfile  lighttpd plain auth file ('user:password' lines)" >&2
    echo "  URL             base URL; credentials are injected per entry" >&2
    exit 1
}

if [[ $# -ne 2 ]]; then
    usage
fi

AUTH_LIST="$1"
URL="$2"

if [[ ! -r "$AUTH_LIST" ]]; then
    echo "$0: cannot read '$AUTH_LIST'" >&2
    exit 1
fi

# Create secure temporary files for the image processing
QR_TMP=$(mktemp /tmp/qr_code.XXXXXX.png)
CARD_TMP=$(mktemp /tmp/qr_card.XXXXXX.png)

# Ensure temporary files are deleted automatically when the script exits or is interrupted
cleanup() {
    rm -f "$QR_TMP" "$CARD_TMP"
}
trap cleanup EXIT

print_card() {
  local url="$1"
  local baseurl="$2"
  local auth="$3"
  local comment="\
${baseurl}
${auth}

Thank you for your time and smile!

Please freely download and repost your photos.
  - No attribution preferred, enjoy! <3

Do not share your URL, QR, or other's photos.
- I'm trying to do something nice.
  - Don't ruin it.
    - Seriously.
      - We have Dragons and Scorned Women.

Alex
"

  qrencode -s 26 -o "$QR_TMP" "$url"

  convert -size 1500x2400 xc:white \
    -gravity center -draw "image over 0,-600 0,0 '$QR_TMP'" \
    -pointsize 48 -fill black \
    -family clean \
    -gravity west \
    -annotate +170+450 "$comment" \
    "$CARD_TMP"

  if true; then
    lp -o media=Custom.5x8in -o InputSlot=Manual "$CARD_TMP"
  else
    display "$CARD_TMP"
  fi
}

while IFS=: read -r user pass _; do
  [[ -z "${user// /}" || "$user" == \#* ]] && continue
  print_card "${URL/:\/\//://${user}:${pass}@}" "${URL}" "user: ${user} password: ${pass}"
done <"$AUTH_LIST"

The generated auth files are then rsync’d and cat | sort into a lighttpd authentication list. I’ll typically do a run of 25 prints, using water proof / resistant paper in 5x8. This will last me 2 to 4 days, YMMV.

HAProxy is used at the edge, tracking successful authorized requests vs IP count, as well as 404 count per client.

global
    log /dev/log local0
    log /dev/log local1 notice

    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s

    chroot /var/lib/haproxy
    user haproxy
    group haproxy
    daemon

    # Modern SSL/TLS security defaults
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    ssl-default-bind-options ssl-min-ver TLSv1.3 no-tls-tickets

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5s
    timeout client  10s
    timeout server  10s

frontend http_in
    bind *:80
    http-request redirect scheme https unless { ssl_fc }

frontend tls_in
    bind *:443 ssl crt /etc/haproxy/lustenberg.pem

    # extract the username from the header
    # this does allow a trival dos, but requires discovery, so im not worried about it too much
    http-request set-var(txn.username) req.hdr(Authorization),regsub(^Basic\s+,,i),b64dec,field(1,:) if { req.hdr(Authorization) -m beg "Basic " }
    http-request set-var-fmt(txn.user_ip) "%[var(txn.username)]:%[src]"

    # increment username counter if this is a new successful ip 
    http-request track-sc0 var(txn.username) table stick_user_ip_count if !{ var(txn.user_ip),in_table(stick_user_ip) } { status 200 }

    # track user:ip
    http-request track-sc1 var(txn.user_ip) table stick_user_ip if { var(txn.username) -m found }

    # track 404s
    http-request track-sc2 src table stick_err_ip
    http-response sc-inc-gpc0(2) if { status 404 }

    # deny when more than 5 ip per user
    http-request deny status 429 if { sc_conn_cnt(0) ge 5 }

    # deny when more than 5 404s in hour
    http-request deny status 429 if { sc_gpc_rate(0,2) ge 5 }

    default_backend local_backend

backend stick_user_ip
    stick-table type string len 64 size 10k expire 24h store http_req_rate(1h)

backend stick_user_ip_count
    stick-table type string len 64 size 10k expire 24h store conn_cnt

backend stick_err_ip
    stick-table type ip size 1m expire 1h store gpc_rate(1,1h)


backend local_backend
    server app_server 127.0.0.1:8080 check