From 522a33b12796c88baee8c2d2f7462ea02b6382db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:38:09 +0000 Subject: [PATCH 1/2] library: one address - Add books inside the staff site, plus Find a book - Apache in the Koha container serves Quick Add at /add/ on the staff site (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves - Quick Add: relative API paths so it works at / and /add/; Add books / Find a book tabs; search by title, author or ISBN showing each copy's shelf and status (/api/search); Manage and Public catalogue links - staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05 stores it) linking to Add books - installer, kiosk add-on and READMEs advertise one main address; the public catalogue stays on its own port (Koha gives public and staff sites the same URL shapes, so they cannot share one address) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 15 ++++++-- library/docker-compose.yml | 6 +-- library/install.sh | 8 ++-- library/koha/bootstrap.pl | 13 +++++++ library/koha/entrypoint.sh | 14 +++++++ library/quickadd/app.py | 43 +++++++++++++++++++++ library/quickadd/index.html | 77 +++++++++++++++++++++++++++++++++---- menus/addon_library.sh | 8 ++-- tests/addon_library.test.sh | 5 ++- 9 files changed, 165 insertions(+), 24 deletions(-) diff --git a/library/README.md b/library/README.md index ee959be..c074576 100644 --- a/library/README.md +++ b/library/README.md @@ -12,12 +12,19 @@ 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 | +| **Your library**: add books, find a book, people, lending, labels, settings | `http://:8081` | `admin` and the generated password | +| **Add books** (scan an ISBN, or use the phone camera) | `http://:8081/add/` | same login (asked once more) | +| **Public catalogue** (search only, for a kiosk or visitors) | `http://:8080` | none | + +For home use you only need the first address: sign in to **Your library**, and use the **Add books** +button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the +shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The +public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and +staff sites the same URL shapes, so they cannot share one address; Add books can, and does.) 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`. +at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port +(`8082`), which is handy behind a proxy but not needed otherwise. ## What it is made of diff --git a/library/docker-compose.yml b/library/docker-compose.yml index cdf0424..40dd3ed 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -30,8 +30,11 @@ services: depends_on: db: condition: service_healthy + quickadd: + condition: service_started environment: TZ: ${TZ:-UTC} + KOHA_QUICKADD_URL: http://quickadd:8082 KOHA_DB_HOST: db KOHA_DB_PASS: ${DB_PASSWORD} KOHA_ADMIN_USER: ${ADMIN_USER:-admin} @@ -53,9 +56,6 @@ services: 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 diff --git a/library/install.sh b/library/install.sh index 60ce5ac..ebedaa2 100755 --- a/library/install.sh +++ b/library/install.sh @@ -79,10 +79,10 @@ show_status() { fi dc ps 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 " Your library: http://$ip:$STAFF_PORT" + echo " sign in here: add books at /add/ (or the 'Add books' button), find a book, lending, labels, people" + echo " Public catalogue: http://$ip:$OPAC_PORT (search only - for a kiosk or visitors)" + echo " Login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env; Add books asks once more with the same login)" echo } diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index d75b535..74f00b5 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -153,6 +153,19 @@ Koha::CirculationRules->set_rules({ }, }); +# ---- Staff home page: a "Library tools" block with a link to the Add books page ---- +# (Koha 26.05 stores these in the HTML customizations tool, not in a preference.) +{ + my $html = '

Library tools

'; + $dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number) + VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)}); + my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' ); + $dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang) + VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html ); +} + # ---- Preferences ---------------------------------------------------------- my %prefs = ( autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request) diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index a99f0ef..34d5bf0 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -56,6 +56,20 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then -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 + # One address: serve the Add books page (Quick Add) inside the staff site at /add/. + # (The staff and public catalogue sites cannot share one address: Koha gives both the + # same URL shapes. The staff site is the natural home for a home library.) + if [ -n "${KOHA_QUICKADD_URL:-}" ]; then + cat > /etc/koha/apache-quickadd.conf << APACHE + + RedirectMatch ^/add\$ /add/ + ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/ + ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/ + +APACHE + sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf" + fi + # 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). diff --git a/library/quickadd/app.py b/library/quickadd/app.py index b885c18..ec2d1d7 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -687,6 +687,42 @@ def op_assign(payload): "last": fmt_barcode(first + len(items) - 1, s)} +# ------------------------------------------------------------------- search +def item_status(it): + if it.get("lost_status"): + return "Lost" + if it.get("withdrawn"): + return "Withdrawn" + if it.get("checked_out_date"): + return "Checked out" + if it.get("damaged_status"): + return "Damaged" + return "On shelf" + + +def op_search(term): + """Find books by title, author or ISBN; each result lists its copies with shelf and status.""" + term = re.sub(r"[%_\\]", "", term or "").strip()[:80] + if len(term) < 2: + return {"results": []} + like = {"-like": "%" + term + "%"} + q = {"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]} + biblios = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "25", "_order_by": "title"}) or [] + if not biblios: + return {"results": []} + ids = [b["biblio_id"] for b in biblios] + items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "500"}) or [] + where = {l["value"]: l["label"] for l in get_locations()} + by_bib = {} + for it in items: + by_bib.setdefault(it["biblio_id"], []).append({ + "barcode": it.get("external_id"), "callnumber": it.get("callnumber") or "", + "location": where.get(it.get("location"), ""), "status": item_status(it)}) + return {"results": [{ + "biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "", + "year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]} + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -801,6 +837,13 @@ class Handler(BaseHTTPRequestHandler): "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) if path == "/api/settings": return self._send(200, settings_view()) + if path == "/api/search": + qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + try: + return self._send(200, op_search((qs.get("q") or [""])[0])) + except KohaError as e: + return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute." + if e.status == 503 else "Search failed: %s" % e.body[:120]}) self._send(404, {"error": "not found"}) def do_POST(self): diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 4cb7127..db94b4f 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -44,6 +44,20 @@ padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; } button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; } + nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; } + nav.tabs a.on { background: var(--accent); color: var(--accent-ink); } + nav.tabs a.ext::after { content: ' \2197'; font-size: .85em; } + .find-form { display: flex; gap: 8px; margin: 10px 0; } + .find-form input { flex: 1; font-size: 1.3rem; padding: 12px 14px; border: 2px solid var(--line); border-radius: 12px; + background: var(--card); color: var(--ink); min-width: 0; } + .hit { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; } + .hit .t { font-weight: 700; font-size: 1.05rem; overflow-wrap: anywhere; } + .hit a { color: var(--accent); } + .copy { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0 0; } + .chip { border-radius: 999px; padding: 1px 10px; font-size: .88rem; font-weight: 600; border: 1px solid var(--line); } + .chip.in { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); } + .chip.out { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); } .cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; } #status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); } .card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; @@ -85,12 +99,26 @@
-

