Merge pull request #399 from outis1one/claude/pressbooks-authelia-setup-tcryml
Add Pressbooks: self-hosted book platform on WordPress Multisite
This commit is contained in:
@@ -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 |
|
| `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) |
|
| `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` |
|
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
||||||
@@ -240,6 +240,7 @@ utilities
|
|||||||
onlyoffice
|
onlyoffice
|
||||||
paintplus
|
paintplus
|
||||||
portainer
|
portainer
|
||||||
|
pressbooks
|
||||||
rustdesk
|
rustdesk
|
||||||
stirling-pdf
|
stirling-pdf
|
||||||
syncthing
|
syncthing
|
||||||
|
|||||||
@@ -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/<repo> GitHub project as an
|
||||||
|
# installable zip URL, for wp-cli's own "plugin install <url>"/"theme install
|
||||||
|
# <url>" (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'
|
||||||
|
<?php
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||||
|
$_SERVER['HTTPS'] = 'on';
|
||||||
|
}
|
||||||
|
PHPEXTRA
|
||||||
|
[[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo "define('PB_PRINCE_COMMAND', '/usr/local/bin/prince');" >> 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 <command>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 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
|
||||||
Reference in New Issue
Block a user