library: login for everything - public catalogue off, read-only guest for Find a book
- the public catalogue is bound to the machine itself by default (OPAC_BIND) and, if ever exposed, requires a library-card login (OpacPublic=0) - a read-only guest login (VIEWER_USER/VIEWER_PASSWORD) in Quick Add: it may load the page, read state and search; every other endpoint is admin-only (403); the page shows only Find a book - the guest deliberately has NO Koha account: Koha's 'catalogue' permission, the only one that allows searching, also allows creating/editing label layouts and templates (tested: a guest created a layout), so it cannot be read-only - koha/ensure.pl (every start): staff-home 'Library tools' block, and removal of a guest account an earlier version created - kiosk add-on shows Find a book signed in as the guest; the admin login never goes into the kiosk config (tested, incl. a mutation check); old public-catalogue tabs are cleaned up - existing installs keep their current public-catalogue behaviour on update Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
+33
-17
@@ -27,6 +27,9 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
|
||||
USER = os.environ.get("ADMIN_USER", "admin")
|
||||
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||
# Optional read-only login to hand out: may open the page and search, nothing else.
|
||||
VIEWER_USER = os.environ.get("VIEWER_USER", "")
|
||||
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
||||
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
|
||||
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
|
||||
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
|
||||
@@ -802,37 +805,50 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _authed(self):
|
||||
def _role(self):
|
||||
"""'admin', 'viewer' (read-only guest) or None."""
|
||||
if not PASSWORD:
|
||||
return False
|
||||
return None
|
||||
header = self.headers.get("Authorization", "")
|
||||
if header.startswith("Basic "):
|
||||
try:
|
||||
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
||||
ok_u = hmac.compare_digest(u.encode(), USER.encode())
|
||||
ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode())
|
||||
return ok_u and ok_p
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
return False
|
||||
if not header.startswith("Basic "):
|
||||
return None
|
||||
try:
|
||||
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
ub, pb = u.encode(), p.encode()
|
||||
# Both comparisons always run, so timing does not reveal which account exists.
|
||||
is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode())
|
||||
is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \
|
||||
& hmac.compare_digest(pb, VIEWER_PASSWORD.encode())
|
||||
return "admin" if is_admin else "viewer" if is_viewer else None
|
||||
|
||||
def _guard(self):
|
||||
if not self._authed():
|
||||
def _guard(self, need_admin=True):
|
||||
role = self._role()
|
||||
if role is None:
|
||||
self._send(401, b"Login required", "text/plain",
|
||||
{"WWW-Authenticate": 'Basic realm="Library quick add"'})
|
||||
{"WWW-Authenticate": 'Basic realm="Library"'})
|
||||
return False
|
||||
if need_admin and role != "admin":
|
||||
self._send(403, {"status": "error", "message": "This login can search but not change anything."})
|
||||
return False
|
||||
self.role = role
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
return self._send(200, b"ok", "text/plain")
|
||||
if not self._guard():
|
||||
return
|
||||
path = self.path.split("?")[0]
|
||||
if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search")):
|
||||
return
|
||||
if path == "/":
|
||||
return self._send(200, INDEX, "text/html; charset=utf-8")
|
||||
if path == "/api/state" and self.role == "viewer":
|
||||
return self._send(200, {"role": "viewer", "library": LIBRARY, "recent": [], "types": {}, "locations": [],
|
||||
"settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL,
|
||||
"opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []})
|
||||
if path == "/api/state":
|
||||
return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(),
|
||||
return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(),
|
||||
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
||||
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||
if path == "/api/settings":
|
||||
|
||||
@@ -455,10 +455,12 @@
|
||||
|
||||
// ---- under the staff site (/add/) everything is same-origin
|
||||
const embedded = location.pathname.startsWith('/add/');
|
||||
let viewer = false; // the read-only guest login: search only
|
||||
|
||||
// ---- views: Add books / Find a book
|
||||
function showView() {
|
||||
const find = location.hash === '#find';
|
||||
const find = viewer || location.hash === '#find';
|
||||
$('tab-add').hidden = viewer;
|
||||
$('view-add').hidden = find; $('view-find').hidden = !find;
|
||||
$('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find);
|
||||
$('title').textContent = find ? 'Find a book' : 'Add to library';
|
||||
@@ -482,18 +484,22 @@
|
||||
el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '',
|
||||
el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status)))
|
||||
: [el('div', { className: 'small' }, 'No copies recorded.')]),
|
||||
el('div', { className: 'small', style: 'margin-top:6px' },
|
||||
viewer ? '' : el('div', { className: 'small', style: 'margin-top:6px' },
|
||||
el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record')))));
|
||||
}
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
|
||||
|
||||
(async () => {
|
||||
const s = await api('/api/state');
|
||||
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
||||
viewer = s.role === 'viewer';
|
||||
types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons();
|
||||
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
||||
staffBase = s.staff_url || '';
|
||||
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
|
||||
// The public catalogue is off by default: only link to it when a public address was configured.
|
||||
$('lnk-opac').hidden = !s.opac_url;
|
||||
if (s.opac_url) $('lnk-opac').href = s.opac_url + '/';
|
||||
$('lnk-staff').href = staffRoot() + '/';
|
||||
$('lnk-staff').hidden = viewer;
|
||||
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
|
||||
if (s.locations && s.locations.length) {
|
||||
const sel = $('shelf');
|
||||
@@ -504,7 +510,7 @@
|
||||
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
|
||||
$('shelfbox').hidden = false;
|
||||
}
|
||||
api('/api/settings').then(applySettings);
|
||||
if (!viewer) api('/api/settings').then(applySettings);
|
||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||
showView();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user