Add to library

-
+ + +
+
diff --git a/menus/addon_library.sh b/menus/addon_library.sh index fbe9b66..2c9c053 100644 --- a/menus/addon_library.sh +++ b/menus/addon_library.sh @@ -217,10 +217,10 @@ action_library_show_access() { 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 " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/" + echo " Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/" + echo " Public catalogue (kiosk): http://${ip}:$(library_env_get OPAC_PORT)/" + echo " Login: ${user} / ${pass} (Add books asks once more with the same login)" echo echo " Passwords live in $LIBRARY_DIR/.env (root only)." pause diff --git a/tests/addon_library.test.sh b/tests/addon_library.test.sh index b2f6a76..6f87f97 100755 --- a/tests/addon_library.test.sh +++ b/tests/addon_library.test.sh @@ -168,8 +168,9 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR # ============================================================================ 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" +check "shows the login" grep -q 'admin / s3cretPASS' "$T/access.out" +check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out" +check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out" # ============================================================================ echo "== update" From 770a6a1dfb807d371e352e43282e0959f155c807 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:09:06 +0000 Subject: [PATCH 2/2] library: login for everything - public catalogue off, read-only guest for Find a book - the public catalogue is bound to the machine itself by default (OPAC_BIND) and, if ever exposed, requires a library-card login (OpacPublic=0) - a read-only guest login (VIEWER_USER/VIEWER_PASSWORD) in Quick Add: it may load the page, read state and search; every other endpoint is admin-only (403); the page shows only Find a book - the guest deliberately has NO Koha account: Koha's 'catalogue' permission, the only one that allows searching, also allows creating/editing label layouts and templates (tested: a guest created a layout), so it cannot be read-only - koha/ensure.pl (every start): staff-home 'Library tools' block, and removal of a guest account an earlier version created - kiosk add-on shows Find a book signed in as the guest; the admin login never goes into the kiosk config (tested, incl. a mutation check); old public-catalogue tabs are cleaned up - existing installs keep their current public-catalogue behaviour on update Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/docker-compose.yml | 5 ++- library/install.sh | 19 ++++++++++-- library/koha/Dockerfile | 2 +- library/koha/bootstrap.pl | 17 ++-------- library/koha/ensure.pl | 35 +++++++++++++++++++++ library/koha/entrypoint.sh | 5 +++ library/quickadd/app.py | 50 ++++++++++++++++++++---------- library/quickadd/index.html | 16 +++++++--- menus/addon_library.sh | 62 +++++++++++++++++++++---------------- tests/addon_library.test.sh | 39 +++++++++++++++-------- 10 files changed, 169 insertions(+), 81 deletions(-) create mode 100644 library/koha/ensure.pl diff --git a/library/docker-compose.yml b/library/docker-compose.yml index 40dd3ed..77009fa 100644 --- a/library/docker-compose.yml +++ b/library/docker-compose.yml @@ -44,7 +44,8 @@ services: KOHA_SHELVES: ${SHELVES:-} KOHA_ITEM_TYPES: ${ITEM_TYPES:-} ports: - - "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80" + # The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it. + - "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80" - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081" volumes: - koha-data:/var/lib/koha @@ -61,6 +62,8 @@ services: KOHA_API: http://koha:8081/api/v1 ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD} + VIEWER_USER: ${VIEWER_USER:-guest} + VIEWER_PASSWORD: ${VIEWER_PASSWORD:-} KOHA_LIBRARY: ${LIBRARY_CODE:-HOME} OPAC_PORT: ${OPAC_PORT:-8080} STAFF_PORT: ${STAFF_PORT:-8081} diff --git a/library/install.sh b/library/install.sh index ebedaa2..da63d67 100755 --- a/library/install.sh +++ b/library/install.sh @@ -80,9 +80,13 @@ show_status() { dc ps echo echo " Your library: http://$ip:$STAFF_PORT" - echo " sign in here: add books at /add/ (or the 'Add books' button), find a book, lending, labels, people" - echo " Public catalogue: http://$ip:$OPAC_PORT (search only - for a kiosk or visitors)" - echo " Login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env; Add books asks once more with the same login)" + echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people" + echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" + echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)" + echo " It opens the Find a book page: search only, it cannot add or change anything." + if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then + echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)" + fi echo } @@ -117,8 +121,12 @@ 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} +# The public catalogue is off by default: 127.0.0.1 = not reachable from the network. +OPAC_BIND=${OPAC_BIND:-127.0.0.1} ADMIN_USER=admin ADMIN_PASSWORD=$(rand 16) +VIEWER_USER=guest +VIEWER_PASSWORD=$(rand 14) DB_ROOT_PASSWORD=$(rand 32) DB_PASSWORD=$(rand 32) KOHA_SUITE=stable @@ -131,6 +139,11 @@ else if ! grep -q '^QUICKADD_PORT=' .env; then echo "QUICKADD_PORT=$(free_port 8082)" >> .env fi + if ! grep -q '^VIEWER_PASSWORD=' .env; then + { echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env + fi + # Existing installs keep their current public-catalogue behaviour. + grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env if ! grep -q '^BIND_ADDR=' .env; then echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env fi diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile index eb69ff8..5dda088 100644 --- a/library/koha/Dockerfile +++ b/library/koha/Dockerfile @@ -39,7 +39,7 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \ done -COPY bootstrap.pl entrypoint.sh /opt/koha-init/ +COPY bootstrap.pl ensure.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 diff --git a/library/koha/bootstrap.pl b/library/koha/bootstrap.pl index 74f00b5..deebb7b 100755 --- a/library/koha/bootstrap.pl +++ b/library/koha/bootstrap.pl @@ -153,19 +153,6 @@ Koha::CirculationRules->set_rules({ }, }); -# ---- Staff home page: a "Library tools" block with a link to the Add books page ---- -# (Koha 26.05 stores these in the HTML customizations tool, not in a preference.) -{ - my $html = '

Library tools

'; - $dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number) - VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)}); - my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' ); - $dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang) - VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html ); -} - # ---- Preferences ---------------------------------------------------------- my %prefs = ( autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request) @@ -173,11 +160,11 @@ my %prefs = ( finesMode => 'off', # a home library does not charge overdue fines itemcallnumber => '082ab', # Dewey call number copied from the MARC record RESTBasicAuth => 1, # lets the quick-add page use the REST API + OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login OpenLibraryCovers => 1, # cover images in the catalogue OPACSearchForTitleIn => '', BiblioDefaultView => 'normal', - OpacPublic => 1, - opacuserlogin => 0, # catalogue is browse-only; no patron logins needed + opacuserlogin => 1, OpacMaintenance => 0, ExpireReservesMaxPickUpDelay => 0, AllowOfflineCirculation => 0, diff --git a/library/koha/ensure.pl b/library/koha/ensure.pl new file mode 100644 index 0000000..5ef74a6 --- /dev/null +++ b/library/koha/ensure.pl @@ -0,0 +1,35 @@ +#!/usr/bin/perl +# Idempotent housekeeping, run on EVERY container start (after the first-time install or +# the schema upgrade), so existing installs pick these up too: +# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only) +# * the "Library tools" block on the staff home page +use Modern::Perl; +use C4::Context; +use Koha::Patrons; +use Koha::Patron::Categories; + +my $dbh = C4::Context->dbh; +# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha: +# Koha's own "catalogue" permission also allows editing label layouts and templates, so a +# Koha account cannot be made truly read-only. Remove any guest account an earlier version created. +for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) { + say "ensure: removing the old Koha guest account '" . $p->userid . "'"; + $p->delete; +} +if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) { + $cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count; +} + +# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). +my ($have) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM additional_contents WHERE code = 'library_tools'}); +unless ($have) { + my $html = '

