diff --git a/library/README.md b/library/README.md
index c074576..c94bded 100644
--- a/library/README.md
+++ b/library/README.md
@@ -1,4 +1,4 @@
-# Home library: Koha + Quick Add (Docker)
+# Room for Books: a home library (Koha + the Room for Books site, in Docker)
A self-hosted library catalogue you can set up in one command:
@@ -203,3 +203,85 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz)
```
+
+## Signing in once
+
+Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
+
+## The home page (the Room for Books site at `/add/`)
+
+One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
+(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
+(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
+and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books.
+
+**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list
+of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have
+are marked, with an *Add another copy* button.
+
+## Everyday use (details)
+
+One page for the day-to-day, so you rarely need Koha's own screens:
+
+- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
+ (book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
+ page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
+ add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
+- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
+ "Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
+ that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
+- **People**: add someone with just a name (the card number is assigned automatically), search, and
+ **Print card** for a library card with a scannable Code 39 barcode.
+- **Add books**: scan an ISBN (or use the phone camera), as before.
+
+The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
+
+### Collections, QR cards, more about a book
+
+- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the
+ Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed.
+- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text
+ or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo.
+ **Print everyone's cards** (People) lays out a sheet of cards.
+- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing,
+ WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it.
+
+### Reading levels (AR) and spreadsheets
+
+AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it.
+Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the
+**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads
+`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
+(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
+in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
+
+### Live camera scanning (needs HTTPS)
+
+Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
+certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
+phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
+browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
+
+### Copying reading levels from AR BookFind
+
+On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
+points, quiz number and interest level fill in by themselves; check them and press Save.
+
+### Tags, and borrowers signing in
+
+- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
+ book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
+ the usual backups of that folder include.
+- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
+ their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
+ people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
+ sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
+ password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
+ default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
+ Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
+
+### Reading levels: pop-up or in-page AR BookFind
+
+The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
+page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
+it into AR's search; AR's own page cannot be pre-filled from here.
diff --git a/library/docker-compose.yml b/library/docker-compose.yml
index 77009fa..1153cd7 100644
--- a/library/docker-compose.yml
+++ b/library/docker-compose.yml
@@ -35,6 +35,7 @@ services:
environment:
TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
+ KOHA_SSO_SECRET: ${SSO_SECRET:-}
KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
@@ -60,6 +61,7 @@ services:
environment:
TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1
+ SSO_SECRET: ${SSO_SECRET:-}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
diff --git a/library/install.sh b/library/install.sh
index da63d67..0ecded4 100755
--- a/library/install.sh
+++ b/library/install.sh
@@ -127,6 +127,8 @@ ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16)
VIEWER_USER=guest
VIEWER_PASSWORD=$(rand 14)
+# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
+SSO_SECRET=$(rand 40)
DB_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable
@@ -142,6 +144,7 @@ else
if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
fi
+ grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
# 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
diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile
index 5dda088..bfe15bd 100644
--- a/library/koha/Dockerfile
+++ b/library/koha/Dockerfile
@@ -39,6 +39,12 @@ 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
+# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
+COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
+# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
+COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
+RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
+
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/ensure.pl b/library/koha/ensure.pl
index 5ef74a6..b0ce188 100644
--- a/library/koha/ensure.pl
+++ b/library/koha/ensure.pl
@@ -7,6 +7,7 @@ use Modern::Perl;
use C4::Context;
use Koha::Patrons;
use Koha::Patron::Categories;
+use Koha::Caches;
my $dbh = C4::Context->dbh;
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
@@ -20,16 +21,35 @@ 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 ';
+# Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
+# (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
+my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
+unless ( -e $flag ) {
+ my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
+ my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
+ if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
+ $dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
+ $dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
+ say "ensure: patron passwords may be short (4+ characters, no strength rule)";
+ Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
+ }
+ if ( open my $fh, '>', $flag ) { close $fh }
+}
+
+# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
+my $html = '
Room for Books ';
+my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
+unless ($id) {
$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' );
+ $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";
+ VALUES (?, 'Room for Books', ?, 'default')}, undef, $id, $html );
+ say "ensure: added the staff home 'Room for Books' block";
+}
+else {
+ $dbh->do( q{UPDATE additional_contents_localizations SET title = 'Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
+ undef, $html, $id );
}
diff --git a/library/koha/entrypoint.sh b/library/koha/entrypoint.sh
index c1532c1..6d7bbae 100755
--- a/library/koha/entrypoint.sh
+++ b/library/koha/entrypoint.sh
@@ -60,22 +60,36 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
# (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
+ # Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
+ # secret header nobody outside can forge: it is overwritten here on every request) that the
+ # request came through the staff site, and Quick Add then asks Koha whether the browser's login
+ # cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
+ # about that fails, Quick Add just asks for its own login as before.
+ SSO_BLOCK=""
+ if [ -n "${KOHA_SSO_SECRET:-}" ]; then
+ install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
+ printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
+ SSO_BLOCK="
+
+ RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
+
+ "
+ fi
cat > /etc/koha/apache-quickadd.conf << APACHE
RedirectMatch ^/add\$ /add/
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
+${SSO_BLOCK}
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).
# 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.
+ # page). Now that the instance exists, swap to plain prefork; Apache runs as the
+ # instance user (Plack does the real work as that user already).
rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE"
diff --git a/library/koha/quickadd-return.pl b/library/koha/quickadd-return.pl
new file mode 100644
index 0000000..5ad26ac
--- /dev/null
+++ b/library/koha/quickadd-return.pl
@@ -0,0 +1,53 @@
+#!/usr/bin/perl
+# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
+#
+# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
+# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
+# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
+# that know the secret get an answer.
+use Modern::Perl;
+use CGI qw( -utf8 );
+use JSON qw( encode_json );
+use Digest::SHA qw( sha256_hex );
+use C4::Context;
+use C4::Circulation qw( AddReturn );
+use Koha::Items;
+
+my $q = CGI->new;
+
+sub reply {
+ my ( $q, $status, $data ) = @_;
+ print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
+ encode_json($data);
+ exit;
+}
+
+my $secret = '';
+if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
+my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
+reply( $q, '403 Forbidden', { status => 'forbidden' } )
+ unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
+
+my $barcode = $q->param('barcode') // '';
+reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
+
+my $item = Koha::Items->find( { barcode => $barcode } );
+reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
+
+# AddReturn needs to know which branch it is happening at.
+my $branch = $item->homebranch;
+my ( $doreturn, $messages, $borrower, $was_out );
+eval {
+ C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
+ $was_out = $item->checkout ? 1 : 0;
+ ( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
+ 1;
+} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
+reply(
+ $q, '200 OK',
+ {
+ status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
+ patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
+ messages => [ sort keys %{ $messages || {} } ],
+ }
+);
diff --git a/library/koha/quickadd-session.pl b/library/koha/quickadd-session.pl
new file mode 100644
index 0000000..e60a487
--- /dev/null
+++ b/library/koha/quickadd-session.pl
@@ -0,0 +1,44 @@
+#!/usr/bin/perl
+# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session).
+#
+# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as
+# someone allowed to edit the catalogue?" so staff do not have to log in a second time.
+#
+# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that
+# are invoked by pages authenticated in the usual way) and only answers callers that present a
+# shared secret, so it cannot be used to probe sessions from outside. The browser's address is
+# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working.
+use Modern::Perl;
+use CGI qw( -utf8 );
+use JSON qw( encode_json );
+use Digest::SHA qw( sha256_hex );
+use C4::Auth qw( check_cookie_auth );
+
+my $q = CGI->new;
+
+# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named
+# sub would otherwise see a stale copy of a file-level variable.
+sub reply {
+ my ( $q, $status, $data ) = @_;
+ print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
+ encode_json($data);
+ exit;
+}
+
+# 1. The caller must know the shared secret (a file only the Koha user and root can read).
+my $secret = '';
+if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
+my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
+reply( $q, '403 Forbidden', { status => 'forbidden' } )
+ unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
+
+# 2. Plausible-looking inputs only.
+my $sid = $q->param('sid') // '';
+my $ip = $q->param('ip') // '';
+reply( $q, '200 OK', { status => 'failed' } )
+ unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/;
+
+# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in.
+my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } );
+reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session;
+reply( $q, '200 OK', { status => $status || 'failed' } );
diff --git a/library/koha/quickadd-value.pl b/library/koha/quickadd-value.pl
new file mode 100644
index 0000000..71c3a6a
--- /dev/null
+++ b/library/koha/quickadd-value.pl
@@ -0,0 +1,42 @@
+#!/usr/bin/perl
+# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl).
+#
+# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE),
+# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret
+# guard as quickadd-session / quickadd-return; only the two categories below can be touched.
+use Modern::Perl;
+use CGI qw( -utf8 );
+use JSON qw( encode_json );
+use Digest::SHA qw( sha256_hex );
+use Koha::AuthorisedValues;
+use Koha::AuthorisedValue;
+
+my $q = CGI->new;
+
+sub reply {
+ my ( $q, $status, $data ) = @_;
+ print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
+ encode_json($data);
+ exit;
+}
+
+my $secret = '';
+if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
+my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
+reply( $q, '403 Forbidden', { status => 'forbidden' } )
+ unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
+
+my $cat = $q->param('category') // '';
+my $value = $q->param('value') // '';
+my $desc = $q->param('description') // '';
+utf8::decode($desc);
+reply( $q, '200 OK', { status => 'invalid' } )
+ unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80;
+
+my $out = eval {
+ my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count;
+ return { status => 'exists' } if $exists;
+ Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store;
+ { status => 'added' };
+};
+reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } );
diff --git a/library/quickadd/Dockerfile b/library/quickadd/Dockerfile
index 5ff5b3f..4a6dde1 100644
--- a/library/quickadd/Dockerfile
+++ b/library/quickadd/Dockerfile
@@ -5,7 +5,7 @@ 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 zbar-tools tzdata \
+ && apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
@@ -13,7 +13,7 @@ RUN apt-get update \
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
-RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
+RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc
new file mode 100644
index 0000000..687801f
Binary files /dev/null and b/library/quickadd/__pycache__/app.cpython-313.pyc differ
diff --git a/library/quickadd/app.py b/library/quickadd/app.py
index f82a644..8694ab7 100644
--- a/library/quickadd/app.py
+++ b/library/quickadd/app.py
@@ -9,6 +9,7 @@ A tiny web app that sits next to Koha. For each ISBN/UPC it:
Standard library only; Z39.50 is done by shelling out to yaz-client.
"""
import base64
+import hashlib
import hmac
import json
import os
@@ -28,6 +29,12 @@ 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.
+# Single sign-on: requests that reach us through the staff site's web server carry this secret in
+# X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit
+# the catalogue. Unset = feature off (only the Basic logins below work).
+SSO_SECRET = os.environ.get("SSO_SECRET", "")
+KOHA_RETURN_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-return.pl"
+KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session"
VIEWER_USER = os.environ.get("VIEWER_USER", "")
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
@@ -300,6 +307,26 @@ def get_locations():
return _LOC_CACHE["value"]
+_COLL_CACHE = {"at": 0.0, "value": []}
+
+
+def get_collections():
+ """Collections (Koha authorised-value category CCODE): 'Kids', 'Cookbooks', ... one per copy."""
+ if time.time() - _COLL_CACHE["at"] < 30:
+ return _COLL_CACHE["value"]
+ try:
+ vals = koha("GET", "/authorised_value_categories/CCODE/authorised_values") or []
+ _COLL_CACHE["value"] = [{"value": v["value"], "label": v.get("description") or v["value"]} for v in vals]
+ _COLL_CACHE["at"] = time.time()
+ except KohaError:
+ pass
+ return _COLL_CACHE["value"]
+
+
+def valid_collection(code):
+ return code if code and any(c["value"] == code for c in get_collections()) else None
+
+
def valid_location(code):
return code if code and any(l["value"] == code for l in get_locations()) else None
@@ -327,7 +354,7 @@ def find_existing(code):
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
COUNTER_FILE = os.path.join(DATA_DIR, "counter.json")
MIN_WIDTH, MAX_WIDTH = 7, 10 # 6 all-digit Code 39 symbols can be misread as Code 32 by some scanners
-DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": ""}
+DEFAULT_SETTINGS = {"barcode_mode": "auto", "width": MIN_WIDTH, "location": "", "patron_password_mode": "generated", "patron_password": ""}
BARCODE_MODES = ("auto", "scan", "none")
LOCK = threading.RLock()
@@ -362,6 +389,12 @@ def clean_settings(payload, current):
s["width"] = min(max(int(payload["width"]), MIN_WIDTH), MAX_WIDTH)
except (TypeError, ValueError):
pass
+ if payload.get("patron_password_mode") in ("generated", "same"):
+ s["patron_password_mode"] = payload["patron_password_mode"]
+ if "patron_password" in payload:
+ pw = str(payload["patron_password"] or "").strip()
+ if pw == "" or 4 <= len(pw) <= 40:
+ s["patron_password"] = pw
if "location" in payload:
loc = payload["location"] or ""
if loc == "" or valid_location(loc):
@@ -726,6 +759,869 @@ def op_search(term):
"year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]}
+# ------------------------------------------------------------ library / book page
+PAGE_SIZE = 48
+ITEM_FILTER_CAP = 2000
+
+
+def _shape_book(b, items, where):
+ mine = [i for i in items if i["biblio_id"] == b["biblio_id"]]
+ out = sum(1 for i in mine if item_status(i) == "Checked out")
+ shelves = sorted({where.get(i.get("location"), "") for i in mine} - {""})
+ colls = {c["value"]: c["label"] for c in get_collections()}
+ collections = sorted({colls.get(i.get("collection_code"), "") for i in mine} - {""})
+ return {"tags": _tags_load().get(str(b["biblio_id"]), []), "collections": collections, "id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "",
+ "year": b.get("copyright_date") or "", "isbn": b.get("isbn") or "", "type": b.get("item_type") or "",
+ "copies": len(mine), "out": out, "shelves": shelves,
+ "callnumber": next((i.get("callnumber") for i in mine if i.get("callnumber")), "") or ""}
+
+
+def op_library(qs):
+ """The Library grid: filter by shelf / format / status, search, sort, page."""
+ def one(k):
+ return (qs.get(k) or [""])[0]
+ term = re.sub(r"[%_\\]", "", one("q")).strip()[:80]
+ shelf, itype, status, coll, tag = one("shelf"), one("type"), one("status"), one("collection"), one("tag")
+ sort = one("sort") or "title"
+ try:
+ page = max(1, int(one("page") or 1))
+ except ValueError:
+ page = 1
+ conds = []
+ if term:
+ like = {"-like": "%" + term + "%"}
+ conds.append({"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]})
+ if tag:
+ ids_t = sorted(int(k) for k, v in _tags_load().items() if any(t.lower() == tag.lower() for t in v))
+ if not ids_t:
+ return {"books": [], "page": page, "more": False}
+ conds.append({"biblio_id": ids_t})
+ order = {"title": "title", "author": "author", "new": "-biblio_id"}.get(sort, "title")
+ item_q = {}
+ if shelf == "-":
+ item_q["location"] = None
+ elif shelf:
+ item_q["location"] = shelf
+ if itype:
+ item_q["item_type_id"] = itype
+ if coll == "-":
+ item_q["collection_code"] = None
+ elif coll:
+ item_q["collection_code"] = coll
+ if status == "out":
+ item_q["checked_out_date"] = {"!=": None}
+ elif status == "in":
+ item_q["checked_out_date"] = None
+ query = {"_order_by": order}
+ if item_q:
+ its = koha("GET", "/items", query={"q": json.dumps(item_q), "_per_page": str(ITEM_FILTER_CAP)}) or []
+ ids = sorted({i["biblio_id"] for i in its})
+ if not ids:
+ return {"books": [], "page": page, "more": False}
+ conds.append({"biblio_id": ids})
+ if conds:
+ query["q"] = json.dumps(conds[0] if len(conds) == 1 else {"-and": conds})
+ query.update({"_per_page": str(PAGE_SIZE + 1), "_page": str(page)})
+ books = koha("GET", "/biblios", query=query) or []
+ more = len(books) > PAGE_SIZE
+ books = books[:PAGE_SIZE]
+ if not books:
+ return {"books": [], "page": page, "more": False}
+ ids = [b["biblio_id"] for b in books]
+ items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "1000"}) or []
+ where = {l["value"]: l["label"] for l in get_locations()}
+ return {"books": [_shape_book(b, items, where) for b in books], "page": page, "more": more}
+
+
+def _checkout_info(item_ids, names):
+ """item_id -> {who, due} for items currently out (staff only)."""
+ if not item_ids:
+ return {}
+ cos = koha("GET", "/checkouts", query={"q": json.dumps({"item_id": item_ids}), "_per_page": "100"}) or []
+ out = {}
+ for c in cos:
+ who = ""
+ if names:
+ try:
+ p = koha("GET", "/patrons/%s" % c["patron_id"])
+ who = ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip()
+ except KohaError:
+ pass
+ out[c["item_id"]] = {"who": who, "patron_id": c["patron_id"], "due": (c.get("due_date") or "")[:10],
+ "checkout_id": c["checkout_id"]}
+ return out
+
+
+def op_book(biblio_id, staff):
+ b = koha("GET", "/biblios/%d" % biblio_id)
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ where = {l["value"]: l["label"] for l in get_locations()}
+ out = _checkout_info([i["item_id"] for i in items if item_status(i) == "Checked out"], staff)
+ copies = []
+ for i in items:
+ c = {"item_id": i["item_id"], "barcode": i.get("external_id") or "", "callnumber": i.get("callnumber") or "",
+ "shelf": i.get("location") or "", "shelf_label": where.get(i.get("location"), ""),
+ "collection": i.get("collection_code") or "",
+ "collection_label": {c["value"]: c["label"] for c in get_collections()}.get(i.get("collection_code"), ""),
+ "status": item_status(i), "type": i.get("item_type_id") or ""}
+ if staff and c["status"] == "Checked out":
+ c["out"] = out.get(i["item_id"])
+ copies.append(c)
+ return {"id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "subtitle": b.get("subtitle") or "",
+ "author": b.get("author") or "", "year": b.get("copyright_date") or b.get("publication_year") or "",
+ "isbn": b.get("isbn") or "", "publisher": b.get("publisher") or "", "pages": b.get("pages") or "",
+ "edition": b.get("edition_statement") or "", "summary": b.get("abstract") or b.get("notes") or "",
+ "series": b.get("series_title") or "", "type": b.get("item_type") or "", "copies": copies,
+ "shelves": get_locations(), "collections": get_collections(),
+ "tags": _tags_load().get(str(b["biblio_id"]), []), "all_tags": tag_counts()}
+
+
+def op_item_update(payload):
+ item_id = payload.get("item_id")
+ if not isinstance(item_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ it = koha("GET", "/items/%d" % item_id)
+ if "shelf" in payload:
+ it["location"] = valid_location(payload.get("shelf")) or None
+ if "collection" in payload:
+ it["collection_code"] = valid_collection(payload.get("collection")) or None
+ if "callnumber" in payload:
+ it["callnumber"] = (str(payload.get("callnumber") or "").strip()[:80]) or None
+ # Koha's update route rejects the computed fields the GET adds.
+ for k in [k for k in it if k.startswith("effective_")] + [
+ "checkouts_count", "holds_count", "renewals_count", "localuse", "timestamp", "call_number_sort",
+ "bookable", "extended_subfields"]:
+ it.pop(k, None)
+ koha("PUT", "/biblios/%d/items/%d" % (it["biblio_id"], item_id), it)
+ return 200, {"status": "saved"}
+
+
+def op_item_delete(payload):
+ item_id, biblio_id = payload.get("item_id"), payload.get("biblio_id")
+ if not isinstance(item_id, int) or not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ return op_undo({"item_id": item_id, "biblio_id": biblio_id})
+
+
+def op_book_delete(payload):
+ biblio_id = payload.get("biblio_id")
+ if not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ if any(item_status(i) == "Checked out" for i in items):
+ return 409, {"status": "error", "message": "A copy is checked out - return it first."}
+ for i in items:
+ koha("DELETE", "/items/%d" % i["item_id"])
+ forget(i["item_id"])
+ koha("DELETE", "/biblios/%d" % biblio_id)
+ d = _tags_load()
+ if d.pop(str(biblio_id), None) is not None:
+ save_json(TAGS_FILE, d)
+ return 200, {"status": "deleted"}
+
+
+def op_item_copy(payload):
+ """Another copy of a book already in the library (new barcode per the barcode setting)."""
+ biblio_id = payload.get("biblio_id")
+ if not isinstance(biblio_id, int):
+ return 400, {"status": "invalid"}
+ with LOCK:
+ b = koha("GET", "/biblios/%d" % biblio_id)
+ items = koha("GET", "/biblios/%d/items" % biblio_id) or []
+ base = items[0] if items else {}
+ mode = load_settings()["barcode_mode"]
+ if mode == "scan":
+ mode = "auto" # no scanner step here: number it, or leave it blank in 'none' mode
+ item = add_item(biblio_id, base.get("item_type_id") or valid_type(b.get("item_type")), base.get("callnumber"),
+ valid_location(payload.get("shelf")) or base.get("location"), mode)
+ return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""}
+
+
+# ------------------------------------------------------------ lending / people
+import datetime as _dt
+
+PATRON_CATEGORY = os.environ.get("PATRON_CATEGORY", "FRIEND")
+
+
+def _patron_name(p):
+ return ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip() or p.get("cardnumber") or "?"
+
+
+def _shape_patron(p):
+ return {"id": p["patron_id"], "name": _patron_name(p), "card": p.get("cardnumber") or "",
+ "email": p.get("email") or "", "phone": p.get("phone") or ""}
+
+
+def op_patron_search(term):
+ term = re.sub(r"[%_\\]", "", term or "").strip()[:60]
+ if not term:
+ res = koha("GET", "/patrons", query={"_per_page": "50", "_order_by": "surname"}) or []
+ else:
+ like = {"-like": "%" + term + "%"}
+ conds = [{"surname": like}, {"firstname": like}, {"cardnumber": {"-like": term + "%"}}]
+ parts = term.split()
+ if len(parts) == 2: # "Jane Smith"
+ conds.append({"-and": [{"firstname": {"-like": parts[0] + "%"}}, {"surname": {"-like": parts[1] + "%"}}]})
+ res = koha("GET", "/patrons", query={"q": json.dumps({"-or": conds}), "_per_page": "25", "_order_by": "surname"}) or []
+ return {"patrons": [_shape_patron(p) for p in res]}
+
+
+def _loan_rows(cos):
+ """Checkouts (with embedded item) -> rows with titles and an overdue flag."""
+ if not cos:
+ return []
+ bib_ids = sorted({c["item"]["biblio_id"] for c in cos if c.get("item")})
+ titles = {}
+ if bib_ids:
+ for b in koha("GET", "/biblios", query={"q": json.dumps({"biblio_id": bib_ids}), "_per_page": str(len(bib_ids) + 1)}) or []:
+ titles[b["biblio_id"]] = ((b.get("title") or "").rstrip(" /:"), b.get("author") or "", b.get("isbn") or "")
+ today = _dt.date.today().isoformat()
+ rows = []
+ for c in cos:
+ it = c.get("item") or {}
+ t = titles.get(it.get("biblio_id"), ("(unknown)", "", ""))
+ due = (c.get("due_date") or "")[:10]
+ rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "",
+ "biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due,
+ "overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0})
+ return rows
+
+
+def op_patron(patron_id):
+ p = koha("GET", "/patrons/%d" % patron_id)
+ cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": patron_id}), "_per_page": "200",
+ "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
+ d = _shape_patron(p)
+ d["loans"] = _loan_rows(cos)
+ d["category"] = p.get("category_id") or ""
+ return d
+
+
+def op_out():
+ """Everything checked out right now, soonest due first (overdue on top)."""
+ cos = koha("GET", "/checkouts", query={"_per_page": "500", "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or []
+ rows = _loan_rows(cos)
+ names = {}
+ for pid in sorted({r["patron_id"] for r in rows}):
+ try:
+ names[pid] = _patron_name(koha("GET", "/patrons/%d" % pid))
+ except KohaError:
+ names[pid] = "?"
+ for r in rows:
+ r["who"] = names.get(r["patron_id"], "?")
+ return {"loans": rows}
+
+
+def _item_by_barcode(code):
+ code = (code or "").strip()
+ if not code or len(code) > 40:
+ return None
+ res = koha("GET", "/items", query={"q": json.dumps({"external_id": code}), "_per_page": "1"}) or []
+ return res[0] if res else None
+
+
+def _title_of(biblio_id):
+ try:
+ return (koha("GET", "/biblios/%d" % biblio_id).get("title") or "").rstrip(" /:")
+ except KohaError:
+ return ""
+
+
+def op_checkout(payload):
+ pid, code = payload.get("patron_id"), payload.get("barcode")
+ if not isinstance(pid, int):
+ return 400, {"status": "invalid", "message": "Pick the person first."}
+ it = _item_by_barcode(code)
+ if not it:
+ return 200, {"status": "unknown", "message": "No book has the barcode %s." % str(code)[:40]}
+ title = _title_of(it["biblio_id"])
+ if item_status(it) == "Checked out":
+ return 200, {"status": "already_out", "title": title, "barcode": it["external_id"],
+ "message": "That one is already checked out. Scan it as a return first."}
+ try:
+ co = koha("POST", "/checkouts", {"patron_id": pid, "item_id": it["item_id"]})
+ except KohaError as e:
+ try:
+ msg = json.loads(e.body).get("error") or ""
+ except ValueError:
+ msg = ""
+ return 200, {"status": "refused", "title": title, "message": msg or "Koha would not allow that checkout."}
+ return 200, {"status": "checked_out", "title": title, "barcode": it["external_id"], "due": (co.get("due_date") or "")[:10]}
+
+
+def op_return(payload):
+ code = str(payload.get("barcode") or "").strip()
+ if not SSO_SECRET:
+ return 200, {"status": "error", "message": "Returns need SSO_SECRET in .env (run the installer's Update)."}
+ req = urllib.request.Request(KOHA_RETURN_URL + "?" + urllib.parse.urlencode({"barcode": code}),
+ headers={"X-Quickadd-Secret": SSO_SECRET})
+ try:
+ with urllib.request.urlopen(req, timeout=20) as r:
+ res = json.loads(r.read())
+ except (urllib.error.URLError, OSError, ValueError):
+ return 502, {"status": "error", "message": "Koha did not answer the return - try again."}
+ it = _item_by_barcode(code) if res.get("status") in ("returned", "not_out") else None
+ res["title"] = _title_of(it["biblio_id"]) if it else ""
+ return 200, res
+
+
+def op_renew(payload):
+ cid = payload.get("checkout_id")
+ if not isinstance(cid, int):
+ return 400, {"status": "invalid"}
+ try:
+ co = koha("POST", "/checkouts/%d/renewal" % cid)
+ except KohaError as e:
+ try:
+ msg = json.loads(e.body).get("error") or ""
+ except ValueError:
+ msg = ""
+ return 200, {"status": "refused", "message": msg or "That one can't be renewed (limit reached or someone is waiting)."}
+ return 200, {"status": "renewed", "due": (co.get("due_date") or "")[:10]}
+
+
+_NAME_CACHE = {"at": 0.0, "value": ""}
+
+
+def library_name():
+ if time.time() - _NAME_CACHE["at"] > 300:
+ try:
+ _NAME_CACHE["value"] = koha("GET", "/libraries/%s" % urllib.parse.quote(LIBRARY)).get("name") or ""
+ except (KohaError, AttributeError):
+ pass
+ _NAME_CACHE["at"] = time.time()
+ return _NAME_CACHE["value"]
+
+
+def op_patron_add(payload):
+ def clean(k, n=60):
+ return re.sub(r"\s+", " ", str(payload.get(k) or "")).strip()[:n]
+ surname, first = clean("surname"), clean("firstname")
+ if not surname:
+ return 400, {"status": "invalid", "message": "A name is needed."}
+ body = {"surname": surname, "firstname": first or None, "category_id": PATRON_CATEGORY, "library_id": LIBRARY,
+ "email": clean("email", 100) or None, "phone": clean("phone", 30) or None}
+ try:
+ p = koha("POST", "/patrons", body, headers={"x-confirm-not-duplicate": "1"} if payload.get("confirm") else None)
+ except KohaError as e:
+ if e.status == 409 or "already exists" in e.body:
+ return 200, {"status": "duplicate", "message": "Someone named %s is already in the library." % _patron_name(body)}
+ raise
+ out = dict(_shape_patron(p), status="added")
+ if not payload.get("nologin"):
+ pw = new_password()
+ try:
+ set_patron_password(p["patron_id"], pw)
+ out["password"] = pw
+ except KohaError:
+ out["password"] = ""
+ return 200, out
+
+
+# ------------------------------------------------ new shelves / collections, QR codes, book info
+KOHA_VALUE_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-value.pl"
+
+
+def op_value_add(payload):
+ """Add a shelf/room or a collection (Koha's REST API cannot; a small guarded Koha script does)."""
+ kind = payload.get("kind")
+ cat = {"shelf": "LOC", "collection": "CCODE"}.get(kind)
+ name = re.sub(r"\s+", " ", str(payload.get("name") or "")).strip()[:60]
+ if not cat or not name:
+ return 400, {"status": "invalid", "message": "A name is needed."}
+ if not SSO_SECRET:
+ return 200, {"status": "error", "message": "Adding these needs SSO_SECRET in .env (run the installer's Update)."}
+ existing = get_locations() if cat == "LOC" else get_collections()
+ for v in existing: # same name again: hand back the one that exists
+ if v["label"].strip().lower() == name.lower():
+ return 200, {"status": "added", "value": v["value"], "label": v["label"]}
+ base = re.sub(r"[^A-Z0-9]+", "", name.upper())[:12] or "X"
+ have = {v["value"] for v in existing}
+ code, n = base, 2
+ while code in have:
+ code = base[:10] + str(n)
+ n += 1
+ req = urllib.request.Request(KOHA_VALUE_URL + "?" + urllib.parse.urlencode({"category": cat, "value": code, "description": name}),
+ headers={"X-Quickadd-Secret": SSO_SECRET})
+ try:
+ with urllib.request.urlopen(req, timeout=20) as r:
+ res = json.loads(r.read())
+ except (urllib.error.URLError, OSError, ValueError):
+ return 502, {"status": "error", "message": "Koha did not answer - try again."}
+ if res.get("status") != "added":
+ return 200, {"status": "error", "message": "Koha would not add that."}
+ _LOC_CACHE["at"] = _COLL_CACHE["at"] = 0.0
+ return 200, {"status": "added", "value": code, "label": name}
+
+
+def qr_svg(text):
+ if not re.fullmatch(r"[A-Za-z0-9-]{1,40}", text or ""):
+ raise ValueError("bad text")
+ return subprocess.run(["qrencode", "-t", "SVG", "-m", "2", "-s", "8", "-l", "M", "-o", "-", text],
+ capture_output=True, timeout=10, check=True).stdout
+
+
+MORE_FILE = os.path.join(DATA_DIR, "more.json")
+MORE_TTL = 30 * 86400
+
+
+def _http_json(url):
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "library-quickadd/1 (home library)"})
+ with urllib.request.urlopen(req, timeout=8) as r:
+ return json.loads(r.read())
+
+
+def _ol_text(v):
+ if isinstance(v, dict):
+ v = v.get("value")
+ return re.sub(r"\s+", " ", v).strip() if isinstance(v, str) else ""
+
+
+def op_more(biblio_id):
+ """Extra details from Open Library (description, subjects) plus look-up links, cached on disk.
+ AR BookFind is a link only: its terms forbid automated access, so nothing is fetched from it."""
+ b = koha("GET", "/biblios/%d" % biblio_id)
+ isbn = re.sub(r"[^0-9Xx]", "", (b.get("isbn") or "").split(" ")[0].split("|")[0])
+ title = (b.get("title") or "").rstrip(" /:")
+ out = {"links": [], "description": "", "subjects": [], "first_published": "", "isbn": isbn,
+ "ar": _ar_load().get(_isbn13(isbn) or "", {})}
+ q = urllib.parse.quote(title + " " + (b.get("author") or ""))
+ if isbn:
+ out["links"] = [
+ {"label": "Open Library", "url": "https://openlibrary.org/isbn/" + isbn},
+ {"label": "Goodreads", "url": "https://www.goodreads.com/book/isbn/" + isbn},
+ {"label": "LibraryThing", "url": "https://www.librarything.com/isbn/" + isbn},
+ {"label": "WorldCat", "url": "https://search.worldcat.org/search?q=bn%3A" + isbn}]
+ out["links"].append({"label": "AR BookFind (reading level)", "url": "https://www.arbookfind.com/", "note": "search for: " + title})
+ if not isbn:
+ return out
+ with LOCK:
+ try:
+ cache = json.load(open(MORE_FILE))
+ except (OSError, ValueError):
+ cache = {}
+ hit = cache.get(isbn)
+ if hit and time.time() - hit["at"] < (MORE_TTL if hit["ok"] else 3600):
+ return dict(out, **hit["data"])
+ data, ok = {}, False
+ try:
+ ed = _http_json("https://openlibrary.org/isbn/%s.json" % isbn)
+ work = (ed.get("works") or [{}])[0].get("key")
+ data["description"] = _ol_text(ed.get("description"))
+ data["subjects"] = []
+ if work:
+ w = _http_json("https://openlibrary.org%s.json" % work)
+ data["description"] = _ol_text(w.get("description")) or data["description"]
+ data["subjects"] = [str(x) for x in (w.get("subjects") or [])][:12]
+ data["first_published"] = str(w.get("first_publish_date") or "")
+ out["links"][0] = {"label": "Open Library", "url": "https://openlibrary.org" + work}
+ data["description"] = data["description"].split("----------")[0].strip()[:1500]
+ ok = True
+ except Exception:
+ pass
+ if ok:
+ data["link0"] = out["links"][0]
+ cache[isbn] = {"at": time.time(), "ok": ok, "data": data}
+ with LOCK:
+ try:
+ tmp = MORE_FILE + ".tmp"
+ json.dump(cache, open(tmp, "w"))
+ os.replace(tmp, MORE_FILE)
+ except OSError:
+ pass
+ if ok and data.get("link0"):
+ out["links"][0] = data.pop("link0")
+ return dict(out, **data)
+
+
+# ------------------------------------------------ reading levels (AR) and CSV in/out
+# AR BookFind has no public feed and its terms forbid automated access, so reading levels are kept
+# here instead: typed in on the book page, or imported from a CSV you made yourself. Keyed by ISBN-13.
+AR_FILE = os.path.join(DATA_DIR, "reading-levels.json")
+
+
+def _isbn13(raw):
+ c = clean_code(raw)
+ if not c:
+ return None
+ return isbn10_to_13(c) if len(c) == 10 else c
+
+
+def _ar_load():
+ try:
+ return json.load(open(AR_FILE))
+ except (OSError, ValueError):
+ return {}
+
+
+def _ar_save(d):
+ tmp = AR_FILE + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(d, f)
+ os.replace(tmp, AR_FILE)
+
+
+def _num(v, maxlen=8):
+ v = str(v or "").strip()[:maxlen]
+ return v if re.fullmatch(r"[0-9]{0,4}(\.[0-9]{1,2})?", v) else None
+
+
+def op_ar_set(payload):
+ key = _isbn13(payload.get("isbn"))
+ level, points, quiz = _num(payload.get("level")), _num(payload.get("points")), re.sub(r"[^0-9]", "", str(payload.get("quiz") or ""))[:8]
+ interest = re.sub(r"[^A-Za-z+]", "", str(payload.get("interest") or ""))[:4].upper()
+ if not key or level is None or points is None:
+ return 400, {"status": "invalid", "message": "Level and points are numbers like 4.5 and 6."}
+ with LOCK:
+ d = _ar_load()
+ if level or points or quiz or interest:
+ d[key] = {"level": level, "points": points, "quiz": quiz, "interest": interest}
+ else:
+ d.pop(key, None)
+ _ar_save(d)
+ return 200, {"status": "saved"}
+
+
+def op_ar_import(payload):
+ """CSV with a header row; needs an isbn column and any of level, points, quiz."""
+ import csv, io
+ text = str(payload.get("csv") or "")[:2_000_000]
+ rows = list(csv.reader(io.StringIO(text)))
+ if len(rows) < 2:
+ return 400, {"status": "invalid", "message": "That file has no rows."}
+ head = [re.sub(r"[^a-z0-9]", "", h.lower()) for h in rows[0]]
+ def col(*names):
+ for n in names:
+ if n in head:
+ return head.index(n)
+ return None
+ cint = col("interest", "interestlevel", "il")
+ ci, cl, cp, cq = col("isbn", "isbn13"), col("level", "arlevel", "bl", "booklevel"), col("points", "arpoints", "pts"), col("quiz", "quizno", "quiznumber")
+ if ci is None or (cl is None and cp is None and cq is None):
+ return 400, {"status": "invalid", "message": "Need a header row with an isbn column and at least one of: level, points, quiz."}
+ done = skipped = 0
+ with LOCK:
+ d = _ar_load()
+ for r in rows[1:]:
+ get = lambda i: r[i] if i is not None and i < len(r) else ""
+ key = _isbn13(get(ci))
+ lv, pt = _num(get(cl)), _num(get(cp))
+ if not key or lv is None or pt is None:
+ skipped += 1
+ continue
+ old = d.get(key, {})
+ d[key] = {"level": lv or old.get("level", ""), "points": pt or old.get("points", ""),
+ "quiz": re.sub(r"[^0-9]", "", get(cq))[:8] or old.get("quiz", ""),
+ "interest": re.sub(r"[^A-Za-z+]", "", get(cint))[:4].upper() or old.get("interest", "")}
+ done += 1
+ _ar_save(d)
+ return 200, {"status": "imported", "count": done, "skipped": skipped}
+
+
+def export_csv():
+ """One row per copy: what you have, where it is, and any reading level on file."""
+ import csv, io
+ where = {l["value"]: l["label"] for l in get_locations()}
+ colls = {c["value"]: c["label"] for c in get_collections()}
+ ar = _ar_load()
+ out = io.StringIO()
+ w = csv.writer(out)
+ w.writerow(["title", "author", "isbn", "year", "barcode", "call number", "shelf", "collection", "status", "ar level", "ar points", "ar quiz", "ar interest"])
+ page = 1
+ while True:
+ books = koha("GET", "/biblios", query={"_per_page": "200", "_page": str(page), "_order_by": "title"}) or []
+ if not books:
+ break
+ ids = [b["biblio_id"] for b in books]
+ items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "2000"}) or []
+ for b in books:
+ isbn = b.get("isbn") or ""
+ a = ar.get(_isbn13(isbn) or "", {})
+ mine = [i for i in items if i["biblio_id"] == b["biblio_id"]] or [{}]
+ for i in mine:
+ w.writerow([(b.get("title") or "").rstrip(" /:"), b.get("author") or "", isbn, b.get("copyright_date") or "",
+ i.get("external_id") or "", i.get("callnumber") or "", where.get(i.get("location"), ""),
+ colls.get(i.get("collection_code"), ""), item_status(i) if i else "", a.get("level", ""), a.get("points", ""), a.get("quiz", ""), a.get("interest", "")])
+ if len(books) < 200:
+ break
+ page += 1
+ return out.getvalue().encode("utf-8-sig")
+
+
+# ------------------------------------------------------------------ tags
+TAGS_FILE = os.path.join(DATA_DIR, "tags.json")
+
+
+def _tags_load():
+ try:
+ d = json.load(open(TAGS_FILE))
+ return d if isinstance(d, dict) else {}
+ except (OSError, ValueError):
+ return {}
+
+
+def clean_tags(raw):
+ out, seen = [], set()
+ for t in (raw if isinstance(raw, list) else []):
+ t = re.sub(r"\s+", " ", str(t)).strip(" ,;#")[:30]
+ if t and t.lower() not in seen:
+ seen.add(t.lower())
+ out.append(t)
+ return out[:20]
+
+
+def tag_counts():
+ counts = {}
+ for tags in _tags_load().values():
+ for t in tags:
+ counts[t] = counts.get(t, 0) + 1
+ return [{"tag": t, "count": n} for t, n in sorted(counts.items(), key=lambda x: x[0].lower())]
+
+
+def op_tags_set(payload):
+ bid = payload.get("biblio_id")
+ if not isinstance(bid, int):
+ return 400, {"status": "invalid"}
+ tags = clean_tags(payload.get("tags"))
+ with LOCK:
+ d = _tags_load()
+ if tags:
+ d[str(bid)] = tags
+ else:
+ d.pop(str(bid), None)
+ save_json(TAGS_FILE, d)
+ return 200, {"status": "saved", "tags": tags, "all": tag_counts()}
+
+
+# ------------------------------------------------------- patron logins / my books
+LOGINS_FILE = os.path.join(DATA_DIR, "logins.json") # patron ids that have a login set up
+PW_WORDS = ["maple", "river", "cedar", "tiger", "lemon", "panda", "otter", "robin", "sunny", "cloud", "apple", "grape",
+ "daisy", "heron", "frost", "amber", "coral", "ember", "fable", "ivory", "brook", "pearl", "tulip", "wheat"]
+_LOGIN_FAILS = {} # client address -> [count, window start]
+_LOGIN_OK = {} # (user, sha256(pw)) -> (expires, patron_id)
+
+
+def new_password():
+ s = load_settings()
+ if s.get("patron_password_mode") == "same" and s.get("patron_password"):
+ return s["patron_password"]
+ return secrets.choice(PW_WORDS) + "%03d" % secrets.randbelow(1000)
+
+
+def _logins():
+ try:
+ d = json.load(open(LOGINS_FILE))
+ return d if isinstance(d, list) else []
+ except (OSError, ValueError):
+ return []
+
+
+def _mark_login(pid):
+ with LOCK:
+ d = _logins()
+ if pid not in d:
+ d.append(pid)
+ save_json(LOGINS_FILE, d)
+
+
+def set_patron_password(pid, pw):
+ koha("POST", "/patrons/%d/password" % pid, {"password": pw, "password_2": pw})
+ _mark_login(pid)
+
+
+def patron_login(user, pw, addr):
+ """Check a patron's card number / userid and password with Koha. Returns the patron id or None.
+ Failures are counted per address so a password cannot be guessed at speed."""
+ if not re.fullmatch(r"[A-Za-z0-9._@-]{1,40}", user or "") or not pw or len(pw) > 100:
+ return None
+ now = time.time()
+ fails = _LOGIN_FAILS.get(addr)
+ if fails and now - fails[1] < 300 and fails[0] >= 10:
+ return None
+ key = (user, hashlib.sha256(pw.encode()).hexdigest())
+ hit = _LOGIN_OK.get(key)
+ if hit and hit[0] > now:
+ return hit[1]
+ pid = None
+ try:
+ r = koha("POST", "/auth/password/validation", {"identifier": user, "password": pw})
+ pid = (r or {}).get("patron_id")
+ except KohaError:
+ pid = None
+ if pid:
+ _LOGIN_OK[key] = (now + 60, pid)
+ _LOGIN_FAILS.pop(addr, None)
+ return pid
+ f = _LOGIN_FAILS.get(addr)
+ if not f or now - f[1] >= 300:
+ f = _LOGIN_FAILS[addr] = [0, now]
+ f[0] += 1
+ return None
+
+
+def op_me(pid):
+ p = koha("GET", "/patrons/%d" % pid)
+ cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": pid}), "_per_page": "200", "_order_by": "due_date"},
+ headers={"x-koha-embed": "item"}) or []
+ return {"name": _patron_name(p), "card": p.get("cardnumber") or "", "loans": _loan_rows(cos)}
+
+
+def op_me_renew(pid, payload):
+ cid = payload.get("checkout_id")
+ if not isinstance(cid, int):
+ return 400, {"status": "invalid"}
+ co = koha("GET", "/checkouts/%d" % cid)
+ if not co or co.get("patron_id") != pid:
+ return 403, {"status": "error", "message": "That is not one of your books."}
+ return op_renew({"checkout_id": cid})
+
+
+def op_me_password(pid, addr, payload):
+ new = str(payload.get("new") or "")
+ if not 4 <= len(new) <= 40:
+ return 400, {"status": "invalid", "message": "Use 4 to 40 characters."}
+ p = koha("GET", "/patrons/%d" % pid)
+ if not patron_login(p.get("cardnumber") or p.get("userid") or "", str(payload.get("old") or ""), addr):
+ return 403, {"status": "error", "message": "The current password is not right."}
+ try:
+ set_patron_password(pid, new)
+ except KohaError as e:
+ return 200, {"status": "error", "message": "Koha would not accept that password: " + e.body[:100]}
+ _LOGIN_OK.clear()
+ return 200, {"status": "saved"}
+
+
+def op_reset_login(payload):
+ """Give someone a (new) login password and show it once."""
+ pid = payload.get("patron_id")
+ if not isinstance(pid, int):
+ return 400, {"status": "invalid"}
+ p = koha("GET", "/patrons/%d" % pid)
+ pw = new_password()
+ set_patron_password(pid, pw)
+ _LOGIN_OK.clear()
+ return 200, {"status": "saved", "name": _patron_name(p), "card": p.get("cardnumber") or "", "password": pw}
+
+
+def op_logins_all(payload):
+ """Logins for everyone who has none yet (people added before logins existed). Passwords are shown once."""
+ have = set(_logins())
+ made, page = [], 1
+ while True:
+ res = koha("GET", "/patrons", query={"q": json.dumps({"category_id": PATRON_CATEGORY}), "_per_page": "100", "_page": str(page)}) or []
+ for p in res:
+ if p["patron_id"] in have:
+ continue
+ pw = new_password()
+ try:
+ set_patron_password(p["patron_id"], pw)
+ except KohaError:
+ continue
+ made.append({"name": _patron_name(p), "card": p.get("cardnumber") or "", "password": pw})
+ if len(res) < 100:
+ break
+ page += 1
+ _LOGIN_OK.clear()
+ return 200, {"status": "done", "made": made}
+
+
+# ------------------------------------------------------------ title / author search
+def z3950_candidates(spec, term, limit=8):
+ """Title search (falling back to any field) on a Z39.50 server; returns summaries with an ISBN."""
+ out = []
+ for attr in ("@attr 1=4", "@attr 1=1016"):
+ with tempfile.TemporaryDirectory() as tmp:
+ dump = os.path.join(tmp, "rec.mrc")
+ q = term.replace('"', " ")
+ script = 'format usmarc\nfind %s "%s"\nshow 1+%d\nquit\n' % (attr, q, limit)
+ try:
+ res = subprocess.run(["yaz-client", "-m", dump, spec], input=script, capture_output=True, text=True,
+ timeout=Z3950_TIMEOUT).stdout
+ except (subprocess.TimeoutExpired, OSError):
+ return out
+ m = re.search(r"Number of hits: (\d+)", res)
+ if not m or int(m.group(1)) == 0 or not os.path.exists(dump):
+ continue
+ raw = open(dump, "rb").read()
+ args = ["yaz-marcdump", "-o", "marcxml"]
+ if len(raw) > 9 and raw[9:10] == b" ":
+ args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"]
+ conv = subprocess.run(args + [dump], capture_output=True, text=True, timeout=15)
+ if conv.returncode != 0:
+ continue
+ try:
+ root = ET.fromstring(conv.stdout if conv.stdout.lstrip().startswith("%s" % (NS["m"], conv.stdout))
+ except ET.ParseError:
+ continue
+ for rec in root.iter("{%s}record" % NS["m"]):
+ info = describe(rec)
+ isbn = ""
+ for df in rec.findall("m:datafield[@tag='020']", NS):
+ for sf in df.findall("m:subfield[@code='a']", NS):
+ c = clean_code((sf.text or "").split(" ")[0])
+ if c and len(c) in (10, 13) and not isbn:
+ isbn = isbn10_to_13(c) if len(c) == 10 else c
+ if isbn:
+ out.append(dict(info, isbn=isbn, source="Library of Congress"))
+ if out:
+ return out
+ return out
+
+
+def openlibrary_candidates(term, limit=10):
+ url = "https://openlibrary.org/search.json?" + urllib.parse.urlencode(
+ {"q": term, "limit": str(limit), "fields": "title,author_name,first_publish_year,isbn,publisher,cover_i"})
+ try:
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r:
+ docs = json.load(r).get("docs") or []
+ except (urllib.error.URLError, OSError, ValueError):
+ return []
+ out = []
+ for d in docs:
+ isbns = [clean_code(i) for i in (d.get("isbn") or [])]
+ isbns = [i for i in isbns if i and len(i) == 13 and i[:3] in ("978", "979")] or [isbn10_to_13(i) for i in isbns if i and len(i) == 10]
+ if not isbns or not d.get("title"):
+ continue
+ out.append({"title": d["title"], "author": ", ".join((d.get("author_name") or [])[:2]), "year": str(d.get("first_publish_year") or ""),
+ "publisher": ((d.get("publisher") or [""])[0])[:60], "isbn": isbns[0], "source": "Open Library"})
+ return out
+
+
+def op_lookup(term):
+ """Books matching a title/author, to choose from; each has an ISBN so choosing one is a normal add."""
+ term = re.sub(r"\s+", " ", term or "").strip()[:100]
+ if len(term) < 3:
+ return {"candidates": []}
+ found, seen = [], set()
+ for src in SOURCES:
+ got = []
+ if src == "openlibrary":
+ got = openlibrary_candidates(term)
+ elif src.startswith("z3950:"):
+ got = z3950_candidates(src[len("z3950:"):], term)
+ for c in got:
+ key = (c["isbn"], (c["title"] + c["author"]).lower()[:40])
+ if c["isbn"] in {k[0] for k in seen} or key in seen:
+ continue
+ seen.add(key)
+ found.append(c)
+ found = found[:12]
+ if found: # which of these do we already own? one query for all
+ conds = [{"isbn": {"-like": "%" + c["isbn"] + "%"}} for c in found] + [{"ean": {"-like": "%" + c["isbn"] + "%"}} for c in found]
+ try:
+ owned = " ".join((b.get("isbn") or "") + " " + (b.get("ean") or "") for b in
+ (koha("GET", "/biblios", query={"q": json.dumps({"-or": conds}), "_per_page": "50"}) or []))
+ except KohaError:
+ owned = ""
+ for c in found:
+ alt = isbn13_to_10(c["isbn"]) or ""
+ c["have"] = c["isbn"] in owned or bool(alt and alt in owned)
+ have = found
+ return {"candidates": have}
+
+
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
@@ -782,6 +1678,33 @@ def decode_photo(data, kind):
INDEX = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), "rb").read()
+_SESSION_CACHE = {}
+
+
+def koha_session_user(sid, ip):
+ """Ask Koha whether this staff session may edit the catalogue. Returns the user name or None.
+ Any problem (Koha starting, endpoint missing, odd reply) reads as 'not signed in', which just
+ means Quick Add asks for its own login."""
+ key, now = (sid, ip), time.time()
+ hit = _SESSION_CACHE.get(key)
+ if hit and hit[0] > now:
+ return hit[1]
+ user = None
+ try:
+ req = urllib.request.Request(KOHA_SESSION_URL + "?" + urllib.parse.urlencode({"sid": sid, "ip": ip}),
+ headers={"X-Quickadd-Secret": SSO_SECRET})
+ with urllib.request.urlopen(req, timeout=4) as r:
+ data = json.load(r)
+ if data.get("status") == "ok":
+ user = data.get("user") or "staff"
+ except Exception:
+ user = None
+ if len(_SESSION_CACHE) > 500:
+ _SESSION_CACHE.clear()
+ _SESSION_CACHE[key] = (now + (20 if user else 5), user) # short: logging out takes effect quickly
+ return user
+
+
class Handler(BaseHTTPRequestHandler):
server_version = "QuickAdd/1.0"
@@ -799,29 +1722,58 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy",
"default-src 'self'; img-src 'self' https: data:; "
- "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
+ "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-src https://www.arbookfind.com")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _role(self):
- """'admin', 'viewer' (read-only guest) or None."""
+ """'admin', 'viewer' (read-only guest) or None. Sets self.via to 'koha' for a staff-site login."""
+ self.via = None
+ self.patron_id = None
+ self.addr = self.client_address[0]
if not PASSWORD:
return None
header = self.headers.get("Authorization", "")
- if not header.startswith("Basic "):
+ if header.startswith("Basic "):
+ 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())
+ if is_admin:
+ return "admin"
+ if is_viewer:
+ return "viewer"
+ # Otherwise: a patron signing in with their card number (or userid) and password.
+ addr = self.client_address[0]
+ if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()):
+ addr = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip() or addr
+ self.addr = addr
+ pid = patron_login(u, p, addr)
+ if pid:
+ self.patron_id = pid
+ return "patron"
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
+ # No Basic login: accept a staff-site login, but only for requests that came through the staff
+ # site's web server (it overwrites X-Quickadd-Proxy with the shared secret on every request).
+ if SSO_SECRET and hmac.compare_digest(self.headers.get("X-Quickadd-Proxy", "").encode(), SSO_SECRET.encode()):
+ sid = ""
+ for part in self.headers.get("Cookie", "").split(";"):
+ k, _, v = part.strip().partition("=")
+ if k == "CGISESSID":
+ sid = v
+ # the browser's address as the web server saw it: the LAST entry (the one it appended)
+ ip = self.headers.get("X-Forwarded-For", "").split(",")[-1].strip()
+ if sid and ip and koha_session_user(sid, ip):
+ self.via = "koha"
+ return "admin"
+ return None
def _guard(self, need_admin=True):
role = self._role()
@@ -832,6 +1784,9 @@ class Handler(BaseHTTPRequestHandler):
if need_admin and role != "admin":
self._send(403, {"status": "error", "message": "This login can search but not change anything."})
return False
+ if self.via == "koha" and self.command == "POST" and self.headers.get("X-Quickadd") != "1":
+ self._send(403, {"status": "error", "message": "Missing request header."})
+ return False
self.role = role
return True
@@ -839,20 +1794,79 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/healthz":
return self._send(200, b"ok", "text/plain")
path = self.path.split("?")[0]
- if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search")):
+ if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search", "/api/library", "/api/me") and not path.startswith(("/api/book/", "/api/more/"))):
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": [],
+ if path == "/api/state" and self.role in ("viewer", "patron"):
+ return self._send(200, {"role": self.role, "library_name": library_name(), "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, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(),
+ return self._send(200, {"role": "admin", "library_name": library_name(), "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":
return self._send(200, settings_view())
+ if path in ("/api/patrons", "/api/out") or path.startswith("/api/patron/"):
+ try:
+ if path == "/api/out":
+ return self._send(200, op_out())
+ if path == "/api/patrons":
+ return self._send(200, op_patron_search((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0]))
+ m = re.match(r"^/api/patron/(\d{1,9})$", path)
+ if not m:
+ return self._send(404, {"error": "not found"})
+ return self._send(200, op_patron(int(m.group(1))))
+ except KohaError as e:
+ return self._send(404 if e.status == 404 else 502, {"status": "error", "message":
+ "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]})
+ if path == "/api/lookup":
+ q = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0]
+ try:
+ return self._send(200, op_lookup(q))
+ except KohaError:
+ return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
+ if path == "/api/me":
+ if self.role != "patron":
+ return self._send(403, {"status": "error", "message": "Sign in with a library card to see your books."})
+ try:
+ return self._send(200, op_me(self.patron_id))
+ except KohaError:
+ return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
+ if path == "/api/export.csv":
+ try:
+ body = export_csv()
+ except KohaError:
+ return self._send(502, {"status": "error"})
+ return self._send(200, body, "text/csv; charset=utf-8", {"Content-Disposition": 'attachment; filename="library.csv"'})
+ if path == "/api/qr":
+ try:
+ svg = qr_svg((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("t") or [""])[0])
+ except (ValueError, OSError, subprocess.SubprocessError):
+ return self._send(400, b"bad", "text/plain")
+ return self._send(200, svg, "image/svg+xml")
+ if path.startswith("/api/more/"):
+ m = re.match(r"^/api/more/(\d{1,9})$", path)
+ if not m:
+ return self._send(404, {"error": "not found"})
+ try:
+ return self._send(200, op_more(int(m.group(1))))
+ except KohaError:
+ return self._send(502, {"status": "error"})
+ if path == "/api/library" or path.startswith("/api/book/"):
+ try:
+ if path == "/api/library":
+ return self._send(200, dict(op_library(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)),
+ shelves=get_locations(), collections=get_collections(), types=get_item_types(), tags=tag_counts()))
+ m = re.match(r"^/api/book/(\d{1,9})$", path)
+ if not m:
+ return self._send(404, {"error": "not found"})
+ return self._send(200, op_book(int(m.group(1)), self.role == "admin"))
+ except KohaError as e:
+ return self._send(404 if e.status == 404 else 502, {"status": "error", "message":
+ "Koha is not answering yet - try again in a minute." if e.status == 503
+ else "Not found." if e.status == 404 else "Failed: %s" % e.body[:120]})
if path == "/api/search":
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
try:
@@ -863,6 +1877,25 @@ class Handler(BaseHTTPRequestHandler):
self._send(404, {"error": "not found"})
def do_POST(self):
+ if self.path.startswith("/api/me/"):
+ if not self._guard(need_admin=False):
+ return
+ if self.role != "patron":
+ return self._send(403, {"status": "error", "message": "Sign in with a library card first."})
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ payload = json.loads(self.rfile.read(min(length, 2000)) or b"{}")
+ if self.path == "/api/me/renew":
+ code, body = op_me_renew(self.patron_id, payload)
+ elif self.path == "/api/me/password":
+ code, body = op_me_password(self.patron_id, self.addr, payload)
+ else:
+ return self._send(404, {"error": "not found"})
+ return self._send(code, body)
+ except KohaError:
+ return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
+ except ValueError:
+ return self._send(400, {"status": "invalid"})
if not self._guard():
return
try:
@@ -877,12 +1910,15 @@ class Handler(BaseHTTPRequestHandler):
return self._send(415, {"status": "error", "message": "Please send a JPEG or PNG photo."})
return self._send(200, {"status": "ok", "codes": codes})
length = int(self.headers.get("Content-Length", "0"))
- if length > 20000:
+ if length > (2_200_000 if self.path == "/api/ar/import" else 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,
"/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings,
- "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign}
+ "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign,
+ "/api/item/update": op_item_update, "/api/item/delete": op_item_delete,
+ "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete,
+ "/api/value/add": op_value_add, "/api/tags/set": op_tags_set, "/api/patron/reset-login": op_reset_login, "/api/logins/all": op_logins_all, "/api/ar/set": op_ar_set, "/api/ar/import": op_ar_import, "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add}
fn = ops.get(self.path)
if not fn:
return self._send(404, {"error": "not found"})
diff --git a/library/quickadd/index.html b/library/quickadd/index.html
index 5d9306c..2b68bc7 100644
--- a/library/quickadd/index.html
+++ b/library/quickadd/index.html
@@ -3,7 +3,7 @@
-Add to library
+Room for Books
-
- Add to library
-
- Add books
- Find a book
- Manage
+
-
+
+
+
-
+
+ Shelf
+ Tag
+ Collection
+ Format
+ Status
+ Any status On the shelf Checked out
+ Sort
+ Sort: title Sort: author Sort: newest first
+
+
+
+ Show more
+
+ Export / import
+
+
+
+
+
+
+
+
Return a book
+
+
Return with camera Live return
+
+
+
+
Check out to…
+
+
Scan card or phone QR with camera Live scan
+ Photograph the library card, or the QR picture on the borrower's phone.
+
+
+
+
+ Checked out right now
+
+
+
+
+
+
+
+
+
+
What you have borrowed, and when it is due.
+
+ Change my password
+
+
+
+
+
+
+
+
+
+