From 442b45c2e0a30eec358359696ed2e77a122fc4d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:04:45 +0000 Subject: [PATCH] library: renamed A Room for Books; multi-tags; borrower sign-in (card number + password) with My books, renew and change password; AR pop-up/frame Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 21 +- library/koha/ensure.pl | 38 ++- .../quickadd/__pycache__/app.cpython-313.pyc | Bin 112366 -> 127834 bytes library/quickadd/app.py | 266 +++++++++++++++++- library/quickadd/index.html | 162 +++++++++-- 5 files changed, 447 insertions(+), 40 deletions(-) diff --git a/library/README.md b/library/README.md index bd1a2ad..638d786 100644 --- a/library/README.md +++ b/library/README.md @@ -1,4 +1,4 @@ -# Home library: Koha + Quick Add (Docker) +# A Room for Books: a home library (Koha + the A Room for Books site, in Docker) A self-hosted library catalogue you can set up in one command: @@ -255,3 +255,22 @@ browsers send small camera frames to the server's decoder a couple of times a se 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/koha/ensure.pl b/library/koha/ensure.pl index 5ef74a6..375b097 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 = '

A 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 (?, 'A Room for Books', ?, 'default')}, undef, $id, $html ); + say "ensure: added the staff home 'A Room for Books' block"; +} +else { + $dbh->do( q{UPDATE additional_contents_localizations SET title = 'A Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'}, + undef, $html, $id ); } diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 507af44bc03c34c2241a62a835c78a3a02c69779..3d80282702d8ce02b214260de31d91435772a284 100644 GIT binary patch delta 28037 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 diff --git a/library/quickadd/app.py b/library/quickadd/app.py index e810082..7e3cfcc 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 @@ -353,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() @@ -388,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): @@ -763,7 +770,7 @@ def _shape_book(b, items, where): 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 {"collections": collections, "id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "", + 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 ""} @@ -774,7 +781,7 @@ def op_library(qs): def one(k): return (qs.get(k) or [""])[0] term = re.sub(r"[%_\\]", "", one("q")).strip()[:80] - shelf, itype, status, coll = one("shelf"), one("type"), one("status"), one("collection") + 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)) @@ -784,6 +791,11 @@ def op_library(qs): 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 == "-": @@ -860,7 +872,8 @@ def op_book(biblio_id, staff): "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()} + "shelves": get_locations(), "collections": get_collections(), + "tags": _tags_load().get(str(b["biblio_id"]), []), "all_tags": tag_counts()} def op_item_update(payload): @@ -903,6 +916,9 @@ def op_book_delete(payload): 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"} @@ -1093,7 +1109,15 @@ def op_patron_add(payload): 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 - return 200, dict(_shape_patron(p), status="added") + 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 @@ -1326,6 +1350,183 @@ def export_csv(): 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} + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -1426,7 +1627,7 @@ 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() @@ -1435,6 +1636,8 @@ class Handler(BaseHTTPRequestHandler): def _role(self): """'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", "") @@ -1448,7 +1651,20 @@ class Handler(BaseHTTPRequestHandler): 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 + 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 # 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()): @@ -1483,12 +1699,12 @@ 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", "/api/library") and not path.startswith(("/api/book/", "/api/more/"))): + 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": @@ -1510,6 +1726,13 @@ class Handler(BaseHTTPRequestHandler): 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/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() @@ -1534,7 +1757,7 @@ class Handler(BaseHTTPRequestHandler): 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())) + 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"}) @@ -1553,6 +1776,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: @@ -1575,7 +1817,7 @@ class Handler(BaseHTTPRequestHandler): "/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/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} + "/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 7fc17f9..3431336 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -3,7 +3,7 @@ -Library +A Room for Books