Library tools

'; + $dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number) + VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)}); + my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' ); + $dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang) + VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html ); + say "ensure: added the staff home 'Library tools' block"; +} diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh index 34d5bf0..c1532c1 100755 --- a/library/koha/entrypoint.sh +++ b/library/koha/entrypoint.sh @@ -108,6 +108,11 @@ else FIRST_RUN=0 fi +# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup) +runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \ + perl /opt/koha-init/ensure.pl \ + || log "WARNING: ensure.pl reported a problem (see above)" + # --- 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 diff --git a/library/quickadd/app.py b/library/quickadd/app.py index ec2d1d7..f82a644 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -27,6 +27,9 @@ 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", "") +# Optional read-only login to hand out: may open the page and search, nothing else. +VIEWER_USER = os.environ.get("VIEWER_USER", "") +VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "") LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME") STAFF_PORT = os.environ.get("STAFF_PORT", "8081") OPAC_PORT = os.environ.get("OPAC_PORT", "8080") @@ -802,37 +805,50 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def _authed(self): + def _role(self): + """'admin', 'viewer' (read-only guest) or None.""" if not PASSWORD: - return False + return None header = self.headers.get("Authorization", "") - if header.startswith("Basic "): - try: - u, _, p = base64.b64decode(header[6:]).decode().partition(":") - ok_u = hmac.compare_digest(u.encode(), USER.encode()) - ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode()) - return ok_u and ok_p - except (ValueError, UnicodeDecodeError): - return False - return False + if not header.startswith("Basic "): + return None + try: + u, _, p = base64.b64decode(header[6:]).decode().partition(":") + except (ValueError, UnicodeDecodeError): + return None + ub, pb = u.encode(), p.encode() + # Both comparisons always run, so timing does not reveal which account exists. + is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode()) + is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \ + & hmac.compare_digest(pb, VIEWER_PASSWORD.encode()) + return "admin" if is_admin else "viewer" if is_viewer else None - def _guard(self): - if not self._authed(): + def _guard(self, need_admin=True): + role = self._role() + if role is None: self._send(401, b"Login required", "text/plain", - {"WWW-Authenticate": 'Basic realm="Library quick add"'}) + {"WWW-Authenticate": 'Basic realm="Library"'}) return False + if need_admin and role != "admin": + self._send(403, {"status": "error", "message": "This login can search but not change anything."}) + return False + self.role = role return True def do_GET(self): if self.path == "/healthz": return self._send(200, b"ok", "text/plain") - if not self._guard(): - return path = self.path.split("?")[0] + if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search")): + return if path == "/": return self._send(200, INDEX, "text/html; charset=utf-8") + if path == "/api/state" and self.role == "viewer": + return self._send(200, {"role": "viewer", "library": LIBRARY, "recent": [], "types": {}, "locations": [], + "settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL, + "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []}) if path == "/api/state": - return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(), + return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(), "library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) if path == "/api/settings": diff --git a/library/quickadd/index.html b/library/quickadd/index.html index db94b4f..5d9306c 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -455,10 +455,12 @@ // ---- under the staff site (/add/) everything is same-origin const embedded = location.pathname.startsWith('/add/'); + let viewer = false; // the read-only guest login: search only // ---- views: Add books / Find a book function showView() { - const find = location.hash === '#find'; + const find = viewer || location.hash === '#find'; + $('tab-add').hidden = viewer; $('view-add').hidden = find; $('view-find').hidden = !find; $('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find); $('title').textContent = find ? 'Find a book' : 'Add to library'; @@ -482,18 +484,22 @@ el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '', el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status))) : [el('div', { className: 'small' }, 'No copies recorded.')]), - el('div', { className: 'small', style: 'margin-top:6px' }, + viewer ? '' : el('div', { className: 'small', style: 'margin-top:6px' }, el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record'))))); } $('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); }; (async () => { const s = await api('/api/state'); - types = s.types; if (!types[type]) type = 'BOOK'; typeButtons(); + viewer = s.role === 'viewer'; + types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons(); staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || ''; staffBase = s.staff_url || ''; - $('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/'; + // The public catalogue is off by default: only link to it when a public address was configured. + $('lnk-opac').hidden = !s.opac_url; + if (s.opac_url) $('lnk-opac').href = s.opac_url + '/'; $('lnk-staff').href = staffRoot() + '/'; + $('lnk-staff').hidden = viewer; $('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl'; if (s.locations && s.locations.length) { const sel = $('shelf'); @@ -504,7 +510,7 @@ sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); }; $('shelfbox').hidden = false; } - api('/api/settings').then(applySettings); + if (!viewer) api('/api/settings').then(applySettings); $('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false))); showView(); })(); diff --git a/menus/addon_library.sh b/menus/addon_library.sh index 2c9c053..a6c5a88 100644 --- a/menus/addon_library.sh +++ b/menus/addon_library.sh @@ -1,29 +1,30 @@ #!/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. +# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk. # # 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. +# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending. +# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab. # - 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. +# Everything needs a login. The kiosk shows the "Find a book" page signed in as a +# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at +# all, because Koha's own search permission also allows editing label layouts); the page +# list stores that guest login like any other Basic Auth site. The admin login never +# goes into the kiosk's config. The public catalogue (OPAC) is off by default. +# +# Whether other devices on the LAN can reach the library site 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" +LIBRARY_CATALOGUE_TAB_NAME="Find a book" ################################################################################ # Detection / state @@ -42,7 +43,13 @@ library_is_running() { [[ "$(docker_container_state library-koha)" == "running" ]] } +# The page the kiosk shows: Quick Add's Find a book view on the library site. library_catalogue_url() { + echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find" +} + +# Pages from earlier versions of this add-on pointed at the public catalogue. +library_old_catalogue_url() { echo "http://localhost:$(library_env_get OPAC_PORT)/" } @@ -66,19 +73,19 @@ addon_library_status() { 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)" + echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)" 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)/" + echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_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" + local tab_label="Show Find a book on the kiosk" + library_tab_exists && tab_label="Stop showing Find a book 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 @@ -111,13 +118,13 @@ library_sync_files() { library_add_tab() { load_existing_config if library_tab_exists; then - log_info "The catalogue is already one of the kiosk's pages." + log_info "Find a book is already one of the kiosk's pages." return 0 fi URLS+=("$(library_catalogue_url)") DURS+=("0") - USERS+=("") - PASSES+=("") + USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login + PASSES+=("$(library_env_get VIEWER_PASSWORD)") NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME") save_config } @@ -125,11 +132,12 @@ library_add_tab() { # Drop every page that points at the catalogue's local address. library_remove_tab() { load_existing_config - local url idx + local url old idx url="$(library_catalogue_url)" + old="$(library_old_catalogue_url)" local -a nu=() nd=() nus=() np=() nn=() for idx in "${!URLS[@]}"; do - [[ "${URLS[$idx]}" == "$url" ]] && continue + [[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}") np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}") done @@ -199,7 +207,7 @@ action_library_install() { return 1 fi - if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then + if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then library_add_tab fi echo @@ -218,9 +226,11 @@ action_library_show_access() { echo "(Network access is limited to this kiosk.)" fi echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/" - echo " Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/" - echo " Public catalogue (kiosk): http://${ip}:$(library_env_get OPAC_PORT)/" - echo " Login: ${user} / ${pass} (Add books asks once more with the same login)" + echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/" + echo " Admin login: ${user} / ${pass}" + echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)" + echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only," + echo " it cannot add or change anything (and it has no login on the staff site itself)." echo echo " Passwords live in $LIBRARY_DIR/.env (root only)." pause diff --git a/tests/addon_library.test.sh b/tests/addon_library.test.sh index 6f87f97..f16926b 100755 --- a/tests/addon_library.test.sh +++ b/tests/addon_library.test.sh @@ -74,6 +74,8 @@ STAFF_PORT=8081 QUICKADD_PORT=8082 ADMIN_USER=admin ADMIN_PASSWORD=s3cretPASS +VIEWER_USER=guest +VIEWER_PASSWORD=guestPASS BIND_ADDR=${BIND_ADDR:-0.0.0.0} ENV echo running > "$STUB_STATE" @@ -98,7 +100,7 @@ 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"; } +tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; } # ============================================================================ echo "== lib/docker.sh" @@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou 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 "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs) +check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS +check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'" +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" @@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta # ============================================================================ echo "== menu while installed" addon_library_menu_builder -check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}" +check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${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 +check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 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) +check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 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[*]}" +check "menu now offers 'Show'" grep -q 'Show Find a book 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 +check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1 # ============================================================================ +echo "== an earlier version's tab (public catalogue address) is cleaned up" +jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH" +check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1 +library_remove_tab >/dev/null 2>&1 +check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0 +check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs) +library_add_tab >/dev/null 2>&1 + echo "== network access" : > "$STUB_LOG" printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network @@ -168,7 +180,8 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR # ============================================================================ echo "== access screen" printf '\n' | action_library_show_access > "$T/access.out" 2>&1 -check "shows the login" grep -q 'admin / s3cretPASS' "$T/access.out" +check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out" +check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out" check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out" check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out" @@ -186,7 +199,7 @@ 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 +check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0 echo "== purge needs the typed confirmation" printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1 @@ -196,8 +209,8 @@ 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) +check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0 +check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs) echo echo "passed: $PASS failed: $FAIL"