#!/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" # "wp" is spelled out explicitly rather than relying on the wordpress:cli # entrypoint's own "wp help $1 && set -- wp $@" auto-detection — that # probe itself runs through wp-cli's bootstrap, so anything that breaks # the bootstrap (a bad wp-config.php, a missing bind mount) makes the # probe fail *silently* and falls through to exec-ing the raw # subcommand as if it were a binary ("core: not found") instead of # surfacing the real error. _pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli wp "$@"; } 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 lives in a must-use plugin (wp-content/mu-plugins/, autoloaded by # WordPress on every request, no activation needed) rather than in # wp-config.php via WORDPRESS_CONFIG_EXTRA — two real, confirmed-live # problems with the wp-config.php route, in order of discovery: # 1. Compose interpolates $VAR-looking tokens found INSIDE .env file # values too, not just inside docker-compose.yml — a raw $_SERVER # sitting in .env got silently blanked to a bare "_SERVER" before # the container ever saw it. # 2. Routing it through a bind-mounted file and a wp-config.php # `require` line (this repo's first fix for #1) traded that bug for # a worse one: wp-cli's Runner does its own restricted, line-level # parsing of wp-config.php to pull out bootstrap constants without # a full WordPress load, and it can't handle anything past a plain # define(...) statement — an if(){ require ...; } line made *every* # wp-cli command in this script fail with a cryptic # "PHP Parse error ... eval()'d code ... unexpected end of file". # mu-plugins load through WordPress's normal plugin bootstrap, not # wp-cli's special wp-config.php pre-parser, so this sidesteps both # issues entirely — nothing here ever touches wp-config.php or .env. mkdir -p html/wp-content/mu-plugins cat > html/wp-content/mu-plugins/pressbooks-extra-config.php << 'PHPEXTRA' > html/wp-content/mu-plugins/pressbooks-extra-config.php [ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> html/wp-content/mu-plugins/pressbooks-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 ${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)" 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 # 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; then log_error "Image build failed — check the output above." return 1 fi if ! docker compose up -d; then log_error "Failed to 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 # "wp" spelled out explicitly — see _pb_wpcli's comment above for why. _wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli wp "$@"; } 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 \\" log_error " --env-file $DIR/.env wordpress:cli wp 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