diff --git a/README.md b/README.md index 311a024..08a481d 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`, `rustdesk`, `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`, `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`, `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` | @@ -240,6 +240,7 @@ utilities onlyoffice paintplus portainer + pressbooks rustdesk stirling-pdf syncthing diff --git a/services/pressbooks.sh b/services/pressbooks.sh new file mode 100644 index 0000000..9008ee0 --- /dev/null +++ b/services/pressbooks.sh @@ -0,0 +1,789 @@ +#!/bin/bash +# services/pressbooks.sh — Self-hosted Pressbooks: write/import books, drag-and-drop +# image placement, export to PDF/EPUB for professional or personal printing. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash pressbooks.sh +# (Docker must already be installed when run standalone) +# +# Pressbooks is a WordPress Multisite plugin/theme suite, not a normal +# WordPress plugin — its own docs are explicit that it "should be used with +# a fresh, multisite WordPress installation" and is "not for use on an +# existing blog." That means it can never be layered onto an existing +# services/wordpress.sh site: it gets its own dedicated WordPress core, +# database, and container here, converted to a Multisite network as part of +# this installer instead of a plain single-site install. +# +# Not following the multi-instance pattern documented in CLAUDE.md: that +# pattern exists for services that are inherently single-tenant per +# install. Pressbooks is the opposite — a single network already hosts any +# number of independent books (each its own site in the network, its own +# authors, its own theme), which is exactly the multi-tenancy the pattern +# gives other services. A second, fully separate Pressbooks *network* would +# only matter for something like two unrelated publishing organizations +# wanting entirely separate admin/user databases on one box — a much rarer +# need than "another book" — so it's left out of scope here. +# +# Chapters are written and images placed via WordPress's own block editor +# (Gutenberg) — dragging an image file into a chapter's content area drops +# an Image block at that position, and the block editor's own "Add Media" +# dialog also accepts drag-and-drop uploads. This is native WordPress +# behavior, not a Pressbooks feature, so it needs no extra plugin here. +# +# PDF export needs a rendering engine Pressbooks itself doesn't ship: +# - PrinceXML, installed on this container — free for non-commercial use +# (adds a small logo to page 1 of every PDF), full price for a +# commercial/watermark-free license. See install_pressbooks' Dockerfile +# generation below. +# - DocRaptor, PrinceXML as a paid SaaS API (DOCRAPTOR_API_KEY) — no local +# binary to maintain, but not free for real (non-watermarked) documents. +# - mPDF, Pressbooks' third documented option, is explicitly unmaintained +# upstream — not offered here. +# EPUB export needs no extra engine (Pressbooks generates it directly) and +# needs EPUBCheck's dependencies below. MOBI export was removed from +# Pressbooks entirely after Amazon discontinued KindleGen and stopped +# accepting MOBI on KDP (March 2025) — not offered here; see the generated +# README for the EPUB→MOBI-via-Calibre workaround for personal Kindle use. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + 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 " Existing install detected. Choose:" + echo " u) Update — refresh plugin/theme/image, keep books and settings" + echo " f) Full reinstall — re-run every prompt from scratch" + echo " c) Cancel — leave everything as-is [default]" + read -r -p " Choice [u/f/c, Enter=cancel]: " _r + case "${_r,,}" in + u) eval "$_var='update'" ;; + f) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + 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##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + CADDY_SERVICE_CONFIGURED=false + CADDY_SERVICE_MODE="" + CADDY_SERVICE_DOMAIN="" + [[ "$_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 + } + + 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; } + + local _block_upstream="$_upstream" + [[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { +${_extra} + 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 + } +} +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."; CADDY_SERVICE_CONFIGURED=true; CADDY_SERVICE_MODE="local"; CADDY_SERVICE_DOMAIN="$_domain"; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + CADDY_SERVICE_CONFIGURED=true + CADDY_SERVICE_MODE="local" + CADDY_SERVICE_DOMAIN="$_domain" + 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" + fi + else + CADDY_SERVICE_CONFIGURED=true + CADDY_SERVICE_MODE="remote" + CADDY_SERVICE_DOMAIN="$_domain" + 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" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true + } + 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 + + 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() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service pressbooks utilities "Self-hosted Pressbooks — write/import books with drag-and-drop images, export to PDF/EPUB (Authelia SSO gate)" 8095 + +# Fetches the newest release of a pressbooks/ GitHub project as an +# installable zip URL, for wp-cli's own "plugin install "/"theme install +# " (which download and unpack it itself — nothing here needs to know +# how to unzip a WordPress plugin). Prefers an actual release asset (the +# packaged, ready-to-install zip these projects publish, vendor/ dependencies +# included) and falls back to the tagged source archive GitHub always +# generates automatically if no asset is found — that fallback can be +# missing composer's vendor/ directory, so it's logged with a warning +# rather than silently swapped in. +_pressbooks_latest_zip_url() { + local _repo="$1" _api _url _tag + _api="$(curl -fsSL "https://api.github.com/repos/pressbooks/${_repo}/releases/latest" 2>/dev/null)" + _url="$(printf '%s' "$_api" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*\.zip"' | head -1 | grep -o 'https://[^"]*')" + if [ -z "$_url" ]; then + _tag="$(printf '%s' "$_api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)" + if [ -n "$_tag" ]; then + _url="https://github.com/pressbooks/${_repo}/archive/refs/tags/${_tag}.zip" + log_warning "No packaged release asset found for ${_repo} — falling back to its" >&2 + log_warning "tagged source archive, which can be missing composer's vendor/ directory." >&2 + fi + fi + printf '%s' "$_url" +} + +# A release zip's top-level directory sometimes carries a version suffix +# (especially the tagged-source-archive fallback above, e.g. +# "pressbooks-book-2.5.0/" instead of "pressbooks-book/") — WP-CLI's own +# "theme enable"/"plugin activate --network" need the directory to be named +# exactly the plugin/theme slug to find it at all. Renames it into place if +# a mismatch is found; a no-op if the zip already unpacked to the right name. +_pressbooks_normalize_slug() { + local _html_dir="$1" _kind="$2" _slug="$3" + docker run --rm -v "${_html_dir}:/var/www/html" alpine sh -c " + cd /var/www/html/wp-content/${_kind} 2>/dev/null || exit 0 + [ -d '${_slug}' ] && exit 0 + d=\$(ls -d ${_slug}-* 2>/dev/null | head -1) + [ -n \"\$d\" ] && mv \"\$d\" '${_slug}' + exit 0 + " >/dev/null 2>&1 +} + +# Installs/refreshes the Pressbooks plugin and its three companion themes +# (McLuhan/pressbooks-book — the default book theme; Aldine — the default +# root theme; Publisher — the default theme for the network's own landing +# site) network-wide, and activates Publisher on the root site. Shared by +# the fresh-install path and the "update" rerun path (CLAUDE.md's +# non-destructive-update convention: this only ever touches plugin/theme +# code, never wp-config.php, .env, or any book's own content/DB rows). +_pressbooks_install_plugins_and_themes() { + local _dir="$1" _net="$2" _port="$3" + _pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli "$@"; } + + local _plugin_url _book_url _aldine_url _publisher_url + _plugin_url="$(_pressbooks_latest_zip_url pressbooks)" + _book_url="$(_pressbooks_latest_zip_url pressbooks-book)" + _aldine_url="$(_pressbooks_latest_zip_url pressbooks-aldine)" + _publisher_url="$(_pressbooks_latest_zip_url pressbooks-publisher)" + + if [ -z "$_plugin_url" ]; then + log_error "Couldn't determine a Pressbooks download URL from GitHub (API unreachable or rate-limited)." + log_error "Install by hand instead: download a release zip from" + log_error " https://github.com/pressbooks/pressbooks/releases" + log_error "then, in Network Admin -> Plugins -> Add New -> Upload Plugin, upload it and Network Activate." + return 1 + fi + + log_info "Installing Pressbooks plugin..." + _pb_wpcli plugin install "$_plugin_url" --force || log_warning "Pressbooks plugin install reported an error — see docker compose logs." + _pressbooks_normalize_slug "${_dir}/html" plugins pressbooks + _pb_wpcli plugin activate pressbooks --network || log_warning "Network-activating Pressbooks failed — do it by hand in Network Admin -> Plugins." + + log_info "Installing Pressbooks themes (McLuhan, Aldine, Publisher)..." + for _pair in "pressbooks-book:$_book_url" "pressbooks-aldine:$_aldine_url" "pressbooks-publisher:$_publisher_url"; do + local _slug="${_pair%%:*}" _url="${_pair#*:}" + [ -z "$_url" ] && { log_warning "Couldn't determine a download URL for theme $_slug — skipping."; continue; } + _pb_wpcli theme install "$_url" --force || log_warning "Theme $_slug install reported an error." + _pressbooks_normalize_slug "${_dir}/html" themes "$_slug" + _pb_wpcli theme enable "$_slug" || log_warning "Network-enabling $_slug failed — do it by hand in Network Admin -> Themes." + done + _pb_wpcli theme activate pressbooks-publisher --url="http://localhost:${_port}" \ + || log_warning "Couldn't set Publisher as the network's own landing-site theme — set it by hand in Appearance -> Themes." +} + +install_pressbooks() { + require_docker || return 1 + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ PRESSBOOKS │" + echo "│ Self-hosted book platform — write/import chapters with │" + echo "│ drag-and-drop images, export to PDF (print) and EPUB (ebook) │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + local DIR="$DOCKER_DIR/pressbooks" + local CONTAINER="pressbooks" + local DB_CONTAINER="pressbooks-db" + local WP_NET="pressbooks_net" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR — a dedicated WordPress Multisite install (never an" + echo "[DRY-RUN] existing site — Pressbooks requires a fresh multisite network)" + echo "[DRY-RUN] Would build a custom image on wordpress:php8.3-apache: mod_rewrite +" + echo "[DRY-RUN] AllowOverride All (multisite needs working .htaccess rewrites)," + echo "[DRY-RUN] Ghostscript/ImageMagick/poppler-utils/libxml2-utils (cover generator +" + echo "[DRY-RUN] EPUB validation), and the ImageMagick PDF-coder policy fix Debian ships" + echo "[DRY-RUN] disabled by default" + echo "[DRY-RUN] Would prompt for a PDF export engine: PrinceXML (installed on this" + echo "[DRY-RUN] container, free for non-commercial use) and/or DocRaptor (paid SaaS API key)" + echo "[DRY-RUN] Would auto-scan for a free host port (8095 default) and dedicated MariaDB" + echo "[DRY-RUN] Would run wp-cli non-interactively: core install, convert to Multisite" + echo "[DRY-RUN] (subdirectory network), install+network-activate the Pressbooks plugin" + echo "[DRY-RUN] and its three themes" + echo "[DRY-RUN] Would offer a Caddy reverse proxy gated by Authelia SSO, and to start it" + return 0 + fi + + # ── Existing install? Offer update-in-place ────────────────────────────── + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing Pressbooks' base image, cover-generator packages, and the" + log_info "Pressbooks plugin/themes only — books, domain, and credentials are left" + log_info "exactly as they are." + ( cd "$DIR" && docker compose build --pull && docker compose up -d ) + local _WP_PORT + _WP_PORT="$(grep '^WEB_PORT=' "$DIR/.env" | cut -d= -f2-)" + _pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "${_WP_PORT:-8095}" + log_success "Pressbooks refreshed" + return 0 + ;; + cancel) + log_info "Leaving the existing Pressbooks install as-is." + return 0 + ;; + fresh) ;; + esac + fi + + # ── Network title + admin account ──────────────────────────────────────── + local PB_TITLE="" PB_ADMIN_USER="" PB_ADMIN_EMAIL="" + prompt_text "Book network title (shown on the landing site):" "My Book Library" PB_TITLE + prompt_text "Admin username:" "admin" PB_ADMIN_USER + prompt_text "Admin email:" "" PB_ADMIN_EMAIL + local PB_ADMIN_PASS="" + [ -f "$DIR/.env" ] && PB_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + [ -n "$PB_ADMIN_PASS" ] || PB_ADMIN_PASS="$(generate_password 16)" + + # ── PDF export engine ───────────────────────────────────────────────────── + echo "" + echo " PDF export needs a rendering engine Pressbooks itself doesn't ship." + local INSTALL_PRINCE="" PRINCE_LICENSE_PATH="" USE_DOCRAPTOR="" DOCRAPTOR_KEY="" + prompt_yn "Install PrinceXML for PDF export? Free for personal use, adds a small logo unless licensed (y/n):" "y" INSTALL_PRINCE + if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then + prompt_text " Already have a paid PrinceXML license file (removes the logo)? Path, or blank to skip:" "" PRINCE_LICENSE_PATH + if [ -n "$PRINCE_LICENSE_PATH" ] && [ ! -f "$PRINCE_LICENSE_PATH" ]; then + log_warning " $PRINCE_LICENSE_PATH not found — continuing with the free non-commercial version." + PRINCE_LICENSE_PATH="" + fi + fi + prompt_yn "Also configure DocRaptor (SaaS alternative — needs your own API key, paid past a small free quota)? (y/n):" "n" USE_DOCRAPTOR + if [[ "$USE_DOCRAPTOR" =~ ^[Yy]$ ]]; then + prompt_text " DocRaptor API key (from https://docraptor.com/documentation/api):" "" DOCRAPTOR_KEY + fi + if [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ]; then + log_warning "No PDF engine configured — Pressbooks' PDF export will fail until PrinceXML" + log_warning "or DocRaptor is set up (re-run this installer to add one later)." + fi + + # ── Free host port ──────────────────────────────────────────────────────── + local WEB_PORT=8095 + find_free_port WEB_PORT "$WEB_PORT" + + mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-UTC}" + + # Book covers, full-book PDF/EPUB exports, and large chapter-image + # imports all run well past stock PHP limits — sized generously up front + # rather than waiting for a first export to hit a wall. + cat > uploads-ini.d/uploads.ini << 'PHPINI' +file_uploads = On +memory_limit = 512M +upload_max_filesize = 128M +post_max_size = 128M +max_execution_time = 600 +max_input_time = 600 +PHPINI + + # WordPress core's own is_ssl() only looks at $_SERVER['HTTPS'], never + # X-Forwarded-Proto — behind Caddy (which terminates TLS and proxies + # plain HTTP to this container) that reads as "never HTTPS," sending + # wp-admin into a login/redirect loop the moment Caddy is wired up. + # This file is `require`d from wp-config.php via WORDPRESS_CONFIG_EXTRA + # below rather than passing the PHP itself through that env var: Compose + # interpolates $VAR-looking tokens found INSIDE .env file values, not + # just inside docker-compose.yml — confirmed live, a raw $_SERVER + # sitting in .env gets silently blanked to a bare "_SERVER" (with a + # "variable is not set" warning) before the container ever sees it, and + # escaping it as $$_SERVER doesn't collapse back to a single $ for an + # env_file-sourced value the way it does for a plain environment: entry + # — it would reach PHP as a broken "$$_SERVER" variable-variable + # instead. A bind-mounted file sidesteps the whole thing: nothing in it + # ever passes through Compose's .env parser. + cat > extra-config.php << 'PHPEXTRA' +> extra-config.php + [ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> extra-config.php + + # Prince license file, if provided, is bind-mounted rather than baked + # into the image — keeps a personal/purchased license out of the image + # layer, and survives an image rebuild on the "update" path untouched. + local PRINCE_LICENSE_VOLUME="" + if [ -n "$PRINCE_LICENSE_PATH" ]; then + cp "$PRINCE_LICENSE_PATH" "$DIR/prince-license.dat" + chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/prince-license.dat" + chmod 600 "$DIR/prince-license.dat" + PRINCE_LICENSE_VOLUME=" - ./prince-license.dat:/usr/local/lib/prince/license/license.dat:ro +" + fi + + # ── Dockerfile ──────────────────────────────────────────────────────────── + local _PRINCE_DOCKERFILE_BLOCK="" + if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then + _PRINCE_DOCKERFILE_BLOCK=' +# PrinceXML — the PDF rendering engine Pressbooks shells out to for PDF +# export. Free for non-commercial use (small logo on page 1 of every PDF; a +# purchased license.dat, bind-mounted by docker-compose.yml, removes it). +# Uses the "linux-generic" tarball rather than a distro-pinned .deb/.rpm so +# this keeps working if wordpress:php8.3-apache'"'"'s underlying Debian release +# moves on, and resolves the current major version + exact filename at +# build time instead of hardcoding one that will eventually go stale. +RUN set -eux; \ + ARCH="$(uname -m)"; \ + MAJOR="$(curl -fsSL https://www.princexml.com/download/ | grep -oE "/download/[0-9]+/" | grep -oE "[0-9]+" | sort -n | tail -1)"; \ + TARBALL_PATH="$(curl -fsSL "https://www.princexml.com/download/${MAJOR}/" | grep -oE "/download/prince-[0-9.]+-linux-generic-${ARCH}\.tar\.gz" | head -1)"; \ + curl -fsSL "https://www.princexml.com${TARBALL_PATH}" -o /tmp/prince.tar.gz; \ + mkdir -p /tmp/prince && tar -xzf /tmp/prince.tar.gz -C /tmp/prince --strip-components=1; \ + printf "\n" | /tmp/prince/install.sh; \ + rm -rf /tmp/prince /tmp/prince.tar.gz +' + fi + + backup_if_exists Dockerfile + cat > Dockerfile << DOCKERFILE +FROM wordpress:php8.3-apache + +# Multisite's subdirectory rewrite rules live in .htaccess — the base +# php-apache image ships mod_rewrite disabled and AllowOverride None, so +# .htaccess is silently ignored (pretty URLs 404, book pages don't route) +# without this. +RUN a2enmod rewrite \\ + && sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf + +# Pressbooks' cover generator shells out to Ghostscript/ImageMagick and +# poppler-utils (pdftoppm/pdfinfo) to rasterize book covers; libxml2-utils +# (xmllint) backs EPUB/HTMLBook validation. curl/ca-certificates are needed +# by the PrinceXML install step below, when enabled. +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ghostscript imagemagick poppler-utils libxml2-utils curl ca-certificates \\ + && rm -rf /var/lib/apt/lists/* + +# Debian's ImageMagick ships a security policy (a CVE-2016-3714 mitigation) +# that blocks the PDF/PS/EPS coders by default. Without this, ImageMagick +# refuses to rasterize the PDF Ghostscript hands it for a cover thumbnail — +# fails with "not authorized \`PDF'" rather than producing an image. +RUN for f in /etc/ImageMagick-6/policy.xml /etc/ImageMagick-7/policy.xml; do \\ + [ -f "\$f" ] && sed -i -E 's/rights="none" pattern="(PDF|PS|EPS)"/rights="read|write" pattern="\\1"/' "\$f"; \\ + done; true +${_PRINCE_DOCKERFILE_BLOCK} +DOCKERFILE + + # ── Caddy network wiring ────────────────────────────────────────────────── + 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_LINE="" _CADDY_NET_SECTION="" + if [ "$_CADDY_MODE" = "local" ]; then + _CADDY_NET_LINE=" - caddy_net +" + _CADDY_NET_SECTION=" + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +" + fi + + # ── docker-compose.yml ──────────────────────────────────────────────────── + backup_if_exists docker-compose.yml + cat > docker-compose.yml << PBCOMPOSE +name: pressbooks + +services: + pressbooks: + build: . + container_name: $CONTAINER + hostname: $CONTAINER + restart: unless-stopped + env_file: .env + depends_on: + - db + volumes: + - ./html:/var/www/html + - ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro + - ./extra-config.php:/usr/local/etc/pressbooks/extra-config.php:ro +${PRINCE_LICENSE_VOLUME} ports: + - "${WEB_PORT}:80" + networks: + - default +${_CADDY_NET_LINE} + db: + image: mariadb:11 + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/mysql + networks: + - default + +networks: + default: + name: $WP_NET +${_CADDY_NET_SECTION} +PBCOMPOSE + + # ── .env ────────────────────────────────────────────────────────────────── + local WP_DB_PASS="" WP_DB_ROOT_PASS="" + [ -f ".env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' .env | cut -d= -f2-)" + [ -f ".env" ] && WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)" + [ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)" + [ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)" + + # Loads the file written above — see the comment there for why the PHP + # itself doesn't live directly in this env var. This line has no "$" in + # it at all, so it can't run into Compose's .env interpolation. + local _CONFIG_EXTRA="require '/usr/local/etc/pressbooks/extra-config.php';" + + backup_if_exists .env + cat > .env << PBENV +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET +WEB_PORT=$WEB_PORT + +# Dedicated MariaDB for this network alone. +MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS +MYSQL_DATABASE=pressbooks +MYSQL_USER=pressbooks +MYSQL_PASSWORD=$WP_DB_PASS + +WORDPRESS_DB_HOST=$DB_CONTAINER +WORDPRESS_DB_NAME=pressbooks +WORDPRESS_DB_USER=pressbooks +WORDPRESS_DB_PASSWORD=$WP_DB_PASS +WORDPRESS_CONFIG_EXTRA="$_CONFIG_EXTRA" + +# Only consulted by wp-cli during initial setup below, not read by the +# wordpress:apache image itself. +WP_SITE_TITLE=$PB_TITLE +WP_ADMIN_USER=$PB_ADMIN_USER +WP_ADMIN_PASSWORD=$PB_ADMIN_PASS +WP_ADMIN_EMAIL=$PB_ADMIN_EMAIL +PBENV + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + log_success "Pressbooks configured at $DIR (port $WEB_PORT)" + log_info "Building image (first build downloads PrinceXML and cover-generator packages — can take a few minutes)..." + + if ! docker compose build && docker compose up -d; then + log_error "Failed to build/start — check: docker compose logs" + return 1 + fi + + log_info "Waiting for WordPress to come up..." + local _tries=0 + until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do + sleep 1; _tries=$((_tries + 1)) + done + + _wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli "$@"; } + + log_info "Running wp-cli core install..." + if ! _wpcli core install \ + --url="http://localhost:${WEB_PORT}" \ + --title="$PB_TITLE" \ + --admin_user="$PB_ADMIN_USER" \ + --admin_password="$PB_ADMIN_PASS" \ + --admin_email="$PB_ADMIN_EMAIL" \ + --skip-email; then + log_error "wp-cli core install failed — WordPress may not have been ready yet. Retry manually:" + log_error " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html --env-file $DIR/.env wordpress:cli core install ..." + return 1 + fi + + # Multisite refuses to activate with the "Plain" (query-string) permalink + # structure — pretty permalinks are a hard prerequisite, not optional. + log_info "Setting pretty permalinks and converting to a Multisite network..." + _wpcli rewrite structure '/%postname%/' --hard + _wpcli core multisite-convert --title="$PB_TITLE" + # multisite-convert doesn't rewrite .htaccess itself on Apache — without + # this, every site but the root 404s. + _wpcli rewrite flush --hard + + _pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "$WEB_PORT" + + # ── Authelia SSO gate ───────────────────────────────────────────────────── + # Pressbooks/WordPress has its own login screen, but no native + # OIDC/reverse-proxy-auth support the way gitea/mealie do (a third-party + # plugin could add one, the same "bigger lift" caveat CLAUDE.md notes for + # Jellyfin/Home Assistant) — so this is the same forward_auth gate used + # for services with no built-in auth at all: Authelia guards the front + # door, WordPress's own login is still a second gate behind it. + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local USE_AUTHELIA="" + prompt_yn "Protect Pressbooks with Authelia SSO? (y/n):" "y" USE_AUTHELIA + [[ "$USE_AUTHELIA" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "Pressbooks" "${CONTAINER}:80" "books" "$EXTRA_BLOCK" + + # Reconcile the domain WordPress/Multisite think they're on: core install + # ran against http://localhost:$WEB_PORT since the final domain isn't + # known until the Caddy prompt above. Two passes — the full scheme+host + # string first (catches siteurl/home, stored with "http://"), then the + # bare host (catches wp_site.domain/wp_blogs.domain, stored without a + # scheme) — doing it in the other order would leave siteurl/home on + # "http://" instead of "https://" once Caddy is terminating TLS. + if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ]; then + _wpcli search-replace "http://localhost:${WEB_PORT}" "https://${CADDY_SERVICE_DOMAIN}" --network --all-tables --report-changed-only + _wpcli search-replace "localhost:${WEB_PORT}" "$CADDY_SERVICE_DOMAIN" --network --all-tables --report-changed-only + log_success "Updated the network's URLs to https://$CADDY_SERVICE_DOMAIN" + fi + + declare -F _authelia_scope_access >/dev/null 2>&1 && [ "$CADDY_SERVICE_CONFIGURED" = true ] \ + && _authelia_scope_access "pressbooks" "$CADDY_SERVICE_DOMAIN" + + local PB_ACCESS_URL="http://localhost:${WEB_PORT}" + [ "$CADDY_SERVICE_CONFIGURED" = true ] && PB_ACCESS_URL="https://$CADDY_SERVICE_DOMAIN" + + write_readme "$DIR" << MD +# Pressbooks + +Self-hosted book platform on a dedicated WordPress Multisite network (its +own container/database — never shares an install with \`services/wordpress.sh\`, +since Pressbooks requires a fresh multisite network of its own). + +- Network admin: ${PB_ACCESS_URL}/wp-admin/network/ +- Admin user: \`$PB_ADMIN_USER\` +- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\` +- Book files: \`html/\` +- Database files: \`db/\` +- PHP limits: \`uploads-ini.d/uploads.ini\` (512M memory, 128M uploads, 600s + execution time — a full-book PDF export can take a while) + +## Creating a book +My Sites -> Network Admin -> Sites -> Add New creates a new book (its own +site in the network). Each book gets its own theme, its own chapters, and +its own front/back matter, picked from the Pressbooks admin bar once inside it. + +## Writing and placing images +Chapters are written in WordPress's own block editor. Type directly into a +chapter; to place an image, either drag an image file straight into the +content area to drop it in as an Image block exactly where you dropped it, +or use the editor's own Add Media button, which also accepts drag-and-drop +in its upload dialog. Cover images are uploaded the same way from a book's +own Book Info screen. + +## Exporting +Export options live under each book's own Export screen. +- **EPUB** — generated directly by Pressbooks, no extra engine needed. +- **PDF** — needs the rendering engine chosen at install time: +$( [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo " - PrinceXML is installed on this container.$( [ -n "$PRINCE_LICENSE_PATH" ] && echo " A license file is installed — no watermark." || echo " Free non-commercial version — adds a small logo to page 1 of every PDF; re-run this installer with a purchased license.dat to remove it." )" ) +$( [ -n "$DOCRAPTOR_KEY" ] && echo " - DocRaptor is configured as an alternative/fallback (uses your own API key — real documents count against your DocRaptor plan; DocRaptor's own \`test\` mode produces unlimited watermarked previews for free)." ) +$( [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ] && echo " - Not configured yet — re-run this installer (Update or Full reinstall) to add PrinceXML and/or DocRaptor." ) +- **MOBI/Kindle** — Pressbooks removed MOBI export after Amazon discontinued + KindleGen and stopped accepting MOBI on KDP (March 2025). For a personal + Kindle copy, export EPUB and convert it with Calibre — this repo's own + \`calibre-web\` service can do that conversion if you don't already have + Calibre elsewhere. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose build --pull && docker compose up -d # refresh base image + packages +\`\`\` +Or re-run \`sudo ./setup.sh pressbooks\` and choose Update, which also +refreshes the Pressbooks plugin/themes to their latest release. + +## wp-cli +\`\`\`bash +docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\ + --env-file $DIR/.env wordpress:cli wp +\`\`\` + +## Backup +\`services/backup.sh\` (Kopia) already covers this directory automatically — +generic for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so +both \`html/\` (every book's content and media) and \`db/\` are captured +together on every run with no per-service setup needed. +MD + + echo "" + echo " Access at: $PB_ACCESS_URL" + echo " Network admin: ${PB_ACCESS_URL}/wp-admin/network/" + echo " Admin user: $PB_ADMIN_USER" + echo " Admin pass: $PB_ADMIN_PASS" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pressbooks