diff --git a/README.md b/README.md index c4c078d..f9409e8 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/anki-sync-server.md b/services/anki-sync-server.md new file mode 100644 index 0000000..e4afb10 --- /dev/null +++ b/services/anki-sync-server.md @@ -0,0 +1,82 @@ +## Client setup — pointing Anki at this server instead of AnkiWeb + +Every client below needs the **Sync URL** and one of the **accounts** shown +higher up in this README. Do this on every device you want synced — a client +still pointed at AnkiWeb won't see collections synced here, and vice versa. + +### Anki Desktop (2.1.66 and newer) +1. **Preferences → Network** +2. Tick **"Self-hosted sync server"** +3. Paste the Sync URL into the field that appears +4. **Sync → log in** with one of the accounts above + +### Anki Desktop (older than 2.1.66) +There's no GUI field yet — set an environment variable before launching Anki +instead, then sync normally: +```bash +# Linux/macOS +export SYNC_ENDPOINT="https://your-sync-url/" +anki + +# Windows (Command Prompt) +set SYNC_ENDPOINT=https://your-sync-url/ +anki.exe +``` +Upgrading Anki to 2.1.66+ is the easier long-term fix — do that if you're +setting this up for anyone who isn't comfortable with environment variables. + +### AnkiDroid +**Settings → Advanced → Custom sync server**, then enter the Sync URL and +log in with one of the accounts above (AnkiDroid 2.16+; update the app if +this option isn't there). + +### AnkiMobile (iOS) +**Settings → Advanced → Custom Sync Server**, same as AnkiDroid — enter the +Sync URL and log in. + +### First sync on each device +The very first sync from a device that already has a local collection will +ask whether to upload local data or download from the server — pick upload +from whichever device has your real collection, and download on every other +device, or you'll end up with two different collections that never merge. + +## Importing your existing Quizlet sets + +This server only handles syncing already-existing Anki collections — it +doesn't import anything itself. Quizlet import happens once, locally, in the +Anki desktop app, before your first sync: + +1. **In Quizlet:** open the set → **Export** → choose the plain-text / + tab-separated format (Quizlet's export dialog lets you pick the delimiter + between term and definition, and between rows — tab and newline are the + Anki-friendly defaults) → copy the exported text or download it as a + `.txt`/`.csv` file. +2. **In Anki Desktop:** **File → Import**, pick the file (or paste the text + into a `.txt` file first if you copied it to the clipboard). +3. Map the two columns to **Front** and **Back** in the import dialog, pick + or create the deck and note type, and import. +4. For **math facts or other simple front/back cards**, the Basic note type + is enough. For **more complex cards** (extra example fields, images, + audio, cloze deletions), switch the note type in the import dialog to a + template with more fields, or convert cards afterward — Anki's own + built-in note types (Basic, Basic (and reversed card), Cloze) cover most + of what Quizlet's own card types can do. +5. Sync from this device once the import looks right, so the imported deck + becomes the copy every other device downloads. + +### Exporting back out (Anki → Quizlet or anywhere else) +**File → Export**, choose "Notes in Plain Text" and pick the deck — this +produces the same tab-separated format Quizlet's own import expects, so the +round trip works in both directions. + +## Why spaced repetition here actually reschedules failed cards + +Anki's scheduler (FSRS, the default since recent Anki versions) tracks a +per-card memory-strength estimate and schedules the next review right before +you'd be expected to forget it. Answering "Again" on a card doesn't just +requeue it for later the same session — it lowers that card's estimated +strength, which shortens every subsequent interval for it until you've +proven you know it again, so a card you keep failing gets shown far more +often than one you consistently get right. This is scheduling logic inside +the Anki client itself; this sync server only stores and syncs the resulting +review history, it doesn't change how reviews are scheduled. diff --git a/services/anki-sync-server.sh b/services/anki-sync-server.sh new file mode 100644 index 0000000..38d7e40 --- /dev/null +++ b/services/anki-sync-server.sh @@ -0,0 +1,486 @@ +#!/bin/bash +# services/anki-sync-server.sh — Self-hosted Anki flashcard sync server. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash anki-sync-server.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +# Detected when the script is executed directly rather than sourced by setup.sh. +# Sets up helpers and globals, then defers execution until after the function +# definition at the bottom of this file. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi + echo " Already installed." + read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r + case "${_r,,}" in + u|update) eval "$_var='update'" ;; + f|fresh) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + generate_password() { + local length="${1:-32}" + openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + backup_if_exists() { + local _file="$1" + [ -f "$_file" ] || return 0 + cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080 + +install_anki-sync-server() { + require_docker || return 1 + log_info "Installing Anki Sync Server..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "anki-sync-server" name/paths/port exactly + # as before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/ntfy.sh and services/homebox.sh. A second instance is a real + # use case here (e.g. a second household wanting fully separate data on + # the same box) even though one instance already supports multiple + # independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's + # "Multi-instance services" section. + local ANKI_DIR="$DOCKER_DIR/anki-sync-server" + local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server" + local WEB_PORT="8080" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $ANKI_DIR(-)" + echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port" + return 0 + fi + + if [ -d "$ANKI_DIR" ]; then + echo "" + echo " Anki Sync Server is already installed at $ANKI_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Anki Sync Server instance alongside it (its" + echo " own data and port — full isolation)" + echo "" + local _TOP_CHOICE="" + prompt_text " Choice [1/2]:" "1" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "2" ]; then + local _suffix="" + while true; do + prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then + log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix" + CONTAINER="anki-sync-server-$_suffix" + log_info "New instance: $ANKI_DIR" + else + # "Manage that install" on THIS instance — the banner above promises + # update/fresh/cancel, so actually offer it instead of falling straight + # through into the same unconditional-overwrite flow as a new install. + if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is." + ( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \ + && log_success "Anki Sync Server image refreshed" \ + || log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs" + return 0 + ;; + cancel) + log_info "Leaving the existing install as-is." + return 0 + ;; + fresh) ;; # fall through to the full install flow below + esac + fi + fi + fi + + # Scan for a free port unconditionally — not just when adding an explicit + # additional instance. A plain first install can just as easily collide + # with an unrelated service that already claimed this default port — see + # CLAUDE.md's "Port collision avoidance" section. + find_free_port WEB_PORT "$WEB_PORT" + + # ── Sync accounts ───────────────────────────────────────────────────────── + # The official sync server has no signup flow of its own — accounts are + # fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container + # start, one per line in .env. Ask for at least one now (each Anki client + # — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer + # to add more for other people sharing this box, since a single instance + # already keeps each account's collection completely separate. + local ANKI_USERS=() ANKI_PASSWORDS=() + local _u="" + prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u + ANKI_USERS+=("$_u") + ANKI_PASSWORDS+=("$(generate_password 24)") + while true; do + local _more="" + prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more + [[ "$_more" =~ ^[Yy]$ ]] || break + prompt_text " Username for the additional account:" "" _u + if [ -z "$_u" ]; then + log_warning "Name can't be empty."; continue + fi + ANKI_USERS+=("$_u") + ANKI_PASSWORDS+=("$(generate_password 24)") + if [ "${#ANKI_USERS[@]}" -ge 8 ]; then + log_warning "That's plenty — stopping at 8 accounts. Add more later by editing .env and re-running 'docker compose up -d'." + break + fi + done + + mkdir -p "$ANKI_DIR/data" + ensure_docker_dir_ownership "$ANKI_DIR" + cd "$ANKI_DIR" || return 1 + + # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): + # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, + # then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a + # remote Caddy box can't resolve container names on this host's bridge + # network anyway; it reaches this service via the host's published port. + local _CADDY_MODE="${CADDY_MODE:-none}" + [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" + [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" + + local _CADDY_NET_BLOCK="" + local _CADDY_NET_SECTION="" + if [ "$_CADDY_MODE" = "local" ]; then + _CADDY_NET_BLOCK=" networks: + - caddy_net +" + _CADDY_NET_SECTION=" +networks: + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +" + fi + + # Build the SYNC_USERn=... lines for docker-compose.yml (compose-time + # interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env — + # same \${VAR} pattern services/homebox.sh uses for its own .env values) + # and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env. + local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx + for i in "${!ANKI_USERS[@]}"; do + idx=$((i + 1)) + _COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}} +" + _ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]} +ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]} +" + done + + backup_if_exists docker-compose.yml + cat > docker-compose.yml << ANKI_COMPOSE +name: $CONTAINER + +services: + anki-sync-server: + image: afrima/anki-sync-server:latest + container_name: $CONTAINER + hostname: $CONTAINER + restart: unless-stopped + environment: + - SYNC_HOST=0.0.0.0 + - SYNC_PORT=8080 + - SYNC_BASE=/data +${_COMPOSE_USER_LINES} volumes: + - ./data:/data + ports: + - "${WEB_PORT}:8080" +${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} +ANKI_COMPOSE + + backup_if_exists .env + cat > .env << ANKI_ENV +TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} +CADDY_NET=$SITE_CADDY_NET + +# One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2, +# ... in docker-compose.yml). Enter these exact values as the account on +# each Anki client (Preferences/Settings → self-hosted sync server). Add +# more pairs by hand later (ANKI_SYNC_USER9=..., ANKI_SYNC_PASSWORD9=...) +# and add the matching SYNC_USER9=\${ANKI_SYNC_USER9}:\${ANKI_SYNC_PASSWORD9} +# line to docker-compose.yml, then 'docker compose up -d' to pick it up. +${_ENV_USER_LINES} +ANKI_ENV + chmod 600 .env + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR" + + echo "" + log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)" + echo "" + echo " Sync accounts (also saved in $ANKI_DIR/.env):" + for i in "${!ANKI_USERS[@]}"; do + echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}" + done + echo "" + + # No Authelia gate here, unlike most other web-facing services in this + # repo: this is a raw HTTP sync API that the Anki client itself talks to + # (not a browser session), so a forward_auth login portal in front of it + # would just break every sync request instead of protecting anything. + # SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary — + # same reasoning as the has-built-in-auth services in CLAUDE.md, just + # with no web UI to additionally gate. + configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" + + local START="" + prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "Anki Sync Server started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + write_readme "$ANKI_DIR" << MD +# Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} + +Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard +app — syncs your collection across devices without going through AnkiWeb. +Anki's own spaced-repetition scheduler (FSRS) gives failed cards more +repetition and correctly-recalled cards longer gaps automatically; nothing +here changes that, it's purely the sync backend. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own data directory, own +accounts, own port) — not shared collections with another Anki Sync Server +instance.") + +## Access +- Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" ) +- Accounts (username / password): +$(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done) + +Enter the Sync URL and one of the above accounts on each Anki client — see +the client setup section below for exactly where. + +## Data +- Collections: \`$ANKI_DIR/data\` +- Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only) + +## Manage +\`\`\`bash +cd $ANKI_DIR +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d +\`\`\` +MD + + log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md" +} + +# ── Standalone execution ─────────────────────────────────────────────────── +if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then + install_anki-sync-server +fi