From 5b79608695a572bafecf49d0c570c74ce01c9a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:13:25 +0000 Subject: [PATCH 01/14] Add library/: one-command Dockerized Koha home library (WIP) Compose stack (MariaDB + Koha built from the Koha project's Debian packages), headless bootstrap replacing the web installer, seeded item types and LoC Z39.50 server, and an install.sh that installs Docker, generates passwords and waits for the site. End-to-end testing in progress. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/.gitignore | 4 + library/docker-compose.yml | 48 +++++++++++ library/install.sh | 126 +++++++++++++++++++++++++++++ library/koha/Dockerfile | 40 +++++++++ library/koha/bootstrap.pl | 111 +++++++++++++++++++++++++ library/koha/certs/.gitkeep | 0 library/koha/entrypoint.sh | 80 ++++++++++++++++++ library/koha/seed/10-itemtypes.yml | 13 +++ library/koha/seed/20-z3950.yml | 9 +++ 9 files changed, 431 insertions(+) create mode 100644 library/.gitignore create mode 100644 library/docker-compose.yml create mode 100755 library/install.sh create mode 100644 library/koha/Dockerfile create mode 100755 library/koha/bootstrap.pl create mode 100644 library/koha/certs/.gitkeep create mode 100755 library/koha/entrypoint.sh create mode 100644 library/koha/seed/10-itemtypes.yml create mode 100644 library/koha/seed/20-z3950.yml diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..46f8659 --- /dev/null +++ b/library/.gitignore @@ -0,0 +1,4 @@ +# Generated by install.sh - contains passwords +.env +# Extra CA certificates dropped in for building behind a proxy +koha/certs/*.crt diff --git a/library/docker-compose.yml b/library/docker-compose.yml new file mode 100644 index 0000000..576a9b1 --- /dev/null +++ b/library/docker-compose.yml @@ -0,0 +1,48 @@ +name: library + +services: + db: + image: mariadb:10.11 + container_name: library-db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: koha_library + MARIADB_USER: koha_library + MARIADB_PASSWORD: ${DB_PASSWORD} + command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"] + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 20 + + koha: + build: + context: ./koha + args: + KOHA_SUITE: ${KOHA_SUITE:-stable} + image: library-koha:local + container_name: library-koha + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + TZ: ${TZ:-UTC} + KOHA_DB_HOST: db + KOHA_DB_PASS: ${DB_PASSWORD} + KOHA_ADMIN_USER: ${ADMIN_USER:-admin} + KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} + KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} + ports: + - "${OPAC_PORT:-8080}:80" + - "${STAFF_PORT:-8081}:8081" + volumes: + - koha-data:/var/lib/koha + +volumes: + db-data: + koha-data: diff --git a/library/install.sh b/library/install.sh new file mode 100755 index 0000000..5f7c849 --- /dev/null +++ b/library/install.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian. +# +# sudo ./install.sh # install Docker if needed, build, start +# sudo ./install.sh --status # show URLs and container health +# sudo ./install.sh --update # rebuild with the latest Koha and restart +# sudo ./install.sh --stop | --start +# +# Optional environment variables: +# LIBRARY_NAME="Smith Family Library" name shown on the catalogue +# OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used) +# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; } +c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; } +c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } +c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; } + +[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; } +RUN_USER="${SUDO_USER:-root}" + +# --- Docker ------------------------------------------------------------------- +ensure_docker() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed" + else + c_info "Installing Docker (official get.docker.com script)..." + command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; } + curl -fsSL https://get.docker.com | sh + docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin + c_ok "Docker installed" + fi + systemctl enable --now docker >/dev/null 2>&1 || true + if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then + usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)" + fi + docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; } +} + +port_in_use() { ss -tlnH "sport = :$1" 2>/dev/null | grep -q .; } +free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; } +rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; } +lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } + +dc() { docker compose --project-directory "$HERE" "$@"; } + +show_status() { + [ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; } + # shellcheck disable=SC1091 + . ./.env + local ip; ip="$(lan_ip)"; ip="${ip:-localhost}" + dc ps + echo + echo " Catalogue (public): http://$ip:$OPAC_PORT" + echo " Staff interface: http://$ip:$STAFF_PORT" + echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" + echo +} + +case "${1:-}" in + --status) show_status; exit 0 ;; + --stop) dc stop; exit 0 ;; + --start) dc start; exit 0 ;; + --update) + ensure_docker + dc build --pull --no-cache koha + dc up -d + show_status; exit 0 ;; + "" ) ;; + *) c_err "Unknown option: $1"; exit 1 ;; +esac + +ensure_docker + +# --- Settings (.env is created once and never overwritten) ------------------------ +if [ ! -f .env ]; then + c_info "Generating settings and passwords..." + TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)" + umask 077 + cat > .env << ENVEOF +# Generated by install.sh - keep this file private. +LIBRARY_NAME="${LIBRARY_NAME:-My Library}" +TZ=${TZ_NOW} +OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") +STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") +ADMIN_USER=admin +ADMIN_PASSWORD=$(rand 16) +DB_ROOT_PASSWORD=$(rand 32) +DB_PASSWORD=$(rand 32) +KOHA_SUITE=stable +ENVEOF + umask 022 + [ "$RUN_USER" = root ] || chown "$RUN_USER" .env +else + c_info "Re-using existing .env" +fi + +if [ -n "${EXTRA_CA:-}" ]; then + cp "$EXTRA_CA" koha/certs/extra-ca.crt + c_info "Trusting extra CA during the build: $EXTRA_CA" +fi + +# --- Build and start --------------------------------------------------------------- +c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..." +dc build +c_info "Starting..." +dc up -d + +# --- Wait until the site answers --------------------------------------------------- +# shellcheck disable=SC1091 +. ./.env +c_info "First start installs the database - waiting for the staff interface..." +for i in $(seq 1 120); do + if curl -fsS -o /dev/null "http://127.0.0.1:$STAFF_PORT/" 2>/dev/null; then READY=1; break; fi + if ! docker ps --format '{{.Names}}' | grep -qx library-koha; then c_err "library-koha stopped - see: docker logs library-koha"; exit 1; fi + sleep 5 +done +[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } + +c_ok "Your library is running." +echo +show_status diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile new file mode 100644 index 0000000..50555b5 --- /dev/null +++ b/library/koha/Dockerfile @@ -0,0 +1,40 @@ +# Koha (open-source library system) built from the Koha project's own Debian +# packages - no third-party Koha images involved. +# +# KOHA_SUITE stable (default) | oldstable | unstable +# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files; +# set "yes" if you want to install other languages later +FROM debian:bookworm-slim + +ARG KOHA_SUITE=stable +ARG KEEP_TRANSLATIONS=no +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata + +# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if +# you are behind a TLS-inspecting proxy. Empty by default. +COPY certs/ /usr/local/share/ca-certificates/extra/ +RUN update-ca-certificates + +RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \ + > /etc/apt/sources.list.d/koha.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends koha-common mariadb-client \ + && a2enmod rewrite headers proxy_http cgi deflate >/dev/null \ + && a2dissite 000-default >/dev/null 2>&1 || true \ + && if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \ + && rm -rf /var/lib/apt/lists/* /usr/share/doc/* + +COPY bootstrap.pl entrypoint.sh /opt/koha-init/ +COPY seed/ /opt/koha-init/seed/ +RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init + +EXPOSE 80 8081 +VOLUME ["/var/lib/koha"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=5 \ + CMD curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1 +ENTRYPOINT ["/opt/koha-init/entrypoint.sh"] diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl new file mode 100755 index 0000000..c3ca66c --- /dev/null +++ b/library/koha/bootstrap.pl @@ -0,0 +1,111 @@ +#!/usr/bin/perl +# Headless replacement for Koha's web installer + onboarding wizard. +# Run as the instance user: koha-shell -c "perl bootstrap.pl" +# +# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a +# future Koha upgrade keeps working), then seeds a home-library setup. +use Modern::Perl; +use C4::Context; +use C4::Installer; +use Koha; +use Koha::Libraries; +use Koha::Patrons; +use Koha::Patron::Categories; + +$| = 1; +my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed'; +my $admin_pass = $ENV{KOHA_ADMIN_PASS}; +die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass; +my %env = ( + admin_user => $ENV{KOHA_ADMIN_USER} || 'admin', + admin_pass => $admin_pass, + library => $ENV{KOHA_LIBRARY_NAME} || 'My Library', +); + +my $installer = C4::Installer->new(); +my $dbh = C4::Context->dbh; +my $intranet = C4::Context->config('intranetdir'); +my $data = "$intranet/installer/data/mysql"; + +# Already installed? (the entrypoint checks too; this is a safety net) +my ($tables) = $dbh->selectrow_array( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()"); +if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; } + +say "bootstrap: loading database schema..."; +if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; } + +$installer->set_marcflavour_syspref('marc21'); + +# Same ordering rules the web installer applies (sysprefs first, userflags last). +my @files = ( + sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ), + sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ), +); +# Optional data worth having: matching rules, "Fast add" framework (a short +# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values. +for my $f (qw( + marcflavour/marc21/optional/marc21_default_matching_rules.yml + marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml + optional/marc21_holdings_coded_values.yml + optional/marc21_relatorterms.yml + optional/auth_val.yml + optional/csv_profiles.yml +)) { + push @files, "$data/en/$f" if -f "$data/en/$f"; +} + +say "bootstrap: loading ", scalar(@files), " framework / data files..."; +my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files ); +for my $lvl (@$list) { + for my $f ( @{ $lvl->{fwklist} } ) { + say " ! $f->{fwkname}: $f->{error}" if $f->{error}; + } +} + +$installer->set_version_syspref(); +$installer->set_languages_syspref('en'); + +# ---- Home-library seed data (YAML in the same format Koha ships) ---------- +for my $y (sort glob("$seed_dir/*.yml")) { + my $err = $installer->load_sql($y); + say " seed $y: ", ( $err ? "ERROR $err" : 'ok' ); +} + +# ---- Library, patron categories, owner account ---------------------------- +Koha::Library->new({ + branchcode => 'HOME', branchname => $env{library}, +})->store unless Koha::Libraries->find('HOME'); + +for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) { + Koha::Patron::Category->new({ + categorycode => $c->[0], description => $c->[1], category_type => $c->[2], + enrolmentperiod => 999, overduenoticerequired => 0, + })->store unless Koha::Patron::Categories->find( $c->[0] ); +} + +my $admin = Koha::Patron->new({ + cardnumber => 'ADMIN', surname => 'Librarian', userid => $env{admin_user}, + branchcode => 'HOME', categorycode => 'OWNER', flags => 1, # 1 = superlibrarian +})->store; +$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); + +# ---- Preferences ---------------------------------------------------------- +my %prefs = ( + autoBarcode => 'incremental', # new items get a barcode automatically + itemcallnumber => '082ab', # Dewey call number copied from the MARC record + OpenLibraryCovers => 1, # cover images in the catalogue + OPACSearchForTitleIn => '', + BiblioDefaultView => 'normal', + OpacPublic => 1, + opacuserlogin => 0, # catalogue is browse-only; no patron logins needed + OpacMaintenance => 0, + ExpireReservesMaxPickUpDelay => 0, + AllowOfflineCirculation => 0, + marcflavour => 'MARC21', + LibraryName => $env{library}, + timezone => $ENV{TZ} || 'UTC', +); +C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs; + +say "bootstrap: done. Staff login: $env{admin_user}"; diff --git a/library/koha/certs/.gitkeep b/library/koha/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh new file mode 100755 index 0000000..f395132 --- /dev/null +++ b/library/koha/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Container entrypoint: (re)creates the Koha instance config from environment +# variables on every start, installs the database on the first start, then runs +# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha. +set -euo pipefail + +INST="${KOHA_INSTANCE:-library}" +DB_HOST="${KOHA_DB_HOST:-db}" +DB_NAME="${KOHA_DB_NAME:-koha_library}" +DB_USER="${KOHA_DB_USER:-koha_library}" +: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}" +: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}" +STAFF_PORT=8081 +export TZ="${TZ:-UTC}" + +log() { echo "[koha-init] $*"; } + +# --- 1. memcached (Koha caches sysprefs and sessions in it) ----------------- +memcached -u memcache -m 64 -d +cron + +# --- 2. wait for MariaDB ---------------------------------------------------- +log "waiting for database at $DB_HOST..." +for i in $(seq 1 120); do + mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break + [ "$i" = 120 ] && { log "database never became ready"; exit 1; } + sleep 2 +done + +# --- 3. instance config (regenerated each time a container is created) ------ +if [ ! -d "/etc/koha/sites/$INST" ]; then + log "creating Koha instance '$INST'..." + sed -i \ + -e 's|^DOMAIN=.*|DOMAIN=""|' \ + -e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \ + -e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \ + /etc/koha/koha-sites.conf + echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd + koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \ + --memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true + + # No RabbitMQ in this stack: workers fall back to polling the database. + CONF="/etc/koha/sites/$INST/koha-conf.xml" + perl -0pi -e 's|\s*.*?||s' "$CONF" + + # Serve both interfaces through Plack (much faster than plain CGI). + SITE="/etc/apache2/sites-available/$INST.conf" + sed -i \ + -e 's|^\(\s*\)Include /etc/koha/apache-shared-opac.conf|\1Include /etc/koha/apache-shared-opac-plack.conf|' \ + -e 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|\1Include /etc/koha/apache-shared-intranet-plack.conf|' \ + -e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf||' \ + -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf||' "$SITE" + koha-plack --enable "$INST" >/dev/null + grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf +fi +chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true + +# --- 4. install or upgrade the database ------------------------------------- +TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \ + -e "select count(*) from information_schema.tables where table_schema='$DB_NAME'") +if [ "$TABLES" = "0" ]; then + log "first start: installing Koha (about a minute)..." + koha-shell -c "KOHA_ADMIN_USER='${KOHA_ADMIN_USER:-admin}' KOHA_ADMIN_PASS='$KOHA_ADMIN_PASS' KOHA_LIBRARY_NAME='${KOHA_LIBRARY_NAME:-My Library}' TZ='$TZ' perl /opt/koha-init/bootstrap.pl" "$INST" + FIRST_RUN=1 +else + log "existing database found - applying any pending upgrades..." + koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)" + FIRST_RUN=0 +fi + +# --- 5. search index (Zebra) ------------------------------------------------ +koha-plack --restart "$INST" >/dev/null 2>&1 || koha-plack --start "$INST" >/dev/null 2>&1 || true +koha-worker --restart "$INST" >/dev/null 2>&1 || true +if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then + log "building search index..." + koha-rebuild-zebra -f -b -a -v "$INST" >/dev/null 2>&1 || koha-rebuild-zebra -f -v "$INST" || true +fi + +log "ready - library catalogue on :80, staff interface on :$STAFF_PORT" +exec apache2ctl -D FOREGROUND diff --git a/library/koha/seed/10-itemtypes.yml b/library/koha/seed/10-itemtypes.yml new file mode 100644 index 0000000..cdfc0fd --- /dev/null +++ b/library/koha/seed/10-itemtypes.yml @@ -0,0 +1,13 @@ +--- +description: + - "Item types for a home library: books, movies, music, games" +tables: + - itemtypes: + translatable: [ description ] + multiline: [] + rows: + - { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" } + - { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" } + - { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" } + - { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" } + - { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" } diff --git a/library/koha/seed/20-z3950.yml b/library/koha/seed/20-z3950.yml new file mode 100644 index 0000000..50975fd --- /dev/null +++ b/library/koha/seed/20-z3950.yml @@ -0,0 +1,9 @@ +--- +description: + - "Public Z39.50 servers used by Cataloging > Z39.50/SRU search" +tables: + - z3950servers: + translatable: [ servername ] + multiline: [] + rows: + - { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio } From 7244cfd9180c1e9daddd20fa3078749989fea479 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:20:45 +0000 Subject: [PATCH 02/14] library: fix entrypoint (Apache user, Listen 8081, start daemons each boot) and crash detection - Run Apache as the instance user instead of mpm_itk AssignUserID - Add Listen for the staff port; start zebra/indexer/worker/plack explicitly - Tolerate koha-plack --enable returning non-zero under set -e - install.sh: only fail on a crash loop, not a single restart Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/install.sh | 5 ++++- library/koha/entrypoint.sh | 21 ++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/library/install.sh b/library/install.sh index 5f7c849..334f356 100755 --- a/library/install.sh +++ b/library/install.sh @@ -116,7 +116,10 @@ dc up -d c_info "First start installs the database - waiting for the staff interface..." for i in $(seq 1 120); do if curl -fsS -o /dev/null "http://127.0.0.1:$STAFF_PORT/" 2>/dev/null; then READY=1; break; fi - if ! docker ps --format '{{.Names}}' | grep -qx library-koha; then c_err "library-koha stopped - see: docker logs library-koha"; exit 1; fi + # One restart is tolerated (Docker's restart policy); a crash loop is not. + if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then + c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1 + fi sleep 5 done [ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; } diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index f395132..aee233c 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -50,7 +50,15 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then -e 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|\1Include /etc/koha/apache-shared-intranet-plack.conf|' \ -e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf||' \ -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf||' "$SITE" - koha-plack --enable "$INST" >/dev/null + koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it + + # Run Apache itself as the instance user instead of mpm_itk's per-vhost + # uid switching (which needs extra capabilities and is pointless here: + # Plack does the real work as that user already). + sed -i '/AssignUserID/d' "$SITE" + sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \ + -e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars + grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf fi chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true @@ -68,13 +76,16 @@ else FIRST_RUN=0 fi -# --- 5. search index (Zebra) ------------------------------------------------ -koha-plack --restart "$INST" >/dev/null 2>&1 || koha-plack --start "$INST" >/dev/null 2>&1 || true -koha-worker --restart "$INST" >/dev/null 2>&1 || true +# --- 5. background services (their `daemon` processes die with the container, +# so they are started explicitly on every start) ------------------------ if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then log "building search index..." - koha-rebuild-zebra -f -b -a -v "$INST" >/dev/null 2>&1 || koha-rebuild-zebra -f -v "$INST" || true + koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true fi +for svc in koha-zebra koha-indexer koha-worker koha-plack; do + "$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \ + || log "WARNING: could not start $svc" +done log "ready - library catalogue on :80, staff interface on :$STAFF_PORT" exec apache2ctl -D FOREGROUND From a15478b382f0cdc5d20a7e0c2bf1f6ed14680eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:29:27 +0000 Subject: [PATCH 03/14] library: run bootstrap via runuser, keep mpm_itk, add SETUID/SETGID caps - koha-shell -c wraps the command in single quotes, which broke the env assignments; use runuser + env instead and verify tables were created - koha-create requires mpm_itk, so keep it and grant the caps it needs - stop any Apache started by koha-create before running it in foreground Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/docker-compose.yml | 4 ++++ library/koha/entrypoint.sh | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 576a9b1..d4ab444 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -37,6 +37,10 @@ services: KOHA_ADMIN_USER: ${ADMIN_USER:-admin} KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} + # Koha's Apache uses mpm_itk, which switches users per request. + cap_add: + - SETUID + - SETGID ports: - "${OPAC_PORT:-8080}:80" - "${STAFF_PORT:-8081}:8081" diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index aee233c..885a735 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -68,7 +68,15 @@ TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \ -e "select count(*) from information_schema.tables where table_schema='$DB_NAME'") if [ "$TABLES" = "0" ]; then log "first start: installing Koha (about a minute)..." - koha-shell -c "KOHA_ADMIN_USER='${KOHA_ADMIN_USER:-admin}' KOHA_ADMIN_PASS='$KOHA_ADMIN_PASS' KOHA_LIBRARY_NAME='${KOHA_LIBRARY_NAME:-My Library}' TZ='$TZ' perl /opt/koha-init/bootstrap.pl" "$INST" + # (not koha-shell -c: it wraps the command in single quotes of its own) + runuser -u "$INST-koha" -- env \ + KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" \ + KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \ + KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" TZ="$TZ" \ + perl /opt/koha-init/bootstrap.pl + TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \ + -e "select count(*) from information_schema.tables where table_schema='$DB_NAME'") + [ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; } FIRST_RUN=1 else log "existing database found - applying any pending upgrades..." @@ -87,5 +95,10 @@ for svc in koha-zebra koha-indexer koha-worker koha-plack; do || log "WARNING: could not start $svc" done +# koha-create / koha-plack may have started Apache already; run it in the foreground instead. +apache2ctl -k stop >/dev/null 2>&1 || true +for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done +rm -f /var/run/apache2/apache2.pid + log "ready - library catalogue on :80, staff interface on :$STAFF_PORT" exec apache2ctl -D FOREGROUND From b4c844b124f976efb84d66c89848708fd31bc636 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:30:12 +0000 Subject: [PATCH 04/14] library: pass PERL5LIB to bootstrap (koha-shell used to set it) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/koha/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index 885a735..5067262 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -70,7 +70,7 @@ if [ "$TABLES" = "0" ]; then log "first start: installing Koha (about a minute)..." # (not koha-shell -c: it wraps the command in single quotes of its own) runuser -u "$INST-koha" -- env \ - KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" \ + KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \ KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \ KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" TZ="$TZ" \ perl /opt/koha-init/bootstrap.pl From f0a0c4ad40124774b612074a0bdeebc22966d7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:51:50 +0000 Subject: [PATCH 05/14] library: seed label templates/layouts, default item type, fix Apache setup - Seed Avery 5160/5167 and Demco spine templates plus Spine, Spine+barcode and Barcode-only layouts (Dewey call number split across lines) - Default 942$c (item type) to BOOK so a Z39.50 import saves without edits - Enable Plack includes in addition to the plain ones (static files/rewrites) - Swap Apache from mpm_itk to prefork after koha-create (itk can't switch users in a container and returned HTTP 500 on every page) - Drop unneeded SETUID/SETGID caps Verified live: staff login, Z39.50 search/import against a local server, save + auto barcode, label PDF with Dewey spine + barcode. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/docker-compose.yml | 4 -- library/koha/bootstrap.pl | 6 ++ library/koha/entrypoint.sh | 13 ++-- library/koha/seed/30-labels.yml | 116 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 library/koha/seed/30-labels.yml diff --git a/library/docker-compose.yml b/library/docker-compose.yml index d4ab444..576a9b1 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -37,10 +37,6 @@ services: KOHA_ADMIN_USER: ${ADMIN_USER:-admin} KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} - # Koha's Apache uses mpm_itk, which switches users per request. - cap_add: - - SETUID - - SETGID ports: - "${OPAC_PORT:-8080}:80" - "${STAFF_PORT:-8081}:8081" diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index c3ca66c..dc676b7 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -90,6 +90,12 @@ my $admin = Koha::Patron->new({ })->store; $admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); +# ---- Make "add a book" need as few decisions as possible ------------------- +# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so +# a Z39.50 import can be saved without touching that field. +$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK' + WHERE tagfield = '942' AND tagsubfield = 'c'}); + # ---- Preferences ---------------------------------------------------------- my %prefs = ( autoBarcode => 'incremental', # new items get a barcode automatically diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index 5067262..99dda5e 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -45,16 +45,21 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then # Serve both interfaces through Plack (much faster than plain CGI). SITE="/etc/apache2/sites-available/$INST.conf" + # The *-plack.conf files add the proxy rules on top of the plain ones (which + # still provide static files and rewrites), so enable them in addition. sed -i \ - -e 's|^\(\s*\)Include /etc/koha/apache-shared-opac.conf|\1Include /etc/koha/apache-shared-opac-plack.conf|' \ - -e 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|\1Include /etc/koha/apache-shared-intranet-plack.conf|' \ - -e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf||' \ - -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf||' "$SITE" + -e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \ + -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE" koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it # Run Apache itself as the instance user instead of mpm_itk's per-vhost # uid switching (which needs extra capabilities and is pointless here: # Plack does the real work as that user already). + # koha-create insists on mpm_itk being enabled, but inside a container it can + # not switch users (initgroups: Operation not permitted -> HTTP 500 on every + # page). Now that the instance exists, swap to plain prefork. + rm -f /etc/apache2/mods-enabled/mpm_itk.* + a2enmod mpm_prefork >/dev/null 2>&1 || true sed -i '/AssignUserID/d' "$SITE" sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \ -e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars diff --git a/library/koha/seed/30-labels.yml b/library/koha/seed/30-labels.yml new file mode 100644 index 0000000..13e9f41 --- /dev/null +++ b/library/koha/seed/30-labels.yml @@ -0,0 +1,116 @@ +--- +description: + - "Label sheets and layouts for Tools > Labels (spine labels and barcodes)" +tables: + - creator_templates: + translatable: [ template_desc ] + multiline: [ ] + rows: + # Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label + # plus barcode on one label. + - template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)" + template_desc: "3 columns, 10 rows. Spine label with barcode." + page_width: 8.5 + page_height: 11 + label_width: 2.63 + label_height: 1 + top_text_margin: 0.139 + left_text_margin: 0.1 + top_margin: 0.35 + left_margin: 0.23 + cols: 3 + rows: 10 + col_gap: 0.13 + row_gap: 0 + units: "INCH" + creator: "Labels" + + # Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers. + - template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)" + template_desc: "4 columns, 20 rows. Barcode-only stickers." + page_width: 8.5 + page_height: 11 + label_width: 1.75 + label_height: 0.5 + top_text_margin: 0.1 + left_text_margin: 0.05 + top_margin: 0.5 + left_margin: 0.3 + cols: 4 + rows: 20 + col_gap: 0.3 + row_gap: 0 + units: "INCH" + creator: "Labels" + + # Demco 1" x 1.5" spine labels (Koha's own sample measurements). + - template_code: "Demco spine | 1 x 1.5 (50 per sheet)" + template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines." + page_width: 8.5 + page_height: 11 + label_width: 1.5 + label_height: 1 + top_text_margin: 0.236 + left_text_margin: 0 + top_margin: 0.5 + left_margin: 0.25 + cols: 5 + rows: 10 + col_gap: 0.0625 + row_gap: 0 + units: "INCH" + creator: "Labels" + + - creator_layouts: + translatable: [ layout_name, layout_xml ] + multiline: [ layout_xml ] + rows: + # callnum_split splits "613.2 SMI" onto separate lines the way spine + # labels are normally read (Dewey/LC aware). + - layout_name: "Spine - call number" + barcode_type: CODE39 + start_label: 1 + printing_type: BIB + guidebox: 0 + oblique_title: 0 + font: TB + font_size: 11 + units: POINT + callnum_split: 1 + text_justify: C + format_string: "itemcallnumber" + layout_xml: + - "" + creator: "Labels" + + - layout_name: "Spine + barcode" + barcode_type: CODE39 + start_label: 1 + printing_type: BIBBAR + guidebox: 0 + oblique_title: 0 + font: TB + font_size: 10 + units: POINT + callnum_split: 1 + text_justify: C + format_string: "itemcallnumber" + layout_xml: + - "" + creator: "Labels" + + - layout_name: "Barcode only" + barcode_type: CODE39 + start_label: 1 + printing_type: BAR + guidebox: 0 + oblique_title: 0 + font: TR + font_size: 10 + units: POINT + callnum_split: 0 + text_justify: L + format_string: "barcode" + layout_xml: + - "" + creator: "Labels" From f4da2608cd9d809d36e2cda5dc51b3d2365ad066 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:07:56 +0000 Subject: [PATCH 06/14] library/install.sh: retry builds on Docker Hub rate limits (HTTP 429) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/install.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/library/install.sh b/library/install.sh index 334f356..34091d2 100755 --- a/library/install.sh +++ b/library/install.sh @@ -48,6 +48,17 @@ lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } dc() { docker compose --project-directory "$HERE" "$@"; } +# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared +# IPs; retry with a growing pause instead of failing the whole install. +with_retry() { + local n=1 max=5 wait=15 + until "$@"; do + [ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; } + c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..." + sleep "$wait"; n=$((n + 1)); wait=$((wait * 2)) + done +} + show_status() { [ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; } # shellcheck disable=SC1091 @@ -67,7 +78,7 @@ case "${1:-}" in --start) dc start; exit 0 ;; --update) ensure_docker - dc build --pull --no-cache koha + with_retry dc build --pull --no-cache koha dc up -d show_status; exit 0 ;; "" ) ;; @@ -106,9 +117,9 @@ fi # --- Build and start --------------------------------------------------------------- c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..." -dc build +with_retry dc build c_info "Starting..." -dc up -d +with_retry dc up -d # --- Wait until the site answers --------------------------------------------------- # shellcheck disable=SC1091 From 2ef8f68b9b07f7b8c4fd7e6028c768bcc53cf0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:24:54 +0000 Subject: [PATCH 07/14] library: add Quick Add page (scan an ISBN, the book is added) A small standard-library Python service next to Koha: - looks the code up via Z39.50 (yaz) then Open Library over HTTPS - checks Koha for an existing copy (ISBN-10/13 and UPC/EAN aware) - adds the record + an item with the next barcode via Koha's REST API - scanner-friendly UI: auto-focus, audible feedback, duplicate card with 'add another copy', manual entry when nothing is found, undo, and the barcode range to print labels for Also: enable RESTBasicAuth, map 024$a to the ean column, wire the service, port and credentials into compose/install.sh. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/.gitignore | 1 + library/docker-compose.yml | 25 ++ library/install.sh | 7 + library/koha/bootstrap.pl | 7 + library/quickadd/Dockerfile | 26 ++ library/quickadd/app.py | 524 ++++++++++++++++++++++++++++++++ library/quickadd/certs/.gitkeep | 0 library/quickadd/index.html | 263 ++++++++++++++++ 8 files changed, 853 insertions(+) create mode 100644 library/quickadd/Dockerfile create mode 100644 library/quickadd/app.py create mode 100644 library/quickadd/certs/.gitkeep create mode 100644 library/quickadd/index.html diff --git a/library/.gitignore b/library/.gitignore index 46f8659..a0a4e8d 100644 --- a/library/.gitignore +++ b/library/.gitignore @@ -2,3 +2,4 @@ .env # Extra CA certificates dropped in for building behind a proxy koha/certs/*.crt +quickadd/certs/*.crt diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 576a9b1..0b6c1ec 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -43,6 +43,31 @@ services: volumes: - koha-data:/var/lib/koha + # Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API). + quickadd: + build: + context: ./quickadd + image: library-quickadd:local + container_name: library-quickadd + restart: unless-stopped + depends_on: + koha: + condition: service_started + environment: + TZ: ${TZ:-UTC} + KOHA_API: http://koha:8081/api/v1 + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + KOHA_LIBRARY: HOME + OPAC_PORT: ${OPAC_PORT:-8080} + STAFF_PORT: ${STAFF_PORT:-8081} + QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} + ports: + - "${QUICKADD_PORT:-8082}:8082" + volumes: + - quickadd-data:/data + volumes: db-data: koha-data: + quickadd-data: diff --git a/library/install.sh b/library/install.sh index 34091d2..a2431b0 100755 --- a/library/install.sh +++ b/library/install.sh @@ -68,6 +68,7 @@ show_status() { echo echo " Catalogue (public): http://$ip:$OPAC_PORT" echo " Staff interface: http://$ip:$STAFF_PORT" + echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)" echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" echo } @@ -98,6 +99,7 @@ LIBRARY_NAME="${LIBRARY_NAME:-My Library}" TZ=${TZ_NOW} OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") +QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}") ADMIN_USER=admin ADMIN_PASSWORD=$(rand 16) DB_ROOT_PASSWORD=$(rand 32) @@ -108,10 +110,15 @@ ENVEOF [ "$RUN_USER" = root ] || chown "$RUN_USER" .env else c_info "Re-using existing .env" + # Settings added in later versions: append them to an older .env. + if ! grep -q '^QUICKADD_PORT=' .env; then + echo "QUICKADD_PORT=$(free_port 8082)" >> .env + fi fi if [ -n "${EXTRA_CA:-}" ]; then cp "$EXTRA_CA" koha/certs/extra-ca.crt + cp "$EXTRA_CA" quickadd/certs/extra-ca.crt c_info "Trusting extra CA during the build: $EXTRA_CA" fi diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index dc676b7..0791c98 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -96,10 +96,17 @@ $admin->set_password({ password => $env{admin_pass}, skip_validation => 1 }); $dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK' WHERE tagfield = '942' AND tagsubfield = 'c'}); +# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework +# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it +# to the catalogue's `ean` column (used by the quick-add page's duplicate check). +$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean' + WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')}); + # ---- Preferences ---------------------------------------------------------- my %prefs = ( autoBarcode => 'incremental', # new items get a barcode automatically itemcallnumber => '082ab', # Dewey call number copied from the MARC record + RESTBasicAuth => 1, # lets the quick-add page use the REST API OpenLibraryCovers => 1, # cover images in the catalogue OPACSearchForTitleIn => '', BiblioDefaultView => 'normal', diff --git a/library/quickadd/Dockerfile b/library/quickadd/Dockerfile new file mode 100644 index 0000000..9998b35 --- /dev/null +++ b/library/quickadd/Dockerfile @@ -0,0 +1,26 @@ +# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python +# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds +# one small layer instead of another base-image pull. +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates python3 yaz tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if +# you are behind a TLS-inspecting proxy. Empty by default. +COPY certs/ /usr/local/share/ca-certificates/extra/ +RUN update-ca-certificates + +RUN useradd --system --uid 10001 --home-dir /app quickadd \ + && mkdir -p /app /data && chown quickadd /data +COPY app.py index.html /app/ +USER quickadd +WORKDIR /app + +EXPOSE 8082 +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)" +CMD ["python3", "/app/app.py"] diff --git a/library/quickadd/app.py b/library/quickadd/app.py new file mode 100644 index 0000000..917c19c --- /dev/null +++ b/library/quickadd/app.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""Quick Add - scan an ISBN, the book is in the catalogue. + +A tiny web app that sits next to Koha. For each ISBN/UPC it: + 1. looks the item up (Z39.50 servers first, then Open Library over HTTPS), + 2. checks Koha for an existing copy, + 3. adds the record + an item (next free barcode) through Koha's REST API. + +Standard library only; Z39.50 is done by shelling out to yaz-client. +""" +import base64 +import hmac +import json +import os +import re +import subprocess +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/") +USER = os.environ.get("ADMIN_USER", "admin") +PASSWORD = os.environ.get("ADMIN_PASSWORD", "") +LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME") +STAFF_PORT = os.environ.get("STAFF_PORT", "8081") +OPAC_PORT = os.environ.get("OPAC_PORT", "8080") +DATA_DIR = os.environ.get("DATA_DIR", "/data") +PORT = int(os.environ.get("PORT", "8082")) +SOURCES = [s.strip() for s in os.environ.get( + "QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()] +Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12")) +ITEM_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"} + +NS = {"m": "http://www.loc.gov/MARC21/slim"} +ET.register_namespace("", NS["m"]) +RECENT_FILE = os.path.join(DATA_DIR, "recent.json") +LOCK = threading.Lock() + + +# ---------------------------------------------------------------- identifiers +def clean_code(raw): + code = re.sub(r"[\s-]", "", raw or "").upper() + return code if re.fullmatch(r"[0-9]{8,14}|[0-9]{9}[0-9X]", code) else None + + +def isbn10_to_13(i10): + body = "978" + i10[:9] + s = sum(int(c) * (1 if n % 2 == 0 else 3) for n, c in enumerate(body)) + return body + str((10 - s % 10) % 10) + + +def isbn13_to_10(i13): + if not i13.startswith("978"): + return None + body = i13[3:12] + s = sum(int(c) * (10 - n) for n, c in enumerate(body)) + chk = (11 - s % 11) % 11 + return body + ("X" if chk == 10 else str(chk)) + + +def is_isbn(code): + return len(code) == 10 or (len(code) == 13 and code[:3] in ("978", "979")) + + +# -------------------------------------------------------------------- Z39.50 +def z3950_search(spec, code): + """spec = host:port/db. Returns MARCXML string or None.""" + host_port_db = spec + attrs = ["@attr 1=7"] if is_isbn(code) else ["@attr 1=1007"] + queries = [code] + if len(code) == 13 and is_isbn(code): + alt = isbn13_to_10(code) + if alt: + queries.append(alt) + for q in queries: + with tempfile.TemporaryDirectory() as tmp: + dump = os.path.join(tmp, "rec.mrc") + script = "format usmarc\nfind %s %s\nshow 1\nquit\n" % (attrs[0], q) + try: + out = subprocess.run( + ["yaz-client", "-m", dump, host_port_db], input=script, + capture_output=True, text=True, timeout=Z3950_TIMEOUT).stdout + except (subprocess.TimeoutExpired, OSError): + return None + m = re.search(r"Number of hits: (\d+)", out) + if not m or int(m.group(1)) == 0 or not os.path.exists(dump): + if "Connection refused" in out or "Connecting...error" in out or "Unable to" in out: + return None + continue + raw = open(dump, "rb").read() + args = ["yaz-marcdump", "-o", "marcxml"] + if len(raw) > 9 and raw[9:10] == b" ": # MARC-8 -> UTF-8 + args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"] + res = subprocess.run(args + [dump], capture_output=True, text=True, timeout=10) + if res.returncode == 0 and " 20000: + return self._send(413, {"error": "too large"}) + payload = json.loads(self.rfile.read(length) or b"{}") + ops = {"/api/add": op_add, "/api/manual": op_manual, "/api/undo": op_undo} + fn = ops.get(self.path) + if not fn: + return self._send(404, {"error": "not found"}) + code, body = fn(payload) + self._send(code, body) + except KohaError as e: + msg = ("Koha is still starting up - try again in a minute." if e.status == 503 + else "Koha refused that: %s" % e.body[:160]) + self._send(502, {"status": "error", "message": msg}) + except (ValueError, ET.ParseError): + self._send(400, {"status": "invalid", "message": "Bad request."}) + except Exception as e: # keep the scanner loop alive whatever happens + self._send(500, {"status": "error", "message": "Unexpected error: %s" % e}) + + +if __name__ == "__main__": + if not PASSWORD: + raise SystemExit("ADMIN_PASSWORD is required") + os.makedirs(DATA_DIR, exist_ok=True) + print("quick add listening on :%d sources=%s" % (PORT, SOURCES), flush=True) + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/library/quickadd/certs/.gitkeep b/library/quickadd/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/library/quickadd/index.html b/library/quickadd/index.html new file mode 100644 index 0000000..3da29d7 --- /dev/null +++ b/library/quickadd/index.html @@ -0,0 +1,263 @@ + + + + + +Add to library + + + +
+
+

Add to library

+ +
+ +
+ +
+ + + +
+
+
+ +

Added this session

+
    + + +

    Earlier

    +
      +
      + + + + From 24c95c91f24a7957afc19441bbbc26e71b44760c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:29:22 +0000 Subject: [PATCH 08/14] Add Library Catalog addon, lib/docker.sh and tests - lib/docker.sh: shared Docker/Compose tooling (on-demand install via apt or get.docker.com, compose wrapper, container state/health, HTTP wait) - menus/addon_library.sh: install/update/uninstall the Koha + Quick Add stack from library/ to /opt/kiosk-library, add the catalogue as a kiosk page (duration 0), choose kiosk-only vs LAN access, show addresses and login - wire into install.sh menu, complete_uninstall and clone_settings - tests/addon_library.test.sh: 41 checks against a stub sudo/docker and a fixture stack (no root, Docker or network needed) Note: the 'network access' choice relies on a BIND_ADDR setting in the library stack, added in a follow-up commit. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- install.sh | 14 +- lib/config.sh | 5 + lib/docker.sh | 106 ++++++++++++ menus/addon_library.sh | 319 ++++++++++++++++++++++++++++++++++++ menus/clone_settings.sh | 8 + menus/complete_uninstall.sh | 4 +- tests/addon_library.test.sh | 203 +++++++++++++++++++++++ 7 files changed, 655 insertions(+), 4 deletions(-) create mode 100644 lib/docker.sh create mode 100644 menus/addon_library.sh create mode 100755 tests/addon_library.test.sh diff --git a/install.sh b/install.sh index 1ac658a..9026446 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,11 @@ # Display/Lockout, the webui/ Node app installed as a systemd # service; no login of its own, put it behind your own reverse proxy # with Authelia forward-auth if it needs to be reachable beyond a -# trusted LAN - see the file header). +# trusted LAN - see the file header), Library Catalog (menus/ +# addon_library.sh - a self-hosted Koha library catalogue in Docker with +# Z39.50 lookup, label printing and a scan-to-add page, shown on the +# kiosk as a public search terminal; installs Docker on demand via +# lib/docker.sh). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network), Electron Maintenance (menus/advanced_electron.sh - # manual update, fix blank screen), Factory Reset @@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh" source "$SCRIPT_DIR/lib/config.sh" # shellcheck source=lib/electron.sh source "$SCRIPT_DIR/lib/electron.sh" +# shellcheck source=lib/docker.sh +source "$SCRIPT_DIR/lib/docker.sh" # shellcheck source=menus/sites.sh source "$SCRIPT_DIR/menus/sites.sh" # shellcheck source=menus/display.sh @@ -97,6 +103,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" # shellcheck source=menus/addon_webui.sh source "$SCRIPT_DIR/menus/addon_webui.sh" +# shellcheck source=menus/addon_library.sh +source "$SCRIPT_DIR/menus/addon_library.sh" # shellcheck source=menus/advanced_electron.sh source "$SCRIPT_DIR/menus/advanced_electron.sh" # shellcheck source=menus/advanced_upgrade.sh @@ -179,8 +187,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index cf3d7d1..464e13e 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -42,6 +42,11 @@ # other addon's own scripts. : "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}" +# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker +# stack is deployed here from this checkout's library/ directory. Root-owned +# on purpose - it holds the generated database/admin passwords in .env. +: "${LIBRARY_DIR:=/opt/kiosk-library}" + # Offline install bundle - only present on systems installed from an ISO # built with `iso/build-iso.sh --offline`: a local apt repository (every # package provisioning and the CUPS addon install) plus prebuilt diff --git a/lib/docker.sh b/lib/docker.sh new file mode 100644 index 0000000..93fe831 --- /dev/null +++ b/lib/docker.sh @@ -0,0 +1,106 @@ +#!/bin/bash +################################################################################ +# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a +# containerised service (the Library Catalog addon is the first). +# +# Everything here goes through `sudo docker ...`: the kiosk admin runs +# install.sh as a regular user and is deliberately NOT put in the `docker` +# group (docker-group membership is root-equivalent). +# +# Depends on: lib/menu.sh (log_* helpers) being sourced first. +################################################################################ + +# Overridable so tests can substitute stubs for the real binaries. +: "${DOCKER_BIN:=docker}" + +docker_is_installed() { + command -v "$DOCKER_BIN" >/dev/null 2>&1 \ + && sudo "$DOCKER_BIN" compose version >/dev/null 2>&1 +} + +docker_daemon_running() { + sudo "$DOCKER_BIN" info >/dev/null 2>&1 +} + +# Install Docker Engine + the Compose plugin if they are missing. +# +# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2): +# preferred because it also works through the offline apt bundle's mirror and +# gets security updates with everything else. On releases without +# docker-compose-v2 (22.04), fall back to Docker's official install script. +docker_ensure_installed() { + if docker_is_installed; then + docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true + log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed" + return 0 + fi + + log_info "Installing Docker..." + if ! sudo apt-get update -qq; then + log_error "apt update failed - check the network/package sources and try again" + return 1 + fi + if apt-cache show docker-compose-v2 >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \ + || { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; } + else + log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script" + have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; } + local script + script=$(mktemp) + if ! curl -fsSL https://get.docker.com -o "$script"; then + rm -f "$script" + log_error "Could not download Docker's install script" + return 1 + fi + sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; } + rm -f "$script" + fi + + sudo systemctl enable --now docker >/dev/null 2>&1 || true + if ! docker_is_installed || ! docker_daemon_running; then + log_error "Docker was installed but is not working - check: sudo systemctl status docker" + return 1 + fi + log_success "Docker installed" +} + +# docker_compose DIR args... -> sudo docker compose --project-directory DIR args... +docker_compose() { + local dir="$1"; shift + sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@" +} + +# docker_container_state NAME -> running | exited | restarting | ... | missing +docker_container_state() { + sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing" +} + +# docker_container_health NAME -> healthy | unhealthy | starting | none | missing +docker_container_health() { + sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \ + || echo "missing" +} + +# docker_wait_http URL [TIMEOUT_SECONDS] +# Polls until URL answers 2xx/3xx; returns 1 on timeout. +docker_wait_http() { + local url="$1" timeout="${2:-300}" waited=0 + while (( waited < timeout )); do + if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then + return 0 + fi + sleep 5 + waited=$((waited + 5)) + done + return 1 +} + +# docker_free_port START -> prints the first TCP port >= START nothing listens on. +docker_free_port() { + local port="$1" + while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do + port=$((port + 1)) + done + echo "$port" +} diff --git a/menus/addon_library.sh b/menus/addon_library.sh new file mode 100644 index 0000000..fbe9b66 --- /dev/null +++ b/menus/addon_library.sh @@ -0,0 +1,319 @@ +#!/bin/bash +################################################################################ +# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library +# catalogue (Koha) running in Docker, shown on the kiosk as a public +# search terminal. +# +# What gets installed (all from this checkout's library/ directory, deployed to +# $LIBRARY_DIR - see library/README.md for the stack itself): +# - Koha: the catalogue ("OPAC", public search, no login) and the staff +# interface (cataloguing, Z39.50 lookup, label printing). +# - Quick Add: a one-box "scan an ISBN, the book is added" page. +# - MariaDB. +# Docker itself is installed on demand (lib/docker.sh). +# +# On the kiosk the catalogue is added as one of the Sites (tabs) with +# duration 0 (stay put - it's a search terminal, not a rotating display). +# Staff and Quick Add are meant for the librarian's own device; whether they +# are reachable from the LAN or only from this kiosk is the "Network access" +# choice below. NOTE: Docker publishes ports straight through iptables and +# bypasses ufw, so "this kiosk only" is enforced by binding the published +# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules. +# +# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first. +################################################################################ + +LIBRARY_CATALOGUE_TAB_NAME="Library catalogue" + +################################################################################ +# Detection / state +################################################################################ + +library_env_get() { + sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' +} + +library_is_installed() { + [[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1 + [[ "$(docker_container_state library-koha)" != "missing" ]] +} + +library_is_running() { + [[ "$(docker_container_state library-koha)" == "running" ]] +} + +library_catalogue_url() { + echo "http://localhost:$(library_env_get OPAC_PORT)/" +} + +# True when the kiosk's page list already shows the catalogue. +library_tab_exists() { + local url idx + url="$(library_catalogue_url)" + for idx in "${!URLS[@]}"; do + [[ "${URLS[$idx]}" == "$url" ]] && return 0 + done + return 1 +} + +addon_library_status() { + if ! library_is_installed; then + echo "Library Catalog: not installed" + return + fi + local state health ip + state="$(docker_container_state library-koha)" + health="$(docker_container_health library-koha)" + ip="$(get_ip_address)" + echo "Library Catalog: ${state}${health:+ ($health)}" + echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)" + if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then + echo " Network: this kiosk only" + else + echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/" + fi +} + +addon_library_menu_builder() { + if library_is_installed; then + load_existing_config + local tab_label="Show the catalogue on the kiosk" + library_tab_exists && tab_label="Stop showing the catalogue on the kiosk" + MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall") + MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall) + else + MENU_LABELS=("Install Library Catalog") + MENU_HANDLERS=(action_library_install) + fi +} + +addon_library_menu() { + run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status +} + +################################################################################ +# Helpers +################################################################################ + +# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the +# deployed .env (passwords) even if a stray one exists in the checkout. +library_sync_files() { + local src="${SCRIPT_DIR}/library" + if [[ ! -f "$src/docker-compose.yml" ]]; then + log_error "library/ not found in this checkout ($src) - re-clone or git pull" + return 1 + fi + sudo mkdir -p "$LIBRARY_DIR" || return 1 + tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1 + sudo chmod 755 "$LIBRARY_DIR/install.sh" +} + +library_add_tab() { + load_existing_config + if library_tab_exists; then + log_info "The catalogue is already one of the kiosk's pages." + return 0 + fi + URLS+=("$(library_catalogue_url)") + DURS+=("0") + USERS+=("") + PASSES+=("") + NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME") + save_config +} + +# Drop every page that points at the catalogue's local address. +library_remove_tab() { + load_existing_config + local url idx + url="$(library_catalogue_url)" + local -a nu=() nd=() nus=() np=() nn=() + for idx in "${!URLS[@]}"; do + [[ "${URLS[$idx]}" == "$url" ]] && continue + nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}") + np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}") + done + if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then + return 0 + fi + URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}") + HOME_TAB_INDEX=-1 + save_config +} + +# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers. +library_set_bind() { + local addr="$1" + if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then + sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env" + else + echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null + fi + docker_compose "$LIBRARY_DIR" up -d +} + +library_warn_if_low_memory() { + local total_mb + total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0) + if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then + log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow." + fi +} + +################################################################################ +# Actions +################################################################################ + +action_library_install() { + echo + echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label" + echo "printing and a scan-to-add page. It needs Docker (installed for you if" + echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes" + echo "5-10 minutes." + echo + library_warn_if_low_memory + + local library_name + library_name=$(ask_text "Library name (shown on the catalogue)" "My Library") + [[ -z "$library_name" ]] && library_name="My Library" + + local bind="0.0.0.0" + echo + echo "Who should be able to open the catalogue, staff interface and Quick Add" + echo "page besides this kiosk?" + echo " - Yes: any device on your network (use it from your own laptop/phone;" + echo " the staff pages are protected by a generated password)." + echo " - No: only this kiosk. Safest, but you can only manage the library here." + if ! ask_yes_no "Allow other devices on the network?" "y"; then + bind="127.0.0.1" + fi + + docker_ensure_installed || { pause; return 1; } + library_sync_files || { pause; return 1; } + + echo + log_info "Building and starting the stack - this takes a while on the first run..." + if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then + log_error "The library stack failed to start - see: sudo docker logs library-koha" + pause + return 1 + fi + + if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then + library_add_tab + fi + echo + log_success "Library Catalog installed" + pause +} + +action_library_show_access() { + local ip user pass + ip="$(get_ip_address)" + user="$(library_env_get ADMIN_USER)" + pass="$(library_env_get ADMIN_PASSWORD)" + echo + if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then + ip="localhost" + echo "(Network access is limited to this kiosk.)" + fi + echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/" + echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/" + echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/" + echo " Login (staff and add books): ${user} / ${pass}" + echo + echo " Passwords live in $LIBRARY_DIR/.env (root only)." + pause +} + +action_library_toggle_tab() { + load_existing_config + if library_tab_exists; then + library_remove_tab + else + library_add_tab + fi + pause +} + +action_library_network() { + local current + current="$(library_env_get BIND_ADDR)" + echo + echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")" + echo + if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then + library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect" + else + library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now" + fi + pause +} + +action_library_start() { + docker_compose "$LIBRARY_DIR" start && log_success "Started" + pause +} + +action_library_stop() { + docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)" + pause +} + +action_library_update() { + echo + echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept." + if ! ask_yes_no "Update now?" "y"; then + return 0 + fi + docker_ensure_installed || { pause; return 1; } + library_sync_files || { pause; return 1; } + if sudo "$LIBRARY_DIR/install.sh" --update; then + log_success "Updated" + else + log_error "Update failed - see: sudo docker logs library-koha" + fi + pause +} + +# library_do_uninstall [purge] +# no argument: stop and remove the containers but KEEP the data volumes and +# $LIBRARY_DIR/.env - the database password is baked into the +# database volume, so a later reinstall needs the same .env. +# purge: also delete the data (all books!), the images and $LIBRARY_DIR. +library_do_uninstall() { + local mode="${1:-}" + library_remove_tab + if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then + if [[ "$mode" == "purge" ]]; then + docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true + else + docker_compose "$LIBRARY_DIR" down 2>/dev/null || true + fi + fi + if [[ "$mode" == "purge" ]]; then + sudo rm -rf "$LIBRARY_DIR" + log_success "Library Catalog and all its data removed" + else + log_success "Library Catalog stopped and removed (your books are kept)" + fi +} + +action_library_uninstall() { + echo + echo "This stops and removes the catalogue containers." + if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then + local confirm + confirm=$(ask_text "Type DELETE to confirm" "") + if [[ "$confirm" != "DELETE" ]]; then + echo "Cancelled" + pause + return 0 + fi + library_do_uninstall purge + else + library_do_uninstall + echo "Your books are kept; install again from this menu to bring them back." + fi + pause +} diff --git a/menus/clone_settings.sh b/menus/clone_settings.sh index 9994193..dc41bf2 100644 --- a/menus/clone_settings.sh +++ b/menus/clone_settings.sh @@ -65,6 +65,7 @@ clone_detect_addons() { command -v netbird &>/dev/null && addons+=("netbird") baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom") webui_is_installed 2>/dev/null && addons+=("webui") + library_is_installed 2>/dev/null && addons+=("library") if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local authelia_url @@ -243,6 +244,13 @@ action_apply_clone_settings() { fi ;; asterisk_intercom) echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;; + library) + if library_is_installed; then + echo " [x] Library Catalog - already installed (the books themselves are not cloned)" + else + echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)" + missing_any=true + fi ;; authelia) echo " [!] Authelia - needs a fresh login on this device" ;; *) diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index c0f3747..beb5037 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -7,7 +7,7 @@ # (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall, # tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall, # squeezelite_do_uninstall, asterisk_intercom_do_uninstall, -# webui_do_uninstall, power_schedule_do_remove_all, +# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all, # emergency_hotspot_do_disable, time_sync_do_reset) instead of # re-implementing removal logic for each addon a second time here - if an # addon's uninstall logic changes, this picks it up automatically. Only @@ -59,6 +59,7 @@ action_complete_uninstall() { echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)" echo " • Asterisk Intercom (Baresip)" echo " • Web UI" + echo " • Library Catalog (Koha) and ALL the books in it" echo " • LightDM and Openbox" echo " • All kiosk schedules and services" echo " • Emergency hotspot configuration" @@ -90,6 +91,7 @@ action_complete_uninstall() { squeezelite_do_uninstall asterisk_intercom_do_uninstall purge webui_do_uninstall + library_do_uninstall purge echo "[3/12] Removing schedules, emergency hotspot and time server setting..." power_schedule_do_remove_all diff --git a/tests/addon_library.test.sh b/tests/addon_library.test.sh new file mode 100755 index 0000000..b2f6a76 --- /dev/null +++ b/tests/addon_library.test.sh @@ -0,0 +1,203 @@ +#!/bin/bash +################################################################################ +# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh +# +# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and +# a fixture stack standing in for library/, so it needs no root, no Docker and +# no network - and cannot touch a real kiosk. (The real stack is exercised by +# library/install.sh itself.) +# +# bash tests/addon_library.test.sh +################################################################################ +set -u + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +PASS=0 FAIL=0 +ok() { PASS=$((PASS + 1)); echo " ok - $1"; } +bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; } +check() { # check "description" command... + local d="$1"; shift + if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi +} + +# ---- stubs -------------------------------------------------------------------- +mkdir -p "$T/bin" +cat > "$T/bin/sudo" << 'EOF' +#!/bin/bash +# Stub sudo: drop "-u USER" and run the command as-is. +if [[ "$1" == "-u" ]]; then shift 2; fi +exec "$@" +EOF +cat > "$T/bin/docker" << 'EOF' +#!/bin/bash +# Stub docker: logs every call; container state comes from $STUB_STATE. +echo "$*" >> "$STUB_LOG" +case "$1" in + --version) echo "Docker version 99.0.0, build stub"; exit 0 ;; + info) exit 0 ;; + compose) + [[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; } + exit 0 ;; + inspect) + st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)" + [[ "$st" == "missing" ]] && exit 1 + case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac + exit 0 ;; +esac +exit 0 +EOF +chmod +x "$T/bin/sudo" "$T/bin/docker" +export PATH="$T/bin:$PATH" +export STUB_LOG="$T/docker.log" STUB_STATE="$T/state" +echo missing > "$STUB_STATE"; : > "$STUB_LOG" + +# ---- fixture "checkout" with a stub library stack ---------------------------- +FIX="$T/checkout" +mkdir -p "$FIX/library/koha/certs" +echo "name: library" > "$FIX/library/docker-compose.yml" +echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env" +echo "cert" > "$FIX/library/koha/certs/proxy.crt" +echo "koha-file" > "$FIX/library/koha/Dockerfile" +cat > "$FIX/library/install.sh" << 'EOF' +#!/bin/bash +# Stub of library/install.sh: records how it was called and writes a .env. +HERE="$(cd "$(dirname "$0")" && pwd)" +echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG" +[[ "${1:-}" == "--update" ]] && exit 0 +cat > "$HERE/.env" << ENV +LIBRARY_NAME="${LIBRARY_NAME:-}" +OPAC_PORT=8080 +STAFF_PORT=8081 +QUICKADD_PORT=8082 +ADMIN_USER=admin +ADMIN_PASSWORD=s3cretPASS +BIND_ADDR=${BIND_ADDR:-0.0.0.0} +ENV +echo running > "$STUB_STATE" +EOF + +# ---- environment the addon expects ------------------------------------------- +export KIOSK_USER="$(id -un)" +export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json" +export LIBRARY_DIR="$T/opt-library" +SCRIPT_DIR="$FIX" +mkdir -p "$KIOSK_DIR" +cat > "$CONFIG_PATH" << 'EOF' +{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}], + "homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"} +EOF + +# shellcheck disable=SC1090 +source "$REPO/lib/menu.sh" +source "$REPO/lib/config.sh" +source "$REPO/lib/docker.sh" +source "$REPO/menus/addon_library.sh" +export KIOSK_UNATTENDED=0 +docker_ensure_installed() { return 0; } # the stub stands in for a working Docker + +tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; } + +# ============================================================================ +echo "== lib/docker.sh" +check "docker_is_installed sees the stub" docker_is_installed +check "container state: missing" test "$(docker_container_state library-koha)" = missing +echo running > "$STUB_STATE" +check "container state: running" test "$(docker_container_state library-koha)" = running +check "container health: healthy" test "$(docker_container_health library-koha)" = healthy +echo missing > "$STUB_STATE" +check "docker_free_port returns a number" test -n "$(docker_free_port 20000)" + +# ============================================================================ +echo "== sync files" +mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env" +library_sync_files +check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile" +check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV +check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt" +rm -rf "$LIBRARY_DIR" + +# ============================================================================ +echo "== not installed" +if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi +addon_library_menu_builder +check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog" +check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed" + +# ============================================================================ +echo "== install action (kiosk-only network, show on kiosk)" +# answers: library name, allow-network=n, show-on-kiosk=y, for pause +printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1 +check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG" +check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG" +check "is_installed is now true" library_is_installed +check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs) +check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs) +check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me +addon_library_status > "$T/status.out" +check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out" + +# ============================================================================ +echo "== menu while installed" +addon_library_menu_builder +check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}" +check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}" + +echo "== tab toggling is idempotent" +library_add_tab >/dev/null 2>&1 +check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1 +printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it +check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0 +check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs) +addon_library_menu_builder +check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}" +printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back +check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1 + +# ============================================================================ +echo "== network access" +: > "$STUB_LOG" +printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network +check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0 +check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG" +printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only +check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1 + +# ============================================================================ +echo "== access screen" +printf '\n' | action_library_show_access > "$T/access.out" 2>&1 +check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out" +check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out" + +# ============================================================================ +echo "== update" +: > "$STUB_LOG" +printf 'y\n\n' | action_library_update >/dev/null 2>&1 +check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG" +check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS + +# ============================================================================ +echo "== uninstall keeps the books by default" +: > "$STUB_LOG" +printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1 +check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG" +check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'" +check "install dir and .env kept" test -f "$LIBRARY_DIR/.env" +check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0 + +echo "== purge needs the typed confirmation" +printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1 +check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env" +: > "$STUB_LOG" +library_add_tab >/dev/null 2>&1 +printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1 +check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG" +check "install dir removed" test ! -e "$LIBRARY_DIR" +check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0 +check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs) + +echo +echo "passed: $PASS failed: $FAIL" +[[ "$FAIL" -eq 0 ]] From e08133dfc078113d6f0dd99014164f1271457f49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:30:41 +0000 Subject: [PATCH 09/14] library: BIND_ADDR for loopback-only mode, real readiness check - BIND_ADDR (default 0.0.0.0) on every published port; 127.0.0.1 keeps the stack reachable from the host only (Docker bypasses ufw, so this is the actual control the kiosk addon's 'this kiosk only' choice uses) - Koha serves its web-installer page with HTTP 200 before the database exists, so 'port answers' was not 'ready': the entrypoint now writes /run/koha-ready, the healthcheck requires it, and install.sh waits for healthy + the quick-add health endpoint Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/docker-compose.yml | 6 +++--- library/install.sh | 11 ++++++++--- library/koha/Dockerfile | 4 ++-- library/koha/entrypoint.sh | 5 +++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 0b6c1ec..df54865 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -38,8 +38,8 @@ services: KOHA_ADMIN_PASS: ${ADMIN_PASSWORD} KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library} ports: - - "${OPAC_PORT:-8080}:80" - - "${STAFF_PORT:-8081}:8081" + - "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80" + - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081" volumes: - koha-data:/var/lib/koha @@ -63,7 +63,7 @@ services: STAFF_PORT: ${STAFF_PORT:-8081} QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary} ports: - - "${QUICKADD_PORT:-8082}:8082" + - "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082" volumes: - quickadd-data:/data diff --git a/library/install.sh b/library/install.sh index a2431b0..a12cc0e 100755 --- a/library/install.sh +++ b/library/install.sh @@ -100,6 +100,7 @@ TZ=${TZ_NOW} OPAC_PORT=$(free_port "${OPAC_PORT:-8080}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}") +BIND_ADDR=${BIND_ADDR:-0.0.0.0} ADMIN_USER=admin ADMIN_PASSWORD=$(rand 16) DB_ROOT_PASSWORD=$(rand 32) @@ -114,6 +115,9 @@ else if ! grep -q '^QUICKADD_PORT=' .env; then echo "QUICKADD_PORT=$(free_port 8082)" >> .env fi + if ! grep -q '^BIND_ADDR=' .env; then + echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env + fi fi if [ -n "${EXTRA_CA:-}" ]; then @@ -131,9 +135,10 @@ with_retry dc up -d # --- Wait until the site answers --------------------------------------------------- # shellcheck disable=SC1091 . ./.env -c_info "First start installs the database - waiting for the staff interface..." -for i in $(seq 1 120); do - if curl -fsS -o /dev/null "http://127.0.0.1:$STAFF_PORT/" 2>/dev/null; then READY=1; break; fi +c_info "First start installs the database - waiting until Koha reports healthy..." +for i in $(seq 1 180); do + health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)" + if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8082}/healthz" 2>/dev/null; then READY=1; break; fi # One restart is tolerated (Docker's restart policy); a crash loop is not. if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1 diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile index 50555b5..8a615f4 100644 --- a/library/koha/Dockerfile +++ b/library/koha/Dockerfile @@ -35,6 +35,6 @@ RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init EXPOSE 80 8081 VOLUME ["/var/lib/koha"] -HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=5 \ - CMD curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1 +HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \ + CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1 ENTRYPOINT ["/opt/koha-init/entrypoint.sh"] diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index 99dda5e..e318eab 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -15,6 +15,10 @@ export TZ="${TZ:-UTC}" log() { echo "[koha-init] $*"; } +# Marker the container healthcheck waits for. Before the database exists Koha +# serves its web-installer page with HTTP 200, so "port answers" is not "ready". +rm -f /run/koha-ready + # --- 1. memcached (Koha caches sysprefs and sessions in it) ----------------- memcached -u memcache -m 64 -d cron @@ -105,5 +109,6 @@ apache2ctl -k stop >/dev/null 2>&1 || true for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done rm -f /var/run/apache2/apache2.pid +touch /run/koha-ready log "ready - library catalogue on :80, staff interface on :$STAFF_PORT" exec apache2ctl -D FOREGROUND From aa7950aa9ee88e12351ab88945c433d4bd1eada9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:33:58 +0000 Subject: [PATCH 10/14] library: shelf picker in Quick Add, docs, status/ss fixes; kiosk README entry - Quick Add: choose the shelf/room (Koha LOC values) once; every scanned item gets it and the choice is remembered; invalid values are ignored - install.sh --status shows localhost in loopback mode; port probing works without ss - library/README.md; kiosk Readme.md add-on section and licenses Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- Readme.md | 15 ++++ library/README.md | 142 ++++++++++++++++++++++++++++++++++++ library/install.sh | 12 ++- library/quickadd/app.py | 38 ++++++++-- library/quickadd/index.html | 27 +++++-- 5 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 library/README.md diff --git a/Readme.md b/Readme.md index fa8839b..d80533f 100644 --- a/Readme.md +++ b/Readme.md @@ -427,6 +427,15 @@ Both can be used at the same time — they serve different purposes: - **IPP Everywhere support** - **PDF printing** via cups-pdf +### Library Catalog (Koha) +- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in +- **Add books by scanning** - a Quick Add page: scan or type an ISBN and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in +- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines +- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library` +- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access +- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything +- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests + ### Remote Access - **VNC** - x11vnc for remote desktop - **WireGuard VPN** - Config paste support @@ -1342,7 +1351,13 @@ This project bundles or installs several open-source components under their resp - **Repository:** https://github.com/netbirdio/netbird - **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE +### Koha (Library Catalog add-on) +- **License:** GPL-3.0-or-later +- **Source:** https://koha-community.org +- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine +### MariaDB and Docker (Library Catalog add-on) +- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com) --- diff --git a/library/README.md b/library/README.md new file mode 100644 index 0000000..35a4bac --- /dev/null +++ b/library/README.md @@ -0,0 +1,142 @@ +# Home library: Koha + Quick Add (Docker) + +A self-hosted library catalogue you can set up in one command: + +```bash +sudo ./install.sh +``` + +It installs Docker if it is missing, generates the passwords, builds the images, +starts everything and prints the addresses and your login. Running it again is safe; +it never overwrites your `.env` (passwords) or your data. + +| What | Address | Login | +|---|---|---| +| **Catalogue** (public search, no login) | `http://:8080` | none | +| **Staff interface** (cataloguing, labels, Z39.50) | `http://:8081` | `admin` and the generated password | +| **Add books** (scan an ISBN) | `http://:8082` | same login | + +Ports move up to the next free number if one is taken; the final addresses are printed +at the end and by `sudo ./install.sh --status`. + +## What it is made of + +* **Koha**: the open-source library system, built from the Koha project's own Debian + packages (no third-party Koha images). Installed and configured automatically; there is + no web installer to click through. +* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added". +* **MariaDB**: the database. + +Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes. + +## Adding books + +Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode +scanner works as a keyboard, so just scan. + +1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot + be reached (many networks block port 210), **Open Library** over HTTPS is used instead. +2. If you already have the title, you are asked whether to add **another copy**. +3. Otherwise the record is added with the **next barcode number**, the Dewey (or LC) call + number from the record, the item type chosen at the top (Book, Movie, Music, Game) and + the shelf you picked. +4. Nothing found? A short form lets you type the title and it is added anyway. +5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers + are never reused, because a label may already be printed. + +DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you +own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title +for many of them. + +The page beeps: high for added, double for a duplicate, low for not found. You can scan a +stack of books without looking at the screen. + +Phone camera scanning is not built in (browsers only allow the camera on HTTPS or +`localhost`). A scanner-app that "types" the code into the box works. + +## Printing labels + +Quick Add shows the barcode range for the session ("Barcodes 12 to 27"). In the staff +interface open **Cataloging → Label creator → Print barcode range**, enter those numbers, +and pick a template and layout: + +| Template | Sheet | +|---|---| +| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode | +| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only | +| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines | + +| Layout | Prints | +|---|---| +| Spine + barcode | call number (split over lines) with the barcode below | +| Spine - call number | call number only | +| Barcode only | barcode | + +Print at 100% scale (not "fit to page") so the labels line up. + +## Finding the books + +The public catalogue searches title, author, subject and ISBN, and shows each copy's +availability, call number and **shelving location**. + +On the Add books page, pick the **Shelf / room** before you scan; every book you add +gets it, and the page remembers your choice. Change it whenever you move to another shelf. +The list comes from the staff interface (*Administration → Authorized values → LOC*), which +starts with a few rooms such as Fiction, General stacks and Reference. Add your own there, +for example "Living room - bookcase 2". + +## Common tasks + +```bash +sudo ./install.sh --status # addresses, login, container health +sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept) +sudo ./install.sh --stop # stop everything +sudo ./install.sh --start # start again +sudo docker logs -f library-koha +``` + +Settings live in `.env` (created on first run, mode 600). Useful ones: + +| Setting | Meaning | +|---|---| +| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access | +| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports | +| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries | +| `LIBRARY_NAME` | name shown on the catalogue (first install only) | + +After editing `.env` run `sudo docker compose up -d`. + +## Behind a proxy or on a locked-down network + +* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`. +* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause. +* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically. + +## Backups + +Your books live in two Docker volumes: `library_db-data` (the catalogue) and +`library_koha-data` (search index, uploads). Back up the database with: + +```bash +sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql +``` + +The search index is rebuilt automatically if it is lost. + +## Security notes + +* The staff interface and Quick Add are protected by the generated password, over plain HTTP. + Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with + HTTPS in front. +* Quick Add uses the staff `admin` login to talk to Koha's API. + +## Layout + +``` +install.sh one-command setup and --status/--update/--stop/--start +docker-compose.yml db + koha + quickadd +koha/ Dockerfile (Koha from the project's Debian repo), entrypoint, + bootstrap.pl (headless install), seed/ (item types, Z39.50 + servers, label templates/layouts) +quickadd/ the scan-to-add service (Python standard library + yaz) +``` diff --git a/library/install.sh b/library/install.sh index a12cc0e..2a312a5 100755 --- a/library/install.sh +++ b/library/install.sh @@ -41,7 +41,13 @@ ensure_docker() { docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; } } -port_in_use() { ss -tlnH "sport = :$1" 2>/dev/null | grep -q .; } +port_in_use() { + if command -v ss >/dev/null 2>&1; then + ss -tlnH "sport = :$1" 2>/dev/null | grep -q . + else # minimal systems without iproute2 + (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null + fi +} free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; } rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; } lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } @@ -64,6 +70,10 @@ show_status() { # shellcheck disable=SC1091 . ./.env local ip; ip="$(lan_ip)"; ip="${ip:-localhost}" + if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then + ip="localhost" + echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)" + fi dc ps echo echo " Catalogue (public): http://$ip:$OPAC_PORT" diff --git a/library/quickadd/app.py b/library/quickadd/app.py index 917c19c..cb0cdca 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -254,6 +254,26 @@ def koha(method, path, body=None, ctype="application/json", headers=None, query= raise KohaError(503, str(e)) +_LOC_CACHE = {"at": 0.0, "value": []} + + +def get_locations(): + """Shelving locations (Koha authorised-value category LOC), cached for 30 s.""" + if time.time() - _LOC_CACHE["at"] < 30: + return _LOC_CACHE["value"] + try: + vals = koha("GET", "/authorised_value_categories/LOC/authorised_values") or [] + _LOC_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals] + _LOC_CACHE["at"] = time.time() + except KohaError: + pass + return _LOC_CACHE["value"] + + +def valid_location(code): + return code if code and any(l["value"] == code for l in get_locations()) else None + + def find_existing(code): variants = {code} if len(code) == 13: @@ -296,7 +316,7 @@ def save_counter(n): pass -def add_item(biblio_id, itype, callnum): +def add_item(biblio_id, itype, callnum, location=None): """Create an item with the next free barcode (retrying on collisions).""" start = next_barcode() for attempt in range(10): @@ -304,7 +324,8 @@ def add_item(biblio_id, itype, callnum): try: item = koha("POST", "/biblios/%s/items" % biblio_id, { "external_id": bc, "item_type_id": itype, "home_library_id": LIBRARY, - "holding_library_id": LIBRARY, "callnumber": callnum or None}) + "holding_library_id": LIBRARY, "callnumber": callnum or None, + "location": location}) save_counter(start + attempt) return item except KohaError as e: @@ -361,6 +382,7 @@ def lookup(code): def op_add(payload): code = clean_code(payload.get("code")) itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" + location = valid_location(payload.get("location")) if not code: return 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."} with LOCK: @@ -369,7 +391,7 @@ def op_add(payload): return 200, dict(existing, status="duplicate", code=code) if existing: # add another copy of a known title with LOCK: - item = add_item(existing["biblio_id"], itype, None) + item = add_item(existing["biblio_id"], itype, None, location) entry = entry_for(code, existing["title"], existing["author"], item, existing["biblio_id"], "extra copy") remember(entry) return 200, dict(entry, status="added") @@ -383,7 +405,7 @@ def op_add(payload): with LOCK: bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", {"x-confirm-not-duplicate": "1"}) - item = add_item(bib["id"], itype, call_number(rec, info["author"])) + item = add_item(bib["id"], itype, call_number(rec, info["author"]), location) entry = entry_for(code, info["title"], info["author"], item, bib["id"], source) remember(entry) return 200, dict(entry, status="added") @@ -395,13 +417,14 @@ def op_manual(payload): return 400, {"status": "invalid", "message": "A title is required."} code = clean_code(payload.get("code")) itype = payload.get("type") if payload.get("type") in ITEM_TYPES else "BOOK" + location = valid_location(payload.get("location")) rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120], (payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120]) set_item_type(rec, itype) with LOCK: bib = koha("POST", "/biblios", ET.tostring(rec, encoding="utf-8"), "application/marcxml+xml", {"x-confirm-not-duplicate": "1"}) - item = add_item(bib["id"], itype, None) + item = add_item(bib["id"], itype, None, location) entry = entry_for(code or "", title, payload.get("author") or "", item, bib["id"], "typed in") remember(entry) return 200, dict(entry, status="added") @@ -410,7 +433,8 @@ def op_manual(payload): def entry_for(code, title, author, item, biblio_id, source): return {"time": int(time.time()), "code": code, "title": title, "author": author, "barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "", - "item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source} + "item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source, + "location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")} def op_undo(payload): @@ -488,7 +512,7 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, INDEX, "text/html; charset=utf-8") if path == "/api/state": return self._send(200, {"recent": load_recent()[:100], "types": ITEM_TYPES, - "library": LIBRARY, "staff_port": STAFF_PORT, "opac_port": OPAC_PORT, + "library": LIBRARY, "locations": get_locations(), "staff_port": STAFF_PORT, "opac_port": OPAC_PORT, "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) self._send(404, {"error": "not found"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 3da29d7..da8b107 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -32,6 +32,10 @@ border-radius: 999px; background: var(--card); font-weight: 600; } .types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } .types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; } + .shelf { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; } + .shelf label { font-weight: 600; } + .shelf select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; + background: var(--card); color: var(--ink); max-width: 100%; } .scan { display: flex; gap: 8px; } .scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line); border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; } @@ -77,6 +81,10 @@
      +
      @@ -102,7 +110,7 @@