6 Commits
Author SHA1 Message Date
Outis 9eadf266f9 Merge pull request #136 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 17:15:49 -05:00
Claude 770a6a1dfb 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
2026-11-06 22:09:06 +00:00
Claude 522a33b127 library: one address - Add books inside the staff site, plus Find a book
- Apache in the Koha container serves Quick Add at /add/ on the staff site
  (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves
- Quick Add: relative API paths so it works at / and /add/; Add books / Find a
  book tabs; search by title, author or ISBN showing each copy's shelf and status
  (/api/search); Manage and Public catalogue links
- staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05
  stores it) linking to Add books
- installer, kiosk add-on and READMEs advertise one main address; the public
  catalogue stays on its own port (Koha gives public and staff sites the same URL
  shapes, so they cannot share one address)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 21:38:09 +00:00
Outis 886745a979 Merge pull request #135 from outis1one/claude/optimistic-clarke-27gpk1
Web UI login with the kiosk's PIN/password; shortcuts list on the Sites page
2026-11-06 13:48:43 -05:00
Claude e0fa69d470 Web UI login with the kiosk's PIN/password; shortcuts list on the Sites page
When the kiosk's password protection is on, the Web UI now asks for the same
PIN/password the lock screen uses: random session cookie (12 h, HttpOnly,
SameSite=Strict), the lock screen's wrong-code slowdown, sessions end when
the kiosk's code changes, sign out. With protection off nothing changes.
The Sites page (Web UI and terminal menu) lists the back/forward/home and
site-switching keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:47:00 +00:00
Outis 84e8f074e8 Merge pull request #134 from outis1one/claude/optimistic-clarke-27gpk1
Web UI default port 8888; Lock now in the power menu
2026-11-06 13:37:28 -05:00
24 changed files with 783 additions and 103 deletions
+2 -1
View File
@@ -507,6 +507,7 @@ Host myserver
- **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install
- **X server key combinations disabled** (Ctrl+Alt+Backspace)
- **Right-click disabled** in kiosk app
- **Web UI login** - when password protection is on, the Web UI asks for the same PIN/password as the lock screen
- **Screen blanking disabled** with schedule awareness
- **DPMS management** - Aggressive keep-alive with schedule respect
@@ -1596,7 +1597,7 @@ full migration pass.
**Previous (v2.16.0):**
- **New: Web UI** (Addons → Web UI) — a small Node/Express app (`webui/`) giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout, the three Core Settings menus that are pure `config.json` read/write with no privileged system mutation involved. Runs as a systemd service under `$KIOSK_USER` (the same user Electron runs as), so it never needs `sudo` — it reads/writes `config.json` with normal filesystem permissions.
- `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk.
- **Login follows the kiosk's password protection.** With password protection on (Core Settings → Password Protection & Lockout), the Web UI asks for the same PIN/password the lock screen uses (a session lasts 12 hours; 5 wrong tries in a row slow it down like the lock screen does; changing the code signs every browser out). With protection off there is no login, so beyond a trusted LAN turn protection on or put it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth — Authelia integration is out of scope for this repo. Direct LAN access with protection off has no authentication at all — treat it like SSH access to the kiosk.
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up.
- Wired into Complete Uninstall (`webui_do_uninstall`) and Clone Settings (addon-presence detection) the same way every other addon is.
- This is the single-kiosk piece of the web-based GUI mentioned in this doc's "Modular Management" notes for a while — a central multi-kiosk fleet dashboard is an intentional follow-up, not part of this pass.
+2 -2
View File
@@ -424,8 +424,8 @@ run_first_time_install() {
echo " - Audio support (PipeWire)"
echo " - Time synchronization (NTP)"
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
echo " addon install/reconfigure and Update - no login of its own,"
echo " see Addons -> Web UI)"
echo " addon install/reconfigure and Update - asks for the kiosk's"
echo " PIN/password when password protection is on, see Addons -> Web UI)"
echo
echo "OPTIONAL (configure after install, via Addons):"
echo " - Lyrion Music Server (LMS) / Squeezelite"
+11 -4
View File
@@ -12,12 +12,19 @@ it never overwrites your `.env` (passwords) or your data.
| What | Address | Login |
|---|---|---|
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
Ports move up to the next free number if one is taken; the final addresses are printed
at the end and by `sudo ./install.sh --status`.
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
(`8082`), which is handy behind a proxy but not needed otherwise.
## What it is made of
+7 -4
View File
@@ -30,8 +30,11 @@ services:
depends_on:
db:
condition: service_healthy
quickadd:
condition: service_started
environment:
TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
@@ -41,7 +44,8 @@ services:
KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports:
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
volumes:
- koha-data:/var/lib/koha
@@ -53,14 +57,13 @@ services:
image: library-quickadd:local
container_name: library-quickadd
restart: unless-stopped
depends_on:
koha:
condition: service_started
environment:
TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8081}
+17 -4
View File
@@ -79,10 +79,14 @@ show_status() {
fi
dc ps
echo
echo " Catalogue (public): http://$ip:$OPAC_PORT"
echo " Staff interface: http://$ip:$STAFF_PORT"
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
echo " Your library: http://$ip:$STAFF_PORT"
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
echo " It opens the Find a book page: search only, it cannot add or change anything."
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
fi
echo
}
@@ -117,8 +121,12 @@ OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16)
VIEWER_USER=guest
VIEWER_PASSWORD=$(rand 14)
DB_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable
@@ -131,6 +139,11 @@ else
if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
fi
if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
fi
# Existing installs keep their current public-catalogue behaviour.
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
if ! grep -q '^BIND_ADDR=' .env; then
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
fi
+1 -1
View File
@@ -39,7 +39,7 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
COPY seed/ /opt/koha-init/seed/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
+2 -2
View File
@@ -160,11 +160,11 @@ my %prefs = (
finesMode => 'off', # a home library does not charge overdue fines
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
RESTBasicAuth => 1, # lets the quick-add page use the REST API
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
OpenLibraryCovers => 1, # cover images in the catalogue
OPACSearchForTitleIn => '',
BiblioDefaultView => 'normal',
OpacPublic => 1,
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
opacuserlogin => 1,
OpacMaintenance => 0,
ExpireReservesMaxPickUpDelay => 0,
AllowOfflineCirculation => 0,
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/perl
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
# the schema upgrade), so existing installs pick these up too:
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
# * the "Library tools" block on the staff home page
use Modern::Perl;
use C4::Context;
use Koha::Patrons;
use Koha::Patron::Categories;
my $dbh = C4::Context->dbh;
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
$p->delete;
}
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
}
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool).
my ($have) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM additional_contents WHERE code = 'library_tools'});
unless ($have) {
my $html = '<div class="main-heading"><h3>Library tools</h3></div><ul class="list-unstyled">'
. '<li><a class="btn btn-primary" href="/add/#find">Find a book</a></li>'
. '<li><a href="/add/">Add books (scan an ISBN)</a></li></ul>';
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html );
say "ensure: added the staff home 'Library tools' block";
}
+19
View File
@@ -56,6 +56,20 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
# (The staff and public catalogue sites cannot share one address: Koha gives both the
# same URL shapes. The staff site is the natural home for a home library.)
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
cat > /etc/koha/apache-quickadd.conf << APACHE
<IfModule mod_proxy_http.c>
RedirectMatch ^/add\$ /add/
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
</IfModule>
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).
@@ -94,6 +108,11 @@ else
FIRST_RUN=0
fi
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
perl /opt/koha-init/ensure.pl \
|| log "WARNING: ensure.pl reported a problem (see above)"
# --- 5. background services (their `daemon` processes die with the container,
# so they are started explicitly on every start) ------------------------
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
+76 -17
View File
@@ -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")
@@ -687,6 +690,42 @@ def op_assign(payload):
"last": fmt_barcode(first + len(items) - 1, s)}
# ------------------------------------------------------------------- search
def item_status(it):
if it.get("lost_status"):
return "Lost"
if it.get("withdrawn"):
return "Withdrawn"
if it.get("checked_out_date"):
return "Checked out"
if it.get("damaged_status"):
return "Damaged"
return "On shelf"
def op_search(term):
"""Find books by title, author or ISBN; each result lists its copies with shelf and status."""
term = re.sub(r"[%_\\]", "", term or "").strip()[:80]
if len(term) < 2:
return {"results": []}
like = {"-like": "%" + term + "%"}
q = {"-or": [{"title": like}, {"author": like}, {"isbn": like}, {"ean": like}]}
biblios = koha("GET", "/biblios", query={"q": json.dumps(q), "_per_page": "25", "_order_by": "title"}) or []
if not biblios:
return {"results": []}
ids = [b["biblio_id"] for b in biblios]
items = koha("GET", "/items", query={"q": json.dumps({"biblio_id": ids}), "_per_page": "500"}) or []
where = {l["value"]: l["label"] for l in get_locations()}
by_bib = {}
for it in items:
by_bib.setdefault(it["biblio_id"], []).append({
"barcode": it.get("external_id"), "callnumber": it.get("callnumber") or "",
"location": where.get(it.get("location"), ""), "status": item_status(it)})
return {"results": [{
"biblio_id": b["biblio_id"], "title": (b.get("title") or "").rstrip(" /:"), "author": b.get("author") or "",
"year": b.get("copyright_date") or "", "copies": by_bib.get(b["biblio_id"], [])} for b in biblios]}
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
@@ -766,41 +805,61 @@ 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":
return self._send(200, settings_view())
if path == "/api/search":
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
try:
return self._send(200, op_search((qs.get("q") or [""])[0]))
except KohaError as e:
return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."
if e.status == 503 else "Search failed: %s" % e.body[:120]})
self._send(404, {"error": "not found"})
def do_POST(self):
+79 -10
View File
@@ -44,6 +44,20 @@
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }
nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; }
nav.tabs a.on { background: var(--accent); color: var(--accent-ink); }
nav.tabs a.ext::after { content: ' \2197'; font-size: .85em; }
.find-form { display: flex; gap: 8px; margin: 10px 0; }
.find-form input { flex: 1; font-size: 1.3rem; padding: 12px 14px; border: 2px solid var(--line); border-radius: 12px;
background: var(--card); color: var(--ink); min-width: 0; }
.hit { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
.hit .t { font-weight: 700; font-size: 1.05rem; overflow-wrap: anywhere; }
.hit a { color: var(--accent); }
.copy { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0 0; }
.chip { border-radius: 999px; padding: 1px 10px; font-size: .88rem; font-weight: 600; border: 1px solid var(--line); }
.chip.in { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); }
.chip.out { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
@@ -85,12 +99,26 @@
<body>
<main>
<header>
<h1>Add to library</h1>
<nav aria-label="Library links">
<a id="lnk-opac" href="#">Catalogue</a><a id="lnk-staff" href="#">Staff</a>
<h1 id="title">Add to library</h1>
<nav class="tabs" aria-label="Sections">
<a id="tab-add" href="#add" class="on">Add books</a>
<a id="tab-find" href="#find">Find a book</a>
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
</nav>
</header>
<section id="view-find" hidden>
<form class="find-form" id="findform" autocomplete="off">
<label class="sr" for="q">Title, author or ISBN</label>
<input id="q" type="search" placeholder="Title, author or ISBN" enterkeyhint="search">
<button class="primary" type="submit">Find</button>
</form>
<div id="found" aria-live="polite"></div>
</section>
<section id="view-add">
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
<div class="shelf" id="shelfbox" hidden>
<label for="shelf">Shelf / room</label>
@@ -149,6 +177,7 @@
<h2>Earlier</h2>
<ul class="list" id="earlier"></ul>
</section>
</main>
<script>
@@ -182,7 +211,7 @@
};
async function api(path, body) {
const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {});
return r.json();
}
@@ -300,7 +329,7 @@
$('status').textContent = 'Reading the barcode…';
try {
const blob = await shrink(f);
const r = await fetch('/api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob });
resolve(r.ok ? (await r.json()).codes : []);
} catch (e) { resolve([]); }
};
@@ -351,7 +380,7 @@
}
let staffBase = '';
const staffRoot = () => staffBase || ('http://' + location.hostname + ':' + staffPort);
const staffRoot = () => staffBase || (location.pathname.startsWith('/add/') ? location.origin : 'http://' + location.hostname + ':' + staffPort);
const printLink = (from, to) => staffRoot() +
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
@@ -424,13 +453,53 @@
}));
}
// ---- 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 = 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';
document.title = find ? 'Find a book' : 'Add to library';
if (find) $('q').focus(); else $('code').focus();
}
window.addEventListener('hashchange', showView);
async function find(term) {
const box = $('found');
if (term.trim().length < 2) { box.replaceChildren(el('div', { className: 'small' }, 'Type at least two letters.')); return; }
box.replaceChildren(el('div', { className: 'small' }, 'Searching…'));
let r;
try { r = await api('/api/search?q=' + encodeURIComponent(term)); } catch (e) { r = { status: 'error' }; }
if (!r.results) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message || 'Search failed.'))); return; }
if (!r.results.length) { box.replaceChildren(el('div', { className: 'small' }, 'No matches for "' + term + '".')); return; }
box.replaceChildren(...r.results.map(b => el('div', { className: 'hit' },
el('div', { className: 't' }, b.title), el('div', { className: 'meta' }, [b.author, b.year].filter(Boolean).join(' · ')),
...(b.copies.length ? b.copies.map(c => el('div', { className: 'copy' },
c.barcode ? el('span', { className: 'badge' }, c.barcode) : '',
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.')]),
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');
@@ -441,9 +510,9 @@
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)));
$('code').focus();
showView();
})();
})();
</script>
+37 -27
View File
@@ -1,29 +1,30 @@
#!/bin/bash
################################################################################
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
# catalogue (Koha) running in Docker, shown on the kiosk as a public
# search terminal.
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
#
# What gets installed (all from this checkout's library/ directory, deployed to
# $LIBRARY_DIR - see library/README.md for the stack itself):
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
# interface (cataloguing, Z39.50 lookup, label printing).
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
# - MariaDB.
# Docker itself is installed on demand (lib/docker.sh).
#
# On the kiosk the catalogue is added as one of the Sites (tabs) with
# duration 0 (stay put - it's a search terminal, not a rotating display).
# Staff and Quick Add are meant for the librarian's own device; whether they
# are reachable from the LAN or only from this kiosk is the "Network access"
# choice below. NOTE: Docker publishes ports straight through iptables and
# bypasses ufw, so "this kiosk only" is enforced by binding the published
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
# all, because Koha's own search permission also allows editing label layouts); the page
# list stores that guest login like any other Basic Auth site. The admin login never
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
#
# Whether other devices on the LAN can reach the library site is the "Network access"
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
# (BIND_ADDR in library/.env), not by firewall rules.
#
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
################################################################################
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
################################################################################
# Detection / state
@@ -42,7 +43,13 @@ library_is_running() {
[[ "$(docker_container_state library-koha)" == "running" ]]
}
# The page the kiosk shows: Quick Add's Find a book view on the library site.
library_catalogue_url() {
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
}
# Pages from earlier versions of this add-on pointed at the public catalogue.
library_old_catalogue_url() {
echo "http://localhost:$(library_env_get OPAC_PORT)/"
}
@@ -66,19 +73,19 @@ addon_library_status() {
health="$(docker_container_health library-koha)"
ip="$(get_ip_address)"
echo "Library Catalog: ${state}${health:+ ($health)}"
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
echo " Network: this kiosk only"
else
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
fi
}
addon_library_menu_builder() {
if library_is_installed; then
load_existing_config
local tab_label="Show the catalogue on the kiosk"
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
local tab_label="Show Find a book on the kiosk"
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
else
@@ -111,13 +118,13 @@ library_sync_files() {
library_add_tab() {
load_existing_config
if library_tab_exists; then
log_info "The catalogue is already one of the kiosk's pages."
log_info "Find a book is already one of the kiosk's pages."
return 0
fi
URLS+=("$(library_catalogue_url)")
DURS+=("0")
USERS+=("")
PASSES+=("")
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
save_config
}
@@ -125,11 +132,12 @@ library_add_tab() {
# Drop every page that points at the catalogue's local address.
library_remove_tab() {
load_existing_config
local url idx
local url old idx
url="$(library_catalogue_url)"
old="$(library_old_catalogue_url)"
local -a nu=() nd=() nus=() np=() nn=()
for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" ]] && continue
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
done
@@ -199,7 +207,7 @@ action_library_install() {
return 1
fi
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
library_add_tab
fi
echo
@@ -217,10 +225,12 @@ action_library_show_access() {
ip="localhost"
echo "(Network access is limited to this kiosk.)"
fi
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
echo " Login (staff and add books): ${user} / ${pass}"
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
echo " Admin login: ${user} / ${pass}"
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
echo " it cannot add or change anything (and it has no login on the staff site itself)."
echo
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
pause
+13 -9
View File
@@ -30,12 +30,15 @@
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
# stays terminal-only for now.
#
# No login of its own, by design: this addon assumes it'll be put behind
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the
# same way other self-hosted apps get protected - Authelia integration
# is explicitly out of scope for this repo (Authelia runs elsewhere).
# Direct LAN access with no proxy in front has no authentication at all -
# treat it the same as SSH access to this kiosk, which is also the
# Login: when the kiosk's password protection is on (Core Settings ->
# Password Protection & Lockout), the Web UI asks for the same PIN/password
# the lock screen uses (webui/lib/auth.js). With protection off it has no
# login, so on a network you don't fully trust, turn protection on or put
# it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth in
# front, the same way other self-hosted apps get protected - Authelia
# integration is explicitly out of scope for this repo (Authelia runs
# elsewhere). Direct LAN access with protection off has no authentication
# at all - treat it the same as SSH access to this kiosk, which is also the
# access level the privileged helper effectively grants if reached
# without a proxy in front: bounded to its fixed action list, not a
# root shell, but real system mutation all the same.
@@ -274,9 +277,10 @@ action_webui_install() {
echo "This installs a small web app for editing Sites & Page Timing,"
echo "Display & Interaction, and Password Protection & Lockout from a"
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
echo "and Asterisk Intercom, and checking for updates. It has no login"
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +"
echo "Authelia) if it needs to be reachable beyond a trusted LAN."
echo "and Asterisk Intercom, and checking for updates. When the kiosk's"
echo "password protection is on it asks for the same PIN/password; when it"
echo "is off there is no login - turn protection on, or put it behind your"
echo "own reverse proxy (e.g. Caddy + Authelia), beyond a trusted LAN."
echo
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
+2
View File
@@ -89,6 +89,8 @@ sites_status() {
else
echo "Auto-rotation: off (all pages manual/hidden)"
fi
echo "Keys on the kiosk: Alt+Left back, Alt+Right forward, Alt+Home this page's own address,"
echo " Ctrl+Tab / Ctrl+PageDown next site, Ctrl+Shift+Tab / Ctrl+PageUp previous"
if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
else
+28 -14
View File
@@ -74,6 +74,8 @@ STAFF_PORT=8081
QUICKADD_PORT=8082
ADMIN_USER=admin
ADMIN_PASSWORD=s3cretPASS
VIEWER_USER=guest
VIEWER_PASSWORD=guestPASS
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ENV
echo running > "$STUB_STATE"
@@ -98,7 +100,7 @@ source "$REPO/menus/addon_library.sh"
export KIOSK_UNATTENDED=0
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
# ============================================================================
echo "== lib/docker.sh"
@@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
check "is_installed is now true" library_is_installed
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
addon_library_status > "$T/status.out"
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
@@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
# ============================================================================
echo "== menu while installed"
addon_library_menu_builder
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
echo "== tab toggling is idempotent"
library_add_tab >/dev/null 2>&1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
addon_library_menu_builder
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
# ============================================================================
echo "== an earlier version's tab (public catalogue address) is cleaned up"
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
library_remove_tab >/dev/null 2>&1
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
library_add_tab >/dev/null 2>&1
echo "== network access"
: > "$STUB_LOG"
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
@@ -168,8 +180,10 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
# ============================================================================
echo "== access screen"
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out"
check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out"
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
# ============================================================================
echo "== update"
@@ -185,7 +199,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
echo "== purge needs the typed confirmation"
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
@@ -195,8 +209,8 @@ library_add_tab >/dev/null 2>&1
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
check "install dir removed" test ! -e "$LIBRARY_DIR"
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
echo
echo "passed: $PASS failed: $FAIL"
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// webui/lib/auth.js - login for the Web UI, using the SAME code the kiosk's
// lock screen uses (Core Settings -> Password Protection & Lockout: a PIN or
// a password).
//
// Only when the kiosk's password protection is on. With it off, the Web UI
// is open as before (it is meant for a trusted LAN, or behind a reverse
// proxy with its own login - see server.js).
//
// A correct code gives the browser a random session cookie (kept in memory;
// a restart of the service logs everyone out). Wrong codes are slowed down
// the same way the lock screen does it: after 5 wrong tries in a row, a
// wait of 30 s that doubles up to 5 minutes. Changing the kiosk's code logs
// every browser out, because sessions are tied to the stored code.
const crypto = require('crypto');
const { readLockSettings, hashPassword } = require('./config');
const COOKIE = 'kioskwebui';
const SESSION_MS = 12 * 60 * 60 * 1000; // 12 hours
const FREE_TRIES = 5;
const BASE_WAIT_S = 30;
const MAX_WAIT_S = 300;
const sessions = new Map(); // token -> { expires, fp }
let failures = 0;
let blockedUntil = 0;
// A short fingerprint of the stored code: sessions stop working when it changes.
const fingerprint = (hash) => crypto.createHash('sha256').update('fp:' + hash).digest('hex');
function parseCookies(header) {
const out = {};
for (const part of String(header || '').split(';')) {
const i = part.indexOf('=');
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
function currentSession(req, lock) {
const token = parseCookies(req.headers.cookie)[COOKIE];
if (!token) return null;
const s = sessions.get(token);
if (!s) return null;
if (s.expires < Date.now() || s.fp !== fingerprint(lock.hash)) {
sessions.delete(token);
return null;
}
return token;
}
function setCookie(res, token, maxAgeS) {
// HttpOnly + SameSite=Strict: not readable by page scripts, and not sent
// on requests that start from another site. No Secure flag - this is
// normally plain http on a LAN.
res.setHeader('Set-Cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeS}`);
}
function issueSession(res, lock) {
for (const [t, s] of sessions) if (s.expires < Date.now()) sessions.delete(t);
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { expires: Date.now() + SESSION_MS, fp: fingerprint(lock.hash) });
setCookie(res, token, SESSION_MS / 1000);
}
function codeMatches(code, hash) {
const a = Buffer.from(hashPassword(code), 'hex');
const b = Buffer.from(hash, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// POST /api/login { code }
function login(req, res) {
const lock = readLockSettings();
if (!lock.enabled) return res.json({ ok: true, required: false });
const wait = Math.ceil((blockedUntil - Date.now()) / 1000);
if (wait > 0) {
return res.status(429).json({ ok: false, waitSeconds: wait, error: `Too many wrong tries - wait ${wait}s` });
}
const code = req.body && typeof req.body.code === 'string' ? req.body.code : '';
if (code && codeMatches(code, lock.hash)) {
failures = 0;
issueSession(res, lock);
return res.json({ ok: true });
}
failures++;
const what = lock.codeType === 'pin' ? 'PIN' : 'password';
if (failures >= FREE_TRIES) {
const waitS = Math.min(MAX_WAIT_S, BASE_WAIT_S * 2 ** (failures - FREE_TRIES));
blockedUntil = Date.now() + waitS * 1000;
return res.status(429).json({ ok: false, waitSeconds: waitS, error: `Too many wrong tries - wait ${waitS}s` });
}
return res.status(401).json({ ok: false, error: `Incorrect ${what}` });
}
// POST /api/logout
function logout(req, res) {
const token = parseCookies(req.headers.cookie)[COOKIE];
if (token) sessions.delete(token);
setCookie(res, '', 0);
res.json({ ok: true });
}
// GET /api/auth - open to everyone: tells the page whether to show a login.
function status(req, res) {
const lock = readLockSettings();
res.json({
required: lock.enabled,
authed: !lock.enabled || !!currentSession(req, lock),
codeType: lock.codeType,
});
}
// Gate for everything else. With protection off it lets everything through.
// Without a session: API calls get a 401, page requests get the login page.
function requireLogin(loginPage) {
return (req, res, next) => {
const lock = readLockSettings();
if (!lock.enabled) return next();
if (currentSession(req, lock)) return next();
// The few things the login page itself needs.
if (req.path === '/api/login' || req.path === '/api/auth' || req.path === '/style.css') return next();
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'Login required', loginRequired: true });
}
res.status(401).sendFile(loginPage);
};
}
// After a successful save that left protection on (e.g. the code was just
// set or changed), keep the person who saved it logged in.
function keepLoggedInAfterSave(res) {
const lock = readLockSettings();
if (lock.enabled) issueSession(res, lock);
}
module.exports = { requireLogin, login, logout, status, keepLoggedInAfterSave, _sessions: sessions };
+14 -1
View File
@@ -179,4 +179,17 @@ function saveConfig(patch) {
return loadConfig();
}
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
// What the Web UI login needs: whether the kiosk's password protection is
// on and the stored SHA-256 of its unlock code (the same one the lock screen
// checks). Only used server-side to verify a login - never sent to a browser.
function readLockSettings() {
const existing = readExisting();
const hash = typeof existing.lockoutPassword === 'string' ? existing.lockoutPassword : '';
return {
enabled: existing.enablePasswordProtection === true && hash.length > 0,
hash,
codeType: existing.lockoutCodeType === 'pin' ? 'pin' : 'password',
};
}
module.exports = { loadConfig, saveConfig, readLockSettings, hashPassword, CONFIG_PATH, SCALAR_DEFAULTS };
+26
View File
@@ -1,3 +1,12 @@
// Signed out (or the kiosk's code changed): any 401 from the server sends
// the page back to the login screen.
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const res = await nativeFetch(...args);
if (res.status === 401 && !String(args[0]).includes('/api/login')) location.reload();
return res;
};
'use strict';
// webui/public/app.js - vanilla JS, no framework/build step. Every
@@ -32,6 +41,23 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
// When the kiosk's password protection is on, this page is behind the same
// PIN/password - say so, and offer Sign out.
fetch('/api/auth').then((r) => r.json()).then((s) => {
if (!s.required) return;
const foot = document.getElementById('sidebar-footer');
foot.textContent = "Signed in with the kiosk's " + (s.codeType === 'pin' ? 'PIN' : 'password') + '. ';
const out = document.createElement('a');
out.href = '#';
out.textContent = 'Sign out';
out.addEventListener('click', async (e) => {
e.preventDefault();
await fetch('/api/logout', { method: 'POST' });
location.reload();
});
foot.appendChild(out);
}).catch(() => {});
/* ---------------------------------------------------------------------- */
/* Config API (Sites / Display / Lockout) */
/* ---------------------------------------------------------------------- */
+13 -1
View File
@@ -46,7 +46,7 @@
</button>
</div>
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
<div class="sidebar-footer" id="sidebar-footer">Open to anyone on this network while the kiosk's password protection is off. Turn it on (Lockout) and this page asks for the same PIN/password. Beyond a trusted LAN, front it with your own reverse proxy + Authelia.</div>
</nav>
<main class="main">
@@ -65,6 +65,18 @@
<button type="button" id="save-sites">Save Sites &amp; Page Timing</button>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">Keyboard shortcuts</h2>
<p class="hint" style="margin-bottom:10px">For a keyboard on the kiosk. They act on the page that's on screen, and don't work while the screen is locked.</p>
<table class="shortcuts">
<tr><td><kbd>Alt</kbd> + <kbd>Left</kbd></td><td>Back (previous page in this site)</td></tr>
<tr><td><kbd>Alt</kbd> + <kbd>Right</kbd></td><td>Forward</td></tr>
<tr><td><kbd>Alt</kbd> + <kbd>Home</kbd></td><td>Home: back to this page's own address, as set above</td></tr>
<tr><td><kbd>Ctrl</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageDown</kbd></td><td>Next site</td></tr>
<tr><td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd> / <kbd>Ctrl</kbd> + <kbd>PageUp</kbd></td><td>Previous site</td></tr>
</table>
<p class="hint" style="margin-top:10px">Screens with no keyboard can show on-screen back / home / forward buttons: Display &rarr; "On-screen back / home / forward buttons". A mouse's side buttons also go back and forward.</p>
</div>
</section>
<!-- Display & Interaction -->
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kiosk Web UI - sign in</title>
<link rel="stylesheet" href="style.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.login { width: min(360px, 92vw); padding: 28px; border-radius: 14px; background: var(--surface, #fff);
box-shadow: 0 6px 30px rgba(0,0,0,.18); }
.login h1 { font-size: 20px; margin: 0 0 6px; }
.login p { margin: 0 0 16px; opacity: .75; font-size: 14px; }
.login input { width: 100%; box-sizing: border-box; padding: 12px; font-size: 18px; margin-bottom: 12px; }
.login button { width: 100%; padding: 12px; font-size: 16px; cursor: pointer; }
#err { color: #c0392b; min-height: 20px; margin-top: 10px; font-size: 14px; }
</style>
</head>
<body>
<form class="login" id="form">
<h1>Kiosk Web UI</h1>
<p id="hint">Enter the kiosk's code to continue.</p>
<input id="code" type="password" autocomplete="current-password" autofocus>
<button type="submit" id="go">Sign in</button>
<div id="err"></div>
</form>
<script>
const $ = (id) => document.getElementById(id);
fetch('/api/auth').then((r) => r.json()).then((s) => {
if (s.authed) { location.href = '/'; return; }
if (s.codeType === 'pin') {
$('hint').textContent = "Enter the kiosk's PIN to continue.";
$('code').setAttribute('inputmode', 'numeric');
} else {
$('hint').textContent = "Enter the kiosk's password to continue.";
}
}).catch(() => {});
$('form').addEventListener('submit', async (e) => {
e.preventDefault();
$('go').disabled = true;
$('err').textContent = '';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: $('code').value }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok) { location.href = '/'; return; }
$('err').textContent = data.error || 'Sign in failed';
$('code').value = '';
$('code').focus();
} catch (err) {
$('err').textContent = 'Could not reach the kiosk';
}
$('go').disabled = false;
});
</script>
</body>
</html>
+11
View File
@@ -393,3 +393,14 @@ button.danger:hover { background: var(--danger-soft); }
.sidebar-footer { display: none; }
.main { padding: 20px; }
}
/* Keyboard shortcuts list on the Sites page */
.shortcuts { border-collapse: collapse; width: 100%; font-size: 13.5px; }
.shortcuts td { padding: 6px 10px 6px 0; vertical-align: top; }
.shortcuts td:first-child { white-space: nowrap; }
kbd {
display: inline-block; padding: 1px 6px; font: inherit; font-size: 12px;
border: 1px solid var(--border, #cfd4da); border-bottom-width: 2px; border-radius: 4px;
background: var(--surface-2, rgba(127,127,127,.12));
}
+20 -6
View File
@@ -6,11 +6,12 @@
// no privileged system mutation involved (see menus/addon_webui.sh's
// header for why the rest of Core Settings/Addons/Advanced aren't here).
//
// No login of its own by design: Authelia runs elsewhere, and the admin
// site goes behind the user's own Caddy reverse proxy with Authelia
// forward-auth in front of it, the same way every other self-hosted app
// they run is protected. This process only binds where it's told to
// (BIND_ADDR/PORT below) and trusts whatever's in front of it.
// Login: when the kiosk's password protection is on, the Web UI asks for the
// same PIN/password the lock screen uses (lib/auth.js); with it off it is
// open. Beyond that, no login of its own: Authelia runs elsewhere, and the
// admin site can go behind the user's own Caddy reverse proxy with Authelia
// forward-auth in front of it. This process only binds where it's told to
// (BIND_ADDR/PORT below).
//
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
// installs) - the same user Electron runs as, and the owner of
@@ -22,6 +23,7 @@ const express = require('express');
const { loadConfig, saveConfig } = require('./lib/config');
const { ACTIONS } = require('./lib/actions');
const { startJob, getJob } = require('./lib/jobs');
const auth = require('./lib/auth');
const HELPER_PATH = process.env.HELPER_PATH || '/usr/local/bin/kiosk-webui-helper';
const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
@@ -29,6 +31,14 @@ const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'su
const app = express();
app.use(express.json({ limit: '256kb' }));
// When the kiosk's password protection is on, the Web UI asks for the same
// PIN/password (see lib/auth.js). With it off, nothing changes.
app.post('/api/login', auth.login);
app.post('/api/logout', auth.logout);
app.get('/api/auth', auth.status);
app.use(auth.requireLogin(path.join(__dirname, 'public', 'login.html')));
app.use(express.static(path.join(__dirname, 'public')));
const SWIPE_MODES = ['dual', 'standard'];
@@ -179,7 +189,11 @@ app.put('/api/config', (req, res) => {
}
try {
res.json(saveConfig(patch));
const saved = saveConfig(patch);
// If this save set or changed the kiosk's code, every old session
// just stopped working - keep the person who saved it signed in.
auth.keepLoggedInAfterSave(res);
res.json(saved);
} catch (e) {
console.error('saveConfig failed:', e);
res.status(500).json({ error: 'Failed to save configuration' });
+14
View File
@@ -18,6 +18,20 @@ process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helpe
const app = require('../server');
// These tests turn password protection on partway through, and the Web UI
// then (correctly) wants a login. A browser keeps the session cookie the
// server hands out on a save; Node's fetch doesn't, so keep it here.
const nodeFetch = global.fetch;
let sessionCookie = '';
global.fetch = async (url, opts = {}) => {
const headers = { ...(opts.headers || {}) };
if (sessionCookie) headers.cookie = sessionCookie;
const res = await nodeFetch(url, { ...opts, headers });
const set = res.headers.get('set-cookie');
if (set) sessionCookie = set.split(';')[0];
return res;
};
let failures = 0;
async function check(label, fn) {
try {
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// webui/test/auth.test.js - the Web UI login: with the kiosk's password
// protection off the UI is open; with it on, everything needs the kiosk's
// PIN/password. Starts the real server on a random port against a scratch
// config.json. Run with: node test/auth.test.js
const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-auth-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.SUDO_CMD = '';
const app = require('../server');
const { saveConfig } = require('../lib/config');
let failures = 0;
async function check(label, fn) {
try {
await fn();
console.log(`PASS: ${label}`);
} catch (e) {
failures++;
console.log(`FAIL: ${label} - ${e.message}`);
}
}
async function main() {
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (url, body, cookie) => fetch(base + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body || {}),
});
const get = (url, cookie) => fetch(base + url, { headers: cookie ? { cookie } : {} });
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
await check('protection off: no login needed, /api/auth says so', async () => {
assert.strictEqual((await get('/api/config')).status, 200);
const a = await (await get('/api/auth')).json();
assert.strictEqual(a.required, false);
assert.strictEqual(a.authed, true);
});
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'hunter22' });
await check('protection on: API calls are refused without a login', async () => {
const res = await get('/api/config');
assert.strictEqual(res.status, 401);
assert.strictEqual((await res.json()).loginRequired, true);
assert.strictEqual((await post('/api/actions/anything/run')).status, 401);
});
await check('protection on: pages get the login screen, not the app', async () => {
const res = await get('/');
assert.strictEqual(res.status, 401);
const html = await res.text();
assert.ok(html.includes('id="code"'));
assert.ok(!html.includes('nav-item'));
assert.strictEqual((await get('/app.js')).status, 401);
});
await check('/api/auth reports a login is required, and the code type', async () => {
const a = await (await get('/api/auth')).json();
assert.strictEqual(a.required, true);
assert.strictEqual(a.authed, false);
assert.strictEqual(a.codeType, 'password');
});
await check('a wrong code is refused and gives no session', async () => {
const res = await post('/api/login', { code: 'nope' });
assert.strictEqual(res.status, 401);
assert.strictEqual(res.headers.get('set-cookie'), null);
});
let cookie = '';
await check('the right code gives an HttpOnly session that unlocks the API', async () => {
const res = await post('/api/login', { code: 'hunter22' });
assert.strictEqual(res.status, 200);
const raw = res.headers.get('set-cookie');
assert.ok(/HttpOnly/i.test(raw) && /SameSite=Strict/i.test(raw));
cookie = cookieOf(res);
assert.strictEqual((await get('/api/config', cookie)).status, 200);
assert.strictEqual((await get('/', cookie)).status, 200);
assert.strictEqual((await (await get('/api/auth', cookie)).json()).authed, true);
});
await check('the stored code hash is never sent to the browser', async () => {
const text = await (await get('/api/config', cookie)).text();
assert.ok(!text.includes('lockoutPassword"'));
assert.ok(!/[0-9a-f]{64}/.test(text));
});
await check('saving settings keeps you signed in', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify({ enableNavButton: false }),
});
assert.strictEqual(res.status, 200);
});
await check('changing the kiosk code signs every browser out', async () => {
saveConfig({ newLockoutPassword: 'another-one' });
assert.strictEqual((await get('/api/config', cookie)).status, 401);
assert.strictEqual((await post('/api/login', { code: 'hunter22' })).status, 401);
const ok = await post('/api/login', { code: 'another-one' });
assert.strictEqual(ok.status, 200);
cookie = cookieOf(ok);
});
await check('setting a new code from the Web UI keeps that browser signed in', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify({ newLockoutPassword: 'third-code' }),
});
assert.strictEqual(res.status, 200);
const fresh = cookieOf(res);
assert.ok(fresh && fresh !== cookie);
assert.strictEqual((await get('/api/config', fresh)).status, 200);
assert.strictEqual((await get('/api/config', cookie)).status, 401);
cookie = fresh;
});
await check('sign out ends the session', async () => {
assert.strictEqual((await post('/api/logout', {}, cookie)).status, 200);
assert.strictEqual((await get('/api/config', cookie)).status, 401);
});
await check('five wrong codes in a row slow logins down - even the right code waits', async () => {
let last;
for (let i = 0; i < 5; i++) last = await post('/api/login', { code: 'wrong' + i });
assert.strictEqual(last.status, 429);
assert.ok((await last.json()).waitSeconds >= 30);
assert.strictEqual((await post('/api/login', { code: 'third-code' })).status, 429);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
if (failures > 0) {
console.log(`${failures} FAILURE(S)`);
process.exit(1);
}
console.log('ALL DONE');
}
main();