#!/usr/bin/env python3 """Quick Add - scan an ISBN, the book is in the catalogue. A tiny web app that sits next to Koha. For each ISBN/UPC it: 1. looks the item up (Z39.50 servers first, then Open Library over HTTPS), 2. checks Koha for an existing copy, 3. adds the record + an item (next free barcode) through Koha's REST API. Standard library only; Z39.50 is done by shelling out to yaz-client. """ import base64 import hmac import json import os import re import secrets import subprocess import tempfile import threading import time import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/") USER = os.environ.get("ADMIN_USER", "admin") PASSWORD = os.environ.get("ADMIN_PASSWORD", "") # 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_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") STAFF_PORT = os.environ.get("STAFF_PORT", "8081") OPAC_PORT = os.environ.get("OPAC_PORT", "8080") # Set these when the site is behind a reverse proxy (different hostnames than host:port). OPAC_URL = os.environ.get("OPAC_URL", "").rstrip("/") STAFF_URL = os.environ.get("STAFF_URL", "").rstrip("/") DATA_DIR = os.environ.get("DATA_DIR", "/data") PORT = int(os.environ.get("PORT", "8082")) SOURCES = [s.strip() for s in os.environ.get( "QUICKADD_SOURCES", "z3950:lx2.loc.gov:210/LCDB,openlibrary").split(",") if s.strip()] Z3950_TIMEOUT = int(os.environ.get("Z3950_TIMEOUT", "12")) DEFAULT_TYPES = {"BOOK": "Book", "DVD": "Movie", "CD": "Music", "GAME": "Game", "OTHER": "Other"} NS = {"m": "http://www.loc.gov/MARC21/slim"} ET.register_namespace("", NS["m"]) RECENT_FILE = os.path.join(DATA_DIR, "recent.json") # ---------------------------------------------------------------- identifiers def clean_code(raw): code = re.sub(r"[\s-]", "", raw or "").upper() return code if re.fullmatch(r"[0-9]{8,14}|[0-9]{9}[0-9X]", code) else None def isbn10_to_13(i10): body = "978" + i10[:9] s = sum(int(c) * (1 if n % 2 == 0 else 3) for n, c in enumerate(body)) return body + str((10 - s % 10) % 10) def isbn13_to_10(i13): if not i13.startswith("978"): return None body = i13[3:12] s = sum(int(c) * (10 - n) for n, c in enumerate(body)) chk = (11 - s % 11) % 11 return body + ("X" if chk == 10 else str(chk)) def is_isbn(code): return len(code) == 10 or (len(code) == 13 and code[:3] in ("978", "979")) # -------------------------------------------------------------------- Z39.50 def z3950_search(spec, code): """spec = host:port/db. Returns MARCXML string or None.""" host_port_db = spec attrs = ["@attr 1=7"] if is_isbn(code) else ["@attr 1=1007"] queries = [code] if len(code) == 13 and is_isbn(code): alt = isbn13_to_10(code) if alt: queries.append(alt) for q in queries: with tempfile.TemporaryDirectory() as tmp: dump = os.path.join(tmp, "rec.mrc") script = "format usmarc\nfind %s %s\nshow 1\nquit\n" % (attrs[0], q) try: out = subprocess.run( ["yaz-client", "-m", dump, host_port_db], input=script, capture_output=True, text=True, timeout=Z3950_TIMEOUT).stdout except (subprocess.TimeoutExpired, OSError): return None m = re.search(r"Number of hits: (\d+)", out) if not m or int(m.group(1)) == 0 or not os.path.exists(dump): if "Connection refused" in out or "Connecting...error" in out or "Unable to" in out: return None continue raw = open(dump, "rb").read() args = ["yaz-marcdump", "-o", "marcxml"] if len(raw) > 9 and raw[9:10] == b" ": # MARC-8 -> UTF-8 args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"] res = subprocess.run(args + [dump], capture_output=True, text=True, timeout=10) if res.returncode == 0 and " (expires, plan): waiting for a scanned sticker PENDING_TTL = 1800 def prune_pending(): now = time.time() for k in [k for k, (exp, _) in PENDING.items() if exp < now]: del PENDING[k] def chosen_location(payload): """The shelf for this add: an explicit choice wins, else the saved default.""" if "location" in payload: return valid_location(payload.get("location")) return valid_location(load_settings().get("location")) def make_plan(payload): """Look the code up. Returns ("done", http_status, body) or ("plan", plan).""" code = clean_code(payload.get("code")) itype = valid_type(payload.get("type")) if not code: return "done", 400, {"status": "invalid", "message": "That doesn't look like an ISBN or barcode."} location = chosen_location(payload) with LOCK: existing = find_existing(code) if existing and not payload.get("copy"): return "done", 200, dict(existing, status="duplicate", code=code) if existing: return "plan", {"code": code, "itype": itype, "location": location, "existing": existing, "xml": None, "title": existing["title"], "author": existing["author"], "callnum": None, "source": "extra copy"} xml, source = lookup(code) if not xml: return "done", 200, {"status": "notfound", "code": code} rec = parse_marc(xml) info = describe(rec) ensure_identifier(rec, code) set_item_type(rec, itype) return "plan", {"code": code, "itype": itype, "location": location, "existing": None, "xml": ET.tostring(rec, encoding="utf-8"), "title": info["title"], "author": info["author"], "callnum": call_number(rec, info["author"]), "source": source} def commit(plan, scanned=None): mode = load_settings()["barcode_mode"] with LOCK: created = None if plan["existing"]: biblio_id = plan["existing"]["biblio_id"] else: bib = koha("POST", "/biblios", plan["xml"], "application/marcxml+xml", {"x-confirm-not-duplicate": "1"}) biblio_id = created = bib["id"] try: item = add_item(biblio_id, plan["itype"], plan["callnum"], plan["location"], mode, scanned) except KohaError: if created: # don't leave an item-less record behind try: koha("DELETE", "/biblios/%s" % created) except KohaError: pass raise entry = entry_for(plan["code"], plan["title"], plan["author"], item, biblio_id, plan["source"]) remember(entry) return dict(entry, status="added") def need_barcode(plan): token = secrets.token_urlsafe(12) prune_pending() PENDING[token] = (time.time() + PENDING_TTL, plan) return 200, {"status": "need_barcode", "token": token, "code": plan["code"], "title": plan["title"], "author": plan["author"]} def finish_or_ask(plan): if load_settings()["barcode_mode"] == "scan": return need_barcode(plan) return 200, commit(plan) def op_add(payload): kind, *rest = make_plan(payload) if kind == "done": return rest[0], rest[1] return finish_or_ask(rest[0]) def op_manual(payload): title = (payload.get("title") or "").strip() if not title: return 400, {"status": "invalid", "message": "A title is required."} code = clean_code(payload.get("code")) itype = valid_type(payload.get("type")) rec = manual_record(code, title[:240], (payload.get("author") or "").strip()[:120], (payload.get("year") or "").strip()[:8], (payload.get("publisher") or "").strip()[:120]) set_item_type(rec, itype) plan = {"code": code or "", "itype": itype, "location": chosen_location(payload), "existing": None, "xml": ET.tostring(rec, encoding="utf-8"), "title": title, "author": (payload.get("author") or "").strip(), "callnum": None, "source": "typed in"} return finish_or_ask(plan) STICKER_RE = re.compile(r"[A-Za-z0-9.$/+% -]{1,32}") def op_finish(payload): """Second step of 'scan a pre-printed sticker' mode.""" token = payload.get("token") barcode = (payload.get("barcode") or "").strip() prune_pending() entry = PENDING.get(token) if not entry: return 200, {"status": "error", "message": "That one timed out - scan the ISBN again."} if not STICKER_RE.fullmatch(barcode): return 200, {"status": "barcode_invalid", "token": token, "message": "That barcode has characters Koha can't use."} try: result = commit(entry[1], barcode) except KohaError as e: if e.status == 409 or "Duplicate" in e.body: return 200, {"status": "barcode_taken", "token": token, "barcode": barcode} raise PENDING.pop(token, None) return 200, result def op_cancel(payload): PENDING.pop(payload.get("token"), None) return 200, {"status": "cancelled"} def entry_for(code, title, author, item, biblio_id, source): return {"time": int(time.time()), "code": code, "title": title, "author": author, "barcode": item.get("external_id"), "callnumber": item.get("callnumber") or "", "item_id": item.get("item_id"), "biblio_id": biblio_id, "source": source, "location": next((l["label"] for l in get_locations() if l["value"] == item.get("location")), "")} def op_undo(payload): item_id = payload.get("item_id") biblio_id = payload.get("biblio_id") if not isinstance(item_id, int) or not isinstance(biblio_id, int): return 400, {"status": "invalid"} with LOCK: koha("DELETE", "/items/%d" % item_id) remaining = koha("GET", "/biblios/%d/items" % biblio_id) or [] if not remaining: # drop the now-empty record too try: koha("DELETE", "/biblios/%d" % biblio_id) except KohaError: pass forget(item_id) return 200, {"status": "undone"} # ----------------------------------------------------------- settings / tools def items_without_barcode(): found, page = [], 1 while len(found) < 5000: chunk = koha("GET", "/items", query={"q": json.dumps({"external_id": None}), "_per_page": "100", "_page": str(page), "_order_by": "item_id"}) or [] found += chunk if len(chunk) < 100: break page += 1 return found def settings_view(): s = load_settings() try: missing = len(items_without_barcode()) except KohaError: missing = None return {"settings": s, "next": fmt_barcode(peek_next(), s) if s["barcode_mode"] == "auto" else None, "without_barcode": missing, "locations": get_locations()} def op_settings(payload): with LOCK: s = clean_settings(payload, load_settings()) save_json(SETTINGS_FILE, s) return 200, settings_view() def op_reserve(payload): try: count = int(payload.get("count")) except (TypeError, ValueError): count = 0 if not 1 <= count <= 2000: return 400, {"status": "invalid", "message": "Choose between 1 and 2000."} s = load_settings() first = reserve(count) return 200, {"status": "reserved", "count": count, "first": fmt_barcode(first, s), "last": fmt_barcode(first + count - 1, s)} def op_assign(payload): """Give a barcode to every item that has none.""" s = load_settings() with LOCK: items = items_without_barcode() if not items: return 200, {"status": "assigned", "count": 0} first = reserve(len(items)) for i, it in enumerate(items): koha("PUT", "/biblios/%s/items/%s" % (it["biblio_id"], it["item_id"]), { "external_id": fmt_barcode(first + i, s), "item_type_id": it["item_type_id"], "home_library_id": it["home_library_id"], "holding_library_id": it["holding_library_id"]}) return 200, {"status": "assigned", "count": len(items), "first": fmt_barcode(first, s), "last": fmt_barcode(first + len(items) - 1, s)} # ------------------------------------------------------------------- search def item_status(it): if it.get("lost_status"): return "Lost" if it.get("withdrawn"): return "Withdrawn" if it.get("checked_out_date"): return "Checked out" if it.get("damaged_status"): return "Damaged" return "On shelf" def op_search(term): """Find books by title, author or ISBN; each result lists its copies with shelf and status.""" term = re.sub(r"[%_\\]", "", term or "").strip()[:80] if len(term) < 2: return {"results": []} like = {"-like": "%" + term + "%"} q = {"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]} biblios = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "25", "_order_by": "title"}) or [] if not biblios: return {"results": []} ids = [b["biblio_id"] for b in biblios] items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "500"}) or [] where = {l["value"]: l["label"] for l in get_locations()} by_bib = {} for it in items: by_bib.setdefault(it["biblio_id"], []).append({ "barcode": it.get("external_id"), "callnumber": it.get("callnumber") or "", "location": where.get(it.get("location"), ""), "status": item_status(it)}) return {"results": [{ "biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "", "year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]} # ------------------------------------------------------------ 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} - {""}) return {"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 = one("shelf"), one("type"), one("status") 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}]}) 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 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"), ""), "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()} 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 "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) 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 ""} # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 def image_kind(data): """Only JPEG and PNG are accepted: the decoder goes through ImageMagick, which has a long history of bugs in rarely used formats, so everything else is refused before it gets anywhere near it.""" if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:8] == b"\x89PNG\r\n\x1a\n": return "png" return None def decode_photo(data, kind): """Read barcodes from a photo with zbarimg. kind 'isbn' returns codes usable as an ISBN/UPC (ISBNs first); kind 'sticker' returns anything Koha could store as a barcode.""" ext = image_kind(data) if not ext: raise ValueError("not a JPEG or PNG image") with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "photo." + ext) with open(path, "wb") as f: f.write(data) try: out = subprocess.run(["zbarimg", "--raw", "-q", path], capture_output=True, text=True, timeout=ZBAR_TIMEOUT).stdout except (subprocess.TimeoutExpired, OSError): return [] codes = [] for line in out.splitlines(): line = line.strip() if not line: continue if kind == "sticker": ok = line if STICKER_RE.fullmatch(line) else None else: ok = clean_code(line) # zbar reports a UPC-A (12 digits) as a 13-digit EAN with a leading zero. # Use the 12 digits printed on the item, as a keyboard scanner would type # them, so lookups and duplicate checks match. if ok and len(ok) == 13 and ok[0] == "0": ok = ok[1:] if ok and ok not in codes: codes.append(ok) if kind != "sticker": # book ISBNs (978/979) before UPCs and short codes codes.sort(key=lambda c: 0 if (len(c) == 13 and c[:3] in ("978", "979")) else 1 if len(c) == 10 else 2) return codes # ----------------------------------------------------------------------- HTTP 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" def log_message(self, fmt, *args): pass def _send(self, code, body, ctype="application/json", extra=None): if not isinstance(body, bytes): body = json.dumps(body).encode() self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") 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'") 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. Sets self.via to 'koha' for a staff-site login.""" self.via = None if not PASSWORD: return None header = self.headers.get("Authorization", "") 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()) 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() if role is None: self._send(401, b"Login required", "text/plain", {"WWW-Authenticate": 'Basic realm="Library"'}) return False if need_admin and role != "admin": self._send(403, {"status": "error", "message": "This login can search but not change anything."}) return False 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 def do_GET(self): 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/")): return if path == "/": return self._send(200, INDEX, "text/html; charset=utf-8") if path == "/api/state" and self.role == "viewer": return self._send(200, {"role": "viewer", "library": LIBRARY, "recent": [], "types": {}, "locations": [], "settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []}) if path == "/api/state": return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(), "library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) if path == "/api/settings": return self._send(200, settings_view()) 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(), types=get_item_types())) 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: return self._send(200, op_search((qs.get("q") or [""])[0])) except KohaError as e: return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute." if e.status == 503 else "Search failed: %s" % e.body[:120]}) self._send(404, {"error": "not found"}) def do_POST(self): if not self._guard(): return try: if self.path.split("?")[0] == "/api/scan": length = int(self.headers.get("Content-Length", "0")) if not 0 < length <= MAX_IMAGE: return self._send(413, {"status": "error", "message": "That photo is too large."}) kind = "sticker" if "kind=sticker" in self.path else "isbn" try: codes = decode_photo(self.rfile.read(length), kind) except ValueError: 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: 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/item/update": op_item_update, "/api/item/delete": op_item_delete, "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete} fn = ops.get(self.path) if not fn: return self._send(404, {"error": "not found"}) code, body = fn(payload) self._send(code, body) except KohaError as e: msg = ("Koha is still starting up - try again in a minute." if e.status == 503 else "Koha refused that: %s" % e.body[:160]) self._send(502, {"status": "error", "message": msg}) except (ValueError, ET.ParseError): self._send(400, {"status": "invalid", "message": "Bad request."}) except Exception as e: # keep the scanner loop alive whatever happens self._send(500, {"status": "error", "message": "Unexpected error: %s" % e}) if __name__ == "__main__": if not PASSWORD: raise SystemExit("ADMIN_PASSWORD is required") os.makedirs(DATA_DIR, exist_ok=True) print("quick add listening on :%d sources=%s" % (PORT, SOURCES), flush=True) ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()