diff --git a/services/anki-sync-server.sh b/services/anki-sync-server.sh index 38d7e40..29b726d 100644 --- a/services/anki-sync-server.sh +++ b/services/anki-sync-server.sh @@ -226,6 +226,153 @@ fi register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080 +# Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an +# instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's +# dynamic scoping means a `local` array declared in the caller is visible +# here without being passed explicitly — same assumption every other helper +# below makes). Numbering is always kept contiguous from 1 by +# _anki_rewrite_account_block, so stopping at the first missing index is +# safe — there's never a gap to skip over. +_anki_load_accounts() { + local _dir="$1" _n=1 _u _p + ANKI_USERS=() ANKI_PASSWORDS=() + while true; do + _u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)" + [ -z "$_u" ] && break + _p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)" + ANKI_USERS+=("$_u") + ANKI_PASSWORDS+=("$_p") + _n=$((_n + 1)) + done +} + +# Regenerates the SYNC_USERn=... lines in docker-compose.yml and the +# matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the +# caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered +# contiguously from 1 — see _anki_load_accounts). Used by both the initial +# install and every account-management mutation (add/remove/rotate) so the +# two never drift apart, same reasoning as CLAUDE.md's shared-helper +# guidance for update vs. fresh-install codepaths. Leaves the port, Caddy +# block, and every other line in either file untouched — only lines +# matching the SYNC_USER/ANKI_SYNC_* patterns are touched. +_anki_rewrite_account_block() { + local _dir="$1" + local _compose="$_dir/docker-compose.yml" + local _env="$_dir/.env" + + sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose" + sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env" + + local _compose_lines="" _env_lines="" i idx + for i in "${!ANKI_USERS[@]}"; do + idx=$((i + 1)) + _compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}} +" + _env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]} +ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]} +" + done + + # Insert right after the fixed SYNC_BASE anchor line — always present, + # written by every version of this script's install flow — instead of + # appending at the end, so the block stays grouped with SYNC_HOST/ + # SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`. + local _tmp + _tmp="$(mktemp)" + printf '%s' "$_compose_lines" > "$_tmp" + sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose" + rm -f "$_tmp" + + printf '%s' "$_env_lines" >> "$_env" +} + +# Interactive add/remove/rotate menu for an existing instance's sync +# accounts, offered from install_anki-sync-server's "already installed" +# menu. Every mutation restarts the container (`docker compose up -d` +# re-reads .env for the new/removed/rotated credentials) but never touches +# the port, Caddy config, or the image — the things CLAUDE.md's "update vs. +# fresh reinstall" convention says a non-destructive path must leave alone. +_anki_manage_accounts() { + local _dir="$1" + local ANKI_USERS=() ANKI_PASSWORDS=() + while true; do + _anki_load_accounts "$_dir" + echo "" + echo " Current sync accounts:" + local i + for i in "${!ANKI_USERS[@]}"; do + echo " $((i + 1))) ${ANKI_USERS[$i]}" + done + [ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)" + echo "" + echo " a) Add an account" + echo " r) Remove an account" + echo " p) Rotate (reset) an account's password" + echo " 0) Done" + echo "" + local ACTION="" + prompt_text " Choice [a/r/p/0]:" "0" ACTION + case "$ACTION" in + a|A) + if [ "${#ANKI_USERS[@]}" -ge 8 ]; then + log_warning "That's plenty — stopping at 8 accounts." + continue + fi + local _u="" + prompt_text " New username:" "" _u + if [ -z "$_u" ]; then + log_warning "Name can't be empty."; continue + fi + ANKI_USERS+=("$_u") + ANKI_PASSWORDS+=("$(generate_password 24)") + _anki_rewrite_account_block "$_dir" + ( cd "$_dir" && docker compose up -d ) \ + && log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \ + || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" + ;; + r|R) + if [ "${#ANKI_USERS[@]}" -eq 0 ]; then + log_warning "No accounts to remove."; continue + fi + local _n="" + prompt_text " Remove which number?" "" _n + if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then + log_warning "Invalid choice."; continue + fi + local _removed="${ANKI_USERS[$((_n - 1))]}" + unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]' + ANKI_USERS=("${ANKI_USERS[@]}") + ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}") + _anki_rewrite_account_block "$_dir" + ( cd "$_dir" && docker compose up -d ) \ + && log_success "Account '$_removed' removed" \ + || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" + ;; + p|P) + if [ "${#ANKI_USERS[@]}" -eq 0 ]; then + log_warning "No accounts yet."; continue + fi + local _n="" + prompt_text " Rotate password for which number?" "" _n + if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then + log_warning "Invalid choice."; continue + fi + ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)" + _anki_rewrite_account_block "$_dir" + ( cd "$_dir" && docker compose up -d ) \ + && log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \ + || log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs" + ;; + 0) + break + ;; + *) + log_warning "Unrecognized choice." + ;; + esac + done +} + install_anki-sync-server() { require_docker || return 1 log_info "Installing Anki Sync Server..." @@ -255,13 +402,18 @@ install_anki-sync-server() { 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 " 1) Manage sync accounts (add / remove / rotate a password — doesn't" + echo " touch the port, Caddy, or the image)" + echo " 2) Manage that install (update image / full reinstall / cancel)" + echo " 3) 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 + prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE + if [ "$_TOP_CHOICE" = "1" ]; then + _anki_manage_accounts "$ANKI_DIR" + return 0 + elif [ "$_TOP_CHOICE" = "3" ]; then local _suffix="" while true; do prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix @@ -332,7 +484,7 @@ install_anki-sync-server() { 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'." + log_warning "That's plenty — stopping at 8 accounts." break fi done @@ -406,10 +558,10 @@ 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. +# each Anki client (Preferences/Settings → self-hosted sync server). To add, +# remove, or reset one of these later, re-run this installer against the +# existing install and pick "Manage sync accounts" — don't hand-edit these +# lines, the matching docker-compose.yml lines have to change in lockstep. ${_ENV_USER_LINES} ANKI_ENV chmod 600 .env @@ -475,6 +627,14 @@ docker compose down docker compose logs -f docker compose pull && docker compose up -d \`\`\` + +To add, remove, or reset the password of a sync account later, re-run the +installer against this install and pick **"Manage sync accounts"** — +don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have +to change alongside it: +\`\`\`bash +sudo ./setup.sh anki-sync-server +\`\`\` MD log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md"