Author SHA1 Message Date
Claude 0af833a39d library: name is Room for Books (no article)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 01:24:58 +00:00
Claude 442b45c2e0 library: renamed A Room for Books; multi-tags; borrower sign-in (card number + password) with My books, renew and change password; AR pop-up/frame
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 01:04:45 +00:00
Claude 6f877d1107 library: live camera scanning over HTTPS, paste-to-fill AR reading levels (with interest level)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:49:33 +00:00
Claude 3d02d28a8c library: reading levels (manual + CSV import), library CSV export, AR BookFind link with copy-title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:35:19 +00:00
Claude 7f230c8e73 library: collections, QR library cards (phone scan), card sheets, book info from Open Library, add shelves/collections from the UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:27:27 +00:00
Claude b784d98c9d library: Lend / return, People and printable library cards in the Library site
Checkout via Koha REST, check-in through a secret-guarded script that runs Koha's AddReturn,
renewals, overdue list, add-a-person with auto card number, Code 39 library card page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 00:05:04 +00:00
Claude d01919e2ac library: Library home page (cover grid, filters, search) and book page with shelf/call-number editing
Quick Add becomes the everyday interface: Library grid with shelf/format/status filters,
a book page (copies, status, edit shelf and call number, add/remove copy, delete), read-only for the guest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 23:56:08 +00:00
Claude 10e991e158 library: staff signed in to Koha open Add books without a second login
Quick Add asks Koha (C4::Auth::check_cookie_auth via a secret-guarded svc script) whether the
browser's session may edit the catalogue; falls back to its own login otherwise. Cookie-authenticated
writes need an X-Quickadd header; the direct port never accepts cookies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 22:45:01 +00:00
41 changed files with 2063 additions and 1197 deletions
-22
View File
@@ -1,22 +0,0 @@
# CLAUDE.md
## No user data in the repo
Never write anything specific to a person's kiosk, network or accounts into
this repository - not in code, tests, fixtures, docs, comments, commit
messages, branch names or pull request text.
That includes:
- WiFi network names (SSIDs), passwords, keys and tokens
- MAC addresses, IP addresses, hostnames and interface names taken from a
real machine
- usernames, email addresses, PINs and passwords
- the URLs of someone's own sites or services
- log output, screenshots or terminal output pasted in by a user
When an example, test or fixture needs a value like these, use an obvious
placeholder (`ExampleNet`, `192.0.2.10`, `aa:bb:cc:dd:ee:ff`, `wlan0`,
`example.com`) instead of a real one. When debugging from output a user
shares, keep it in the conversation or the scratchpad, not in a file in this
repo, and leave it out of commit messages.
+5 -12
View File
@@ -91,7 +91,7 @@ Useful options (`iso/build-iso.sh --help` for all):
| Option | Effect |
|---|---|
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8888`) or `./install.sh`. |
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8090`) or `./install.sh`. |
| `--username NAME [--password PASS]` | Pre-set the admin account (password is prompted for if omitted; only a hash goes on the ISO). Not `kiosk`. |
| `--hostname`, `--ssh-key FILE`, `--locale`, `--keyboard` | Pre-set those. |
| `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
@@ -249,7 +249,6 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
- **On-screen keyboard** - Auto-shows on text fields or click keyboard icon
- **Navigation menu** - Click key icon (top-left) to access site list and gesture cheat sheet
- **Back / home / forward buttons** (optional, off by default) - three half-transparent buttons (◀ 🏠 ▶) at the top middle of the screen; the home button returns the site on screen to the address it was set up with under Sites & Page Timing, for kiosks with no keyboard or mouse side buttons. They appear on a tap, click or mouse movement and fade after a few seconds. Turn on in Core Settings → Display & Interaction, or the Web UI, where you can also set how solid they look (opacity 10–100%, default 50).
### On-Screen Keyboard
- **HTML-based keyboard** with full QWERTY layout
@@ -507,7 +506,6 @@ 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
@@ -539,9 +537,7 @@ It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kios
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
**More than one WiFi adapter** (e.g. a built-in card and a USB dongle): a *WiFi adapter* dropdown appears at the top, and the status box lists every adapter with what it's connected to. Status, scan and connect act on the one you pick; by default it's the adapter that's connected right now.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect and region only). The app reaches it through a root-owned socket, `/run/kiosk-wifi-helper.sock` (`kiosk-wifi-helper.socket`, group-restricted to the kiosk user, `0660`), rather than `sudo`: the app can end up running with Linux's "no new privileges" flag, and then sudo refuses with *"The no new privileges flag is set"*. A passwordless-sudo rule (`/etc/sudoers.d/kiosk-wifi`) stays as a fallback. Existing kiosks get both with Advanced → Upgrade.
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
---
@@ -777,10 +773,7 @@ smb://WORKGROUP/COMPUTER/PrinterName
**Keyboard Shortcuts:**
- `Ctrl+Tab` or `Ctrl+]` - Next tab
- `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab
- `Ctrl+PageDown` / `Ctrl+PageUp` - Next/Previous tab (the standard browser keys)
- `Alt+Right` / `Alt+Left` - Page forward / back within the current site (the standard browser keys; they used to switch tabs)
- `Alt+Home` - Back to the current site's own start address (the one set under Sites & Page Timing)
- Mouse side buttons (back/forward) - the same page forward/back
- `Alt+Right/Left` - Next/Previous tab
- `F10` or `Ctrl+H` - Toggle hidden tabs
- `Escape` - Return to normal tabs (from hidden)
- `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu
@@ -1586,7 +1579,7 @@ full migration pass.
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
**Previous (v2.17.0):**
- **Web UI now installs by default** during first-time provisioning (port 8888 for new installs, 8090 on older ones; no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
- **Privilege model:** the web service itself still runs as `$KIOSK_USER` with zero ambient `sudo`. A new narrow, allow-listed root helper is the only way it ever gains privilege — reachable only via a single-path passwordless sudo rule (generated and validated with `visudo -c -f` before being installed), and it re-checks its own fixed action allow-list before dispatching anything. Chosen over running the whole service as root after asking directly: since this repo has no login of its own by design, a request that reaches the web UI with no reverse proxy in front is effectively unauthenticated, so the allow-list bounds what that can actually do to five vetted actions, never a root shell.
- Long-running installs stream live output to the browser via Server-Sent Events, one action at a time — a second request while one is in flight gets a clear `409`, never silently queued or dropped.
@@ -1597,7 +1590,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.
- **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.
- **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.
- 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.
+1 -1
View File
@@ -18,7 +18,7 @@
# ~/ubuntu-based-kiosk - interactively by default (same prompts as a
# manual install), or with --unattended taking every default and
# rebooting straight into the kiosk. Configure sites afterwards via
# the Web UI (http://<ip>:8888) or ./install.sh.
# the Web UI (http://<ip>:8090) or ./install.sh.
#
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
# (menu title, plus the `autoinstall` kernel argument with --no-confirm);
+20 -181
View File
@@ -1,6 +1,5 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec,spawn}=require('child_process');
const net=require('net');
const fs=require('fs');
const path=require('path');
const os=require('os');
@@ -60,8 +59,6 @@ let allowNavigation='same-origin';
let enablePauseButton=true;
let enableKeyboardButton=true;
let enableNavButton=true;
let enableHistoryButtons=false;
let historyButtonsOpacity=50;
let enablePasswordProtection=false;
let lockoutPassword="";
let lockoutTimeout=0;
@@ -104,11 +101,6 @@ function loadConfig(){
enablePauseButton=(config.enablePauseButton!==false);
enableKeyboardButton=(config.enableKeyboardButton!==false);
enableNavButton=(config.enableNavButton!==false);
enableHistoryButtons=(config.enableHistoryButtons===true);
{
const pct=Number(config.historyButtonsOpacity);
historyButtonsOpacity=(Number.isInteger(pct)&&pct>=10&&pct<=100)?pct:50;
}
enablePasswordProtection=(config.enablePasswordProtection===true);
lockoutPassword=config.lockoutPassword||"";
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
@@ -129,7 +121,6 @@ function loadConfig(){
console.log('[CONFIG] Navigation:',allowNavigation);
console.log('[CONFIG] Pause button:',enablePauseButton);
console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons,'opacity',historyButtonsOpacity+'%');
console.log('[CONFIG] Password protection:',enablePasswordProtection);
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
@@ -772,23 +763,6 @@ function rotateToNextSite(){
}
}
// The address a site is loaded from: its configured URL, with the site's
// own login filled in (if it has one) and local files/folders turned into
// the kiosk's viewer addresses. Used when a site's view is first created
// and by the home button, so both always agree.
function buildTabUrl(t){
let url=t.url;
if(t.username&&t.password){
try{
const u=new URL(t.url);
u.username=t.username;
u.password=t.password;
url=u.toString();
}catch(e){}
}
return resolveLocalSite(url);
}
function attachView(i){
closeHTMLKeyboard();
@@ -1170,75 +1144,6 @@ function returnToTabs(){
markActivity();
}
// Page back/forward in the site that's on screen (Alt+Left / Alt+Right, the
// mouse's side buttons, and the optional on-screen buttons). Not while the
// screen is locked or a dialog (PIN, WiFi, power...) is open on top - the
// keys would silently navigate the page hidden behind it.
function historyBlocked(){
if(isLockedOut)return true;
for(const w of [pinWindow,wifiWindow,promptWindow,pauseWindow]){
if(w&&!w.isDestroyed())return true;
}
return false;
}
function historyGo(direction){
if(historyBlocked())return;
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
if(!view||!view.webContents||view.webContents.isDestroyed())return;
const wc=view.webContents;
const nav=wc.navigationHistory;
if(direction==='back'){
if(nav?nav.canGoBack():wc.canGoBack()){nav?nav.goBack():wc.goBack();}
}else{
if(nav?nav.canGoForward():wc.canGoForward()){nav?nav.goForward():wc.goForward();}
}
markActivity();
}
// The tab (index into tabs) behind the n-th hidden view: hidden sites are the
// ones with duration -1, in order.
function hiddenViewIndexToTabIndex(h){
let n=0;
for(let i=0;i<tabs.length;i++){
if(parseInt(tabs[i].duration)===-1){
if(n===h)return i;
n++;
}
}
return -1;
}
// Home button / Alt+Home: back to the address this site was set up with
// (Core Settings -> Sites & Page Timing), whatever page the user has
// browsed to since. Acts on the site that's on screen, so with several
// sites each one goes to its own address.
function historyHome(){
if(historyBlocked())return;
const view=showingHidden?hiddenViews[currentHiddenIndex]:views[currentIndex];
if(!view||!view.webContents||view.webContents.isDestroyed())return;
const tabIdx=showingHidden?hiddenViewIndexToTabIndex(currentHiddenIndex):viewIndexToTabIndex(currentIndex);
if(tabIdx<0||!tabs[tabIdx])return;
const target=buildTabUrl(tabs[tabIdx]);
if(view.webContents.getURL()!==target){
view.webContents.loadURL(target).catch(()=>{});
}
markActivity();
}
// Power menu -> Lock now: put up the lock screen right away (the same one
// the inactivity timeout shows). Only when password protection is on and a
// code is set - showLockoutScreen refuses otherwise, so a kiosk with no
// code can't be locked with nothing to unlock it.
function lockNow(){
if(!enablePasswordProtection||!lockoutPassword){
console.log('[LOCKOUT] Lock now ignored - password protection is off');
return;
}
console.log('[LOCKOUT] Lock now requested from the power menu');
showLockoutScreen('unlock');
}
function showPinEntry(){
if(pinWindow&&!pinWindow.isDestroyed()){
pinWindow.focus();
@@ -1310,56 +1215,7 @@ const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
// Parse the helper's one JSON reply line (the last line of its output).
function parseWifiReply(out){
const line=out.trim().split('\n').pop();
return JSON.parse(line);
}
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
// sudo it keeps working when this app has the "no new privileges" flag set.
// Resolves null when the socket isn't usable (not installed yet, or this
// user isn't allowed), so the caller falls back to sudo.
function runWifiHelperSocket(action,input){
return new Promise(resolve=>{
let out='',done=false;
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
sock.on('connect',()=>{
// Request = action line, then the JSON body; half-close so the
// helper sees end of input.
sock.end(action+'\n'+(input?JSON.stringify(input):''));
});
sock.on('data',d=>{out+=d;});
sock.on('error',e=>{
if(out){
// A reset after the reply arrived - use the reply.
try{finish(parseWifiReply(out));return;}catch(_){}
}
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
finish(null);
}else{
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
}
});
sock.on('close',()=>{
if(done)return;
try{
finish(parseWifiReply(out));
}catch(e){
console.error('[WIFI] helper socket output:',out);
finish({ok:false,error:'The WiFi helper gave no usable answer'});
}
});
});
}
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
function runWifiHelperSudo(action,input){
function runWifiHelper(action,input){
return new Promise(resolve=>{
let out='',err='',done=false;
const finish=result=>{if(!done){done=true;resolve(result);}};
@@ -1376,11 +1232,12 @@ function runWifiHelperSudo(action,input){
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
child.on('close',()=>{
clearTimeout(killer);
const line=out.trim().split('\n').pop();
try{
finish(parseWifiReply(out));
finish(JSON.parse(line));
}catch(e){
// Not our JSON - typically sudo refusing ("a password is required",
// or the "no new privileges" flag) - Upgrade installs the socket.
// Not our JSON - typically sudo refusing ("a password is required")
// on a kiosk set up before this feature: Upgrade installs the rule.
console.error('[WIFI] helper output:',out,err);
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
}
@@ -1390,12 +1247,6 @@ function runWifiHelperSudo(action,input){
});
}
async function runWifiHelper(action,input){
const viaSocket=await runWifiHelperSocket(action,input);
if(viaSocket!==null)return viaSocket;
return runWifiHelperSudo(action,input);
}
function resetWifiIdleTimer(){
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
wifiIdleTimer=setTimeout(()=>{
@@ -1476,10 +1327,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
return result;
}
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='ifaces')return runWifiHelper('ifaces');
if(action==='status'||action==='scan'){
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
}
if(action==='status'||action==='scan')return runWifiHelper(action);
if(action==='region-status')return runWifiHelper('region-status');
if(action==='region-set'){
const input={};
@@ -1489,7 +1337,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
}
if(action==='connect'){
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
return result;
}
@@ -1558,7 +1406,7 @@ function showPowerMenu(){
// For normal mode, use custom overlay with 30-second timeout
console.log('[POWER] Showing custom power menu overlay');
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim(),canLock:!!(enablePasswordProtection&&lockoutPassword)};
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()};
// Send to all views (preload.js runs in views, not mainWindow)
for(const view of views){
if(view&&view.webContents&&!view.webContents.isDestroyed()){
@@ -1620,11 +1468,6 @@ async function createWindow(){
mainWindow.on('focus',()=>markActivity());
mainWindow.webContents.on('before-input-event',()=>markActivity());
// Mouse side buttons (back/forward). Reported by the window on Linux.
mainWindow.on('app-command',(event,command)=>{
if(command==='browser-backward')historyGo('back');
else if(command==='browser-forward')historyGo('forward');
});
if(!tabs.length){
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
@@ -1644,7 +1487,16 @@ async function createWindow(){
mainWindow.addBrowserView(view);
const url=buildTabUrl(t);
let url=t.url;
if(t.username&&t.password){
try{
const u=new URL(t.url);
u.username=t.username;
u.password=t.password;
url=u.toString();
}catch(e){}
}
url=resolveLocalSite(url);
const initialOrigin=new URL(t.url).origin;
@@ -1701,7 +1553,6 @@ async function createWindow(){
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
view.webContents.send('nav-button-enabled',enableNavButton);
view.webContents.send('history-buttons-config',{enabled:enableHistoryButtons,opacity:historyButtonsOpacity});
console.log('[MAIN] Page loaded - sending nav-button-enabled: '+enableNavButton);
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
@@ -1743,13 +1594,8 @@ async function createWindow(){
if(action==='shutdown')exec('systemctl poweroff');
else if(action==='restart')exec('systemctl reboot');
else if(action==='reload'){app.relaunch();app.quit();}
else if(action==='lock')lockNow();
});
ipcMain.on('toggle-hidden',toggleHidden);
ipcMain.on('history-nav',(event,direction)=>{
if(direction==='back'||direction==='forward')historyGo(direction);
else if(direction==='home')historyHome();
});
ipcMain.on('return-to-tabs',forceReturnToTabs);
ipcMain.on('user-activity',markActivity);
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
@@ -1939,16 +1785,9 @@ async function createWindow(){
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
globalShortcut.register('Control+]',()=>{nextTab();});
globalShortcut.register('Control+[',()=>{prevTab();});
// The standard browser keys for switching tabs. (Alt+Left/Right used to
// switch sites; they are now page back/forward, below.)
globalShortcut.register('Control+PageDown',()=>{nextTab();});
globalShortcut.register('Control+PageUp',()=>{prevTab();});
globalShortcut.register('Alt+Right',()=>{nextTab();});
globalShortcut.register('Alt+Left',()=>{prevTab();});
}
// Standard browser keys for page back/forward.
globalShortcut.register('Alt+Left',()=>{historyGo('back');});
globalShortcut.register('Alt+Right',()=>{historyGo('forward');});
globalShortcut.register('Alt+Home',()=>{historyHome();});
globalShortcut.register('F10',toggleHidden);
globalShortcut.register('Control+H',toggleHidden);
-94
View File
@@ -115,24 +115,6 @@ ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
}
});
// On-screen back / home / forward buttons (top middle, half transparent) - an
// option for screens with no keyboard or mouse side buttons.
let historyButtonsEnabled=false;
let historyBar=null;
let historyBarHideTimer=null;
let historyOpacity=0.5; // 0.1 - 1.0, from the "opacity" setting (percent)
const historyHoverOpacity=()=>Math.min(1,historyOpacity+0.4);
ipcRenderer.on('history-buttons-config',(event,cfg)=>{
historyButtonsEnabled=!!(cfg&&cfg.enabled===true);
const pct=Number(cfg&&cfg.opacity);
historyOpacity=(pct>=10&&pct<=100)?pct/100:0.5;
console.log('[HISTORY-BTN] Back/forward buttons enabled: '+historyButtonsEnabled+', opacity '+historyOpacity);
if(historyBar){
historyBar.style.opacity=String(historyOpacity);
if(!historyButtonsEnabled)historyBar.style.display='none';
}
});
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
navButtonEnabled=enabled;
console.log('[NAV-BTN] Navigation button enabled: '+enabled);
@@ -271,57 +253,6 @@ window.addEventListener('DOMContentLoaded',()=>{
document.body.appendChild(navButton);
}
// Back/forward buttons, top middle. The corners are often all taken by the
// menu, pause, keyboard and power buttons, so these sit between them.
const HISTORY_BAR_HIDE_DELAY=5000;
function createHistoryBar(){
if(historyBar)return;
historyBar=document.createElement('div');
historyBar.id='electron-history-bar';
historyBar.style.cssText=`
position:fixed;top:12px;left:50%;transform:translateX(-50%);
display:none;gap:18px;z-index:999999;opacity:${historyOpacity};
transition:opacity 0.15s;user-select:none;
`;
const makeButton=(label,direction,path)=>{
const b=document.createElement('div');
b.title=label;
b.innerHTML='<svg width="30" height="30" viewBox="0 0 24 24" fill="white"><path d="'+path+'"/></svg>';
b.style.cssText=`
width:56px;height:56px;background:rgba(44,62,80,0.9);
border:2px solid rgba(255,255,255,0.9);border-radius:50%;
display:flex;align-items:center;justify-content:center;cursor:pointer;
`;
b.addEventListener('click',(e)=>{
e.preventDefault();
e.stopPropagation();
ipcRenderer.send('history-nav',direction);
showHistoryBar();
});
return b;
};
historyBar.append(
makeButton('Back','back','M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z'),
makeButton('Home (this site\'s start page)','home','M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z'),
makeButton('Forward','forward','M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z')
);
historyBar.addEventListener('mouseenter',()=>{historyBar.style.opacity=String(historyHoverOpacity());});
historyBar.addEventListener('mouseleave',()=>{historyBar.style.opacity=String(historyOpacity);});
document.body.appendChild(historyBar);
}
function showHistoryBar(){
if(!historyButtonsEnabled)return;
if(!historyBar)createHistoryBar();
if(!historyBar)return;
historyBar.style.display='flex';
if(historyBarHideTimer)clearTimeout(historyBarHideTimer);
historyBarHideTimer=setTimeout(()=>{
if(historyBar)historyBar.style.display='none';
historyBarHideTimer=null;
},HISTORY_BAR_HIDE_DELAY);
}
// Power button in top-right corner (follows same show/hide logic as nav button)
let powerButton=null;
let powerButtonHideTimer=null;
@@ -666,16 +597,6 @@ window.addEventListener('DOMContentLoaded',()=>{
ipcRenderer.send('power-action','reload');
});
// Only offered when password protection is on (set in showPowerMenu).
const lockBtn=document.createElement('button');
lockBtn.id='power-menu-lock';
lockBtn.textContent='🔒 Lock now';
lockBtn.style.cssText=btnStyle+'background:#8e44ad;color:white;display:none;';
lockBtn.addEventListener('click',()=>{
hidePowerMenu();
ipcRenderer.send('power-action','lock');
});
const cancelBtn=document.createElement('button');
cancelBtn.textContent='Cancel';
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
@@ -683,7 +604,6 @@ window.addEventListener('DOMContentLoaded',()=>{
hidePowerMenu();
});
buttonsDiv.appendChild(lockBtn);
buttonsDiv.appendChild(shutdownBtn);
buttonsDiv.appendChild(restartBtn);
buttonsDiv.appendChild(reloadBtn);
@@ -719,8 +639,6 @@ window.addEventListener('DOMContentLoaded',()=>{
}
infoDiv.innerHTML=infoText;
}
const lockBtn=document.getElementById('power-menu-lock');
if(lockBtn)lockBtn.style.display=(info&&info.canLock)?'':'none';
powerMenu.style.display='flex';
powerMenuVisible=true;
@@ -1000,23 +918,11 @@ window.addEventListener('DOMContentLoaded',()=>{
if(navButtonEnabled){
showNavButton();
}
showHistoryBar();
// Always show power button on user interaction
showPowerButton();
}
// Back/forward buttons also appear on plain mouse movement, for screens
// with a mouse but no touch (the other buttons wait for a click/keypress).
let lastHistoryMove=0;
document.addEventListener('mousemove',()=>{
if(!historyButtonsEnabled)return;
const now=Date.now();
if(now-lastHistoryMove<300)return;
lastHistoryMove=now;
showHistoryBar();
},{passive:true,capture:true});
// Show pause button on any user interaction
const pauseButtonTriggers=['mousedown','touchstart','keydown'];
pauseButtonTriggers.forEach(eventType=>{
+8 -49
View File
@@ -95,11 +95,7 @@
</div>
<div id="wifi-step" class="hidden">
<div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div id="status">Checking…</div>
<div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label>
@@ -143,7 +139,6 @@
const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id);
let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
@@ -168,47 +163,16 @@
return '▂';
}
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() {
const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const s = await request('status');
const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
// Every adapter at a glance, the selected one marked.
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
} else {
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
line(`Adapter: ${s.iface}`);
}
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
line(`Adapter: ${s.iface}`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
}
@@ -217,7 +181,7 @@
setBusy(true);
message('Scanning…', 'busy');
const list = $('networks');
const r = await request('scan', adapter ? {iface: adapter} : {});
const r = await request('scan');
list.textContent = '';
if (!r.ok) {
message(r.error, 'error');
@@ -253,7 +217,7 @@
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password, iface: adapter});
const r = await request('connect', {ssid, password});
if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = '';
@@ -323,19 +287,14 @@
message('');
$('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden');
refreshStatus().then(() => scan());
refreshStatus();
loadRegion();
scan();
}
$('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect);
+2 -15
View File
@@ -79,8 +79,6 @@ INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true"
ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=50
ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0
@@ -205,7 +203,7 @@ load_existing_config() {
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn history_btn password_enabled boot_password
local pause_btn keyboard_btn nav_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
@@ -215,12 +213,6 @@ load_existing_config() {
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null)
[[ "$nav_btn" == "true" ]] && ENABLE_NAV_BUTTON="true" || ENABLE_NAV_BUTTON="false"
history_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableHistoryButtons // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$history_btn" == "true" ]] && ENABLE_HISTORY_BUTTONS="true" || ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=$(sudo -u "$KIOSK_USER" jq -r '.historyButtonsOpacity // 50' "$CONFIG_PATH" 2>/dev/null || echo "50")
[[ "$HISTORY_BUTTONS_OPACITY" =~ ^[0-9]+$ ]] && (( HISTORY_BUTTONS_OPACITY >= 10 && HISTORY_BUTTONS_OPACITY <= 100 )) || HISTORY_BUTTONS_OPACITY=50
password_enabled=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
@@ -271,9 +263,6 @@ save_config() {
local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false"
local history_btn_json="false"
[[ "$ENABLE_HISTORY_BUTTONS" == "true" ]] && history_btn_json="true"
local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
@@ -312,8 +301,6 @@ save_config() {
--argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \
--argjson enableHistoryButtons "$history_btn_json" \
--argjson historyButtonsOpacity "${HISTORY_BUTTONS_OPACITY:-50}" \
--argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
@@ -327,7 +314,7 @@ save_config() {
--arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enableHistoryButtons:$enableHistoryButtons,historyButtonsOpacity:$historyButtonsOpacity,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do
+4 -26
View File
@@ -198,12 +198,7 @@ provision_configure_display() {
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
# flip this later, and does below if declined at the end of setup.
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
# Only on first install: this file is also where Advanced -> Virtual
# Consoles records the admin's choice, and Upgrade re-runs this step -
# overwriting it would silently turn consoles back off (or on).
if [[ ! -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
fi
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
@@ -247,23 +242,6 @@ provision_install_wifi_helper() {
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
fi
rm -f "$tmp"
# The same helper through a root-owned socket, which is what the kiosk
# app uses first: it can end up running with the "no new privileges"
# flag, and sudo then refuses to run as root. The sudoers rule above
# stays as a fallback. Socket access is limited to the kiosk user's
# group (0660) - the same reach as the sudoers rule.
provision_install_file "usr/local/bin/kiosk-wifi-helper-socket" "$BIN_DIR/kiosk-wifi-helper-socket" 755
provision_install_file "etc/systemd/system/kiosk-wifi-helper@.service" "$SYSTEMD_DIR/kiosk-wifi-helper@.service"
provision_install_file "etc/systemd/system/kiosk-wifi-helper.socket" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
local kiosk_group
kiosk_group=$(id -gn "$KIOSK_USER" 2>/dev/null || echo "$KIOSK_USER")
sudo sed -i "s/@KIOSK_GROUP@/${kiosk_group}/" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
sudo systemctl daemon-reload
if ! sudo systemctl enable kiosk-wifi-helper.socket &>/dev/null || ! sudo systemctl restart kiosk-wifi-helper.socket; then
log_warning "Couldn't start the kiosk WiFi helper socket - the WiFi screen will fall back to sudo"
fi
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
sudo udevadm control --reload-rules 2>/dev/null || true
@@ -328,7 +306,7 @@ provision_configure_webui() {
return 0
fi
local port="8888"
local port="8090"
webui_write_env_file "$port"
webui_write_unit_file
webui_write_helper_script
@@ -424,8 +402,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 - asks for the kiosk's"
echo " PIN/password when password protection is on, see Addons -> Web UI)"
echo " addon install/reconfigure and Update - no login of its own,"
echo " see Addons -> Web UI)"
echo
echo "OPTIONAL (configure after install, via Addons):"
echo " - Lyrion Music Server (LMS) / Squeezelite"
+72 -1
View File
@@ -1,4 +1,4 @@
# Home library: Koha + Quick Add (Docker)
# Room for Books: a home library (Koha + the Room for Books site, in Docker)
A self-hosted library catalogue you can set up in one command:
@@ -203,3 +203,74 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz)
```
## Signing in once
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
## Everyday use (the Library site at `/add/`)
One page for the day-to-day, so you rarely need Koha's own screens:
- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format
(book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its
page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number,
add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.)
- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out;
"Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything
that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply.
- **People**: add someone with just a name (the card number is assigned automatically), search, and
**Print card** for a library card with a scannable Code 39 barcode.
- **Add books**: scan an ISBN (or use the phone camera), as before.
The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending).
### Collections, QR cards, more about a book
- **Collections** (Kids, Cookbooks, ...) are per copy, shown on the book page next to the shelf, and filterable on the
Library page. Choose **+ New collection...** (or **+ New shelf...**) in the dropdown to add one: no Koha screens needed.
- **Library cards** carry a Code 39 barcode and a **QR code** of the card number. **Save as picture** makes an image to text
or AirDrop to a phone; on Lend / return, **Scan card or phone QR with camera** reads either one from a photo.
**Print everyone's cards** (People) lays out a sheet of cards.
- **More about a book**: a description and subjects from Open Library, plus look-up links (Goodreads, LibraryThing,
WorldCat). **AR BookFind** is a link only: Renaissance's terms forbid automated access, so nothing is fetched from it.
### Reading levels (AR) and spreadsheets
AR BookFind has no public feed or import, and its terms forbid automated look-ups, so this site does not fetch from it.
Instead: the book page has an **Open AR BookFind** link (plus **Copy title** to paste into its search) and boxes to record the
**level, points and quiz number** you find there (the read-only guest sees them). **Library page -> Export / import** downloads
`library.csv` (one row per copy, with any reading level) and imports a CSV of `isbn, level, points[, quiz]` you made yourself
(for example from a spreadsheet or a Renaissance report you are entitled to export). Levels are stored in `reading-levels.json`
in the Add books data volume, keyed by ISBN, so they survive a Koha reinstall and are included in the usual backups of that folder.
### Live camera scanning (needs HTTPS)
Browsers only allow a live camera on HTTPS pages (or on the machine itself). Put the library site behind Caddy with a real
certificate and **Live scan** buttons appear: Add books (scan a stack of ISBNs one after another), Lend / return (live card or
phone-QR scan, live book check-out for the chosen person, live returns). Chrome on Android reads codes in the browser; other
browsers send small camera frames to the server's decoder a couple of times a second. On plain HTTP the photo buttons stay.
### Copying reading levels from AR BookFind
On the AR BookFind book page, select everything (Ctrl/Cmd+A), copy, and paste into the box on the book page here: the level,
points, quiz number and interest level fill in by themselves; check them and press Save.
### Tags, and borrowers signing in
- **Tags**: any number per book (to read, kids, signed...). Add and remove them on the book page; click one to see every
book that has it; the Library page can filter by tag. They are stored in `tags.json` in the Add books data volume, which
the usual backups of that folder include.
- **Borrowers sign in**: every person gets a login, **their library card number + a password**. Adding someone shows
their password once (a different easy one like `maple482`, or the same one for everyone: People -> *Sign-in passwords for new
people*). *Give everyone a sign-in* covers people added earlier; *New password* resets one person. A borrower who signs in
sees the library and a **My books** page: what they have out, when each is due (or overdue), **Renew**, and **Change my
password**. They cannot lend, edit or see other people. Passwords are Koha's own patron passwords; this stack relaxes Koha's
default strength rule (4+ characters instead of 8 with upper/lower/digit) once, at install or update.
Ten wrong guesses from one address locks sign-in attempts from it for five minutes.
### Reading levels: pop-up or in-page AR BookFind
The book page's AR box has **Open beside this page** (a pop-up window next to the book) and **Show it here** (AR BookFind inside the
page; some browsers block its cookies inside another page, then use the pop-up). Either copies the title first so you can paste
it into AR's search; AR's own page cannot be pre-filled from here.
+2
View File
@@ -35,6 +35,7 @@ services:
environment:
TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
KOHA_SSO_SECRET: ${SSO_SECRET:-}
KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
@@ -60,6 +61,7 @@ services:
environment:
TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1
SSO_SECRET: ${SSO_SECRET:-}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
+3
View File
@@ -127,6 +127,8 @@ ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16)
VIEWER_USER=guest
VIEWER_PASSWORD=$(rand 14)
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
SSO_SECRET=$(rand 40)
DB_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable
@@ -142,6 +144,7 @@ else
if ! grep -q '^VIEWER_PASSWORD=' .env; then
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
fi
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
# Existing installs keep their current public-catalogue behaviour.
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
if ! grep -q '^BIND_ADDR=' .env; then
+6
View File
@@ -39,6 +39,12 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
COPY seed/ /opt/koha-init/seed/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
+29 -9
View File
@@ -7,6 +7,7 @@ use Modern::Perl;
use C4::Context;
use Koha::Patrons;
use Koha::Patron::Categories;
use Koha::Caches;
my $dbh = C4::Context->dbh;
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
@@ -20,16 +21,35 @@ if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
}
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool).
my ($have) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM additional_contents WHERE code = 'library_tools'});
unless ($have) {
my $html = '<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>';
# Patron logins use short, easy-to-remember passwords (a home library, not a bank): Koha's defaults
# (8+ characters, upper+lower+digit) are relaxed once, unless an admin has changed them since.
my $flag = '/var/lib/koha/' . ( $ENV{KOHA_INSTANCE} || 'library' ) . '/.password-policy-set';
unless ( -e $flag ) {
my ($strong) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'RequireStrongPassword'});
my ($minlen) = $dbh->selectrow_array(q{SELECT value FROM systempreferences WHERE variable = 'minPasswordLength'});
if ( ( $strong // 1 ) == 1 && ( $minlen // 8 ) == 8 ) {
$dbh->do(q{UPDATE systempreferences SET value = 0 WHERE variable = 'RequireStrongPassword'});
$dbh->do(q{UPDATE systempreferences SET value = 4 WHERE variable = 'minPasswordLength'});
say "ensure: patron passwords may be short (4+ characters, no strength rule)";
Koha::Caches->get_instance->flush_all; # preferences are cached; direct SQL edits need this
}
if ( open my $fh, '>', $flag ) { close $fh }
}
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool). Rewritten each start.
my $html = '<div class="main-heading"><h3>Room for Books</h3></div><ul class="list-unstyled">'
. '<li><a class="btn btn-primary" href="/add/">Open Room for Books</a> (library, lend / return, people, add books)</li>'
. '<li><a href="/add/#find">Find a book</a></li></ul>';
my ($id) = $dbh->selectrow_array(q{SELECT id FROM additional_contents WHERE code = 'library_tools'});
unless ($id) {
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
$id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html );
say "ensure: added the staff home 'Library tools' block";
VALUES (?, 'Room for Books', ?, 'default')}, undef, $id, $html );
say "ensure: added the staff home 'Room for Books' block";
}
else {
$dbh->do( q{UPDATE additional_contents_localizations SET title = 'Room for Books', content = ? WHERE additional_content_id = ? AND lang = 'default'},
undef, $html, $id );
}
+18 -4
View File
@@ -60,22 +60,36 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
# (The staff and public catalogue sites cannot share one address: Koha gives both the
# same URL shapes. The staff site is the natural home for a home library.)
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
# Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
# secret header nobody outside can forge: it is overwritten here on every request) that the
# request came through the staff site, and Quick Add then asks Koha whether the browser's login
# cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
# about that fails, Quick Add just asks for its own login as before.
SSO_BLOCK=""
if [ -n "${KOHA_SSO_SECRET:-}" ]; then
install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
SSO_BLOCK=" <IfModule mod_headers.c>
<Location /add/>
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
</Location>
</IfModule>"
fi
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%/}/
${SSO_BLOCK}
</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).
# koha-create insists on mpm_itk being enabled, but inside a container it can
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
# page). Now that the instance exists, swap to plain prefork.
# page). Now that the instance exists, swap to plain prefork; Apache runs as the
# instance user (Plack does the real work as that user already).
rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl).
#
# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it
# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears
# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers
# that know the secret get an answer.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use C4::Context;
use C4::Circulation qw( AddReturn );
use Koha::Items;
my $q = CGI->new;
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
my $barcode = $q->param('barcode') // '';
reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/;
my $item = Koha::Items->find( { barcode => $barcode } );
reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item;
# AddReturn needs to know which branch it is happening at.
my $branch = $item->homebranch;
my ( $doreturn, $messages, $borrower, $was_out );
eval {
C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' );
$was_out = $item->checkout ? 1 : 0;
( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch );
1;
} or reply( $q, '200 OK', { status => 'failed', error => "$@" } );
reply(
$q, '200 OK',
{
status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed',
patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '',
messages => [ sort keys %{ $messages || {} } ],
}
);
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/svc/quickadd-session (URL /cgi-bin/koha/svc/quickadd-session).
#
# Lets the Add books page (Quick Add) ask: "is this browser signed in to the staff site as
# someone allowed to edit the catalogue?" so staff do not have to log in a second time.
#
# It wraps Koha's own C4::Auth::check_cookie_auth (documented for exactly this: services that
# are invoked by pages authenticated in the usual way) and only answers callers that present a
# shared secret, so it cannot be used to probe sessions from outside. The browser's address is
# passed in explicitly, so Koha's "session is tied to the IP that logged in" rule keeps working.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use C4::Auth qw( check_cookie_auth );
my $q = CGI->new;
# Takes the CGI object as an argument: under Plack this file runs wrapped in a sub, where a named
# sub would otherwise see a stale copy of a file-level variable.
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
# 1. The caller must know the shared secret (a file only the Koha user and root can read).
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
# 2. Plausible-looking inputs only.
my $sid = $q->param('sid') // '';
my $ip = $q->param('ip') // '';
reply( $q, '200 OK', { status => 'failed' } )
unless $sid =~ /\A[A-Za-z0-9]{16,64}\z/ && $ip =~ /\A[0-9A-Fa-f:.]{3,45}\z/;
# 3. Ask Koha. Staff who may edit the catalogue (superlibrarians always may) are let in.
my ( $status, $session ) = check_cookie_auth( $sid, { editcatalogue => 'edit_catalogue' }, { remote_addr => $ip } );
reply( $q, '200 OK', { status => 'ok', user => scalar $session->param('id') } ) if $status eq 'ok' && $session;
reply( $q, '200 OK', { status => $status || 'failed' } );
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/perl
# Installed as .../intranet/cgi-bin/quickadd-value.pl (URL /cgi-bin/koha/quickadd-value.pl).
#
# Koha's REST API cannot add shelves/rooms (authorised value category LOC) or collections (CCODE),
# so the Library site asks this script to, with Koha's own Koha::AuthorisedValue class. Same shared-secret
# guard as quickadd-session / quickadd-return; only the two categories below can be touched.
use Modern::Perl;
use CGI qw( -utf8 );
use JSON qw( encode_json );
use Digest::SHA qw( sha256_hex );
use Koha::AuthorisedValues;
use Koha::AuthorisedValue;
my $q = CGI->new;
sub reply {
my ( $q, $status, $data ) = @_;
print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ),
encode_json($data);
exit;
}
my $secret = '';
if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh }
my $given = $ENV{HTTP_X_QUICKADD_SECRET} // '';
reply( $q, '403 Forbidden', { status => 'forbidden' } )
unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret);
my $cat = $q->param('category') // '';
my $value = $q->param('value') // '';
my $desc = $q->param('description') // '';
utf8::decode($desc);
reply( $q, '200 OK', { status => 'invalid' } )
unless $cat =~ /\A(?:LOC|CCODE)\z/ && $value =~ /\A[A-Z0-9_-]{1,20}\z/ && length($desc) && length($desc) <= 80;
my $out = eval {
my $exists = Koha::AuthorisedValues->search( { category => $cat, authorised_value => $value } )->count;
return { status => 'exists' } if $exists;
Koha::AuthorisedValue->new( { category => $cat, authorised_value => $value, lib => $desc, lib_opac => $desc } )->store;
{ status => 'added' };
};
reply( $q, '200 OK', $out // { status => 'failed', error => "$@" } );
+2 -2
View File
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
&& rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
@@ -13,7 +13,7 @@ RUN apt-get update \
COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data
Binary file not shown.
+955 -20
View File
File diff suppressed because it is too large Load Diff
+766 -43
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add to library</title>
<title>Room for Books</title>
<style>
:root {
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
@@ -53,6 +53,7 @@
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; }
a { color: var(--accent); }
.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); }
@@ -93,28 +94,188 @@
border-radius: 8px; background: var(--card); color: var(--ink); }
details.settings input[type=number] { width: 6em; }
.result-note { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok); }
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; }
.filters select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); max-width: 100%; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 14px 12px; margin: 6px 0 16px; }
.tile { display: block; text-decoration: none; color: inherit; }
.tile .art { position: relative; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line);
display: flex; align-items: flex-end; padding: 8px; font-weight: 700; font-size: .88rem; line-height: 1.2;
color: #fff; background: var(--tint, #5b6f63); box-shadow: 0 1px 3px rgba(0,0,0,.18); overflow-wrap: anywhere; }
.tile .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
.tile .art img[hidden] { display: none; }
.tile:hover .art, .tile:focus-visible .art { outline: 3px solid var(--accent); outline-offset: 2px; }
.tile .tt { margin-top: 6px; font-weight: 600; font-size: .9rem; line-height: 1.25; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tile .aa { color: var(--muted); font-size: .82rem; overflow-wrap: anywhere; }
.tile .flag { position: absolute; top: 6px; right: 6px; z-index: 1; font-size: .72rem; padding: 1px 8px; border-radius: 999px;
background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn); font-weight: 700; }
.tile .num { position: absolute; top: 6px; left: 6px; z-index: 1; font-size: .72rem; padding: 1px 7px; border-radius: 999px;
background: rgba(0,0,0,.65); color: #fff; font-weight: 700; }
.bookhead { display: flex; gap: 16px; flex-wrap: wrap; margin: 8px 0 14px; }
.bookhead .art { width: 140px; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line); position: relative;
background: var(--tint, #5b6f63); color: #fff; font-weight: 700; padding: 10px; display: flex; align-items: flex-end; flex: none; overflow-wrap: anywhere; }
.bookhead .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
.bookhead .art img[hidden] { display: none; }
.bookhead h2 { margin: 0 0 4px; font-size: 1.4rem; }
.copybox { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
button.danger { color: var(--bad); border-color: var(--bad); }
.person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); }
.person .t { flex: 1; min-width: 180px; }
.loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }
.loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; }
.chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
.libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000;
display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; }
.libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; }
.libcard .codes { display: flex; gap: 3mm; align-items: center; } .libcard .bc { flex: 1; min-width: 0; }
.libcard .bc svg { width: 100%; height: 14mm; display: block; } .libcard img.qr { width: 19mm; height: 19mm; flex: none; }
.libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; }
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
.cardsheet .libcard { break-inside: avoid; }
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
#live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; }
#live[hidden] { display: none; }
#live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; }
#live .frame { position: relative; width: min(100%, 640px); }
#live .frame::after { content: ''; position: absolute; inset: 18% 12%; border: 3px solid rgba(255,255,255,.85); border-radius: 12px; pointer-events: none; }
#live .msg { color: #fff; font-weight: 600; text-align: center; min-height: 3em; max-width: 640px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
<body>
<main>
<header>
<h1 id="title">Add to library</h1>
<h1 id="title">Room for Books</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="tab-library" href="#library" class="on">Library</a>
<a id="tab-mine" href="#mine" hidden>My books</a>
<a id="tab-lend" href="#lend">Lend / return</a>
<a id="tab-people" href="#people">People</a>
<a id="tab-add" href="#add">Add books</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>
<section id="view-library" 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>
<input id="q" type="search" placeholder="Search title, author or ISBN" enterkeyhint="search">
<button class="primary" type="submit">Search</button>
</form>
<div id="found" aria-live="polite"></div>
<div class="filters">
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
<label class="sr" for="f-tag">Tag</label><select id="f-tag"></select>
<label class="sr" for="f-coll">Collection</label><select id="f-coll"></select>
<label class="sr" for="f-type">Format</label><select id="f-type"></select>
<label class="sr" for="f-status">Status</label><select id="f-status">
<option value="">Any status</option><option value="in">On the shelf</option><option value="out">Checked out</option></select>
<label class="sr" for="f-sort">Sort</label><select id="f-sort">
<option value="title">Sort: title</option><option value="author">Sort: author</option><option value="new">Sort: newest first</option></select>
</div>
<div id="libstatus" class="small" aria-live="polite"></div>
<div class="grid" id="grid"></div>
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
<details class="settings" id="csvtools">
<summary>Export / import</summary>
<div class="tool"><strong>Export your books</strong>
<div class="small">A spreadsheet (CSV) with one row per copy: title, author, ISBN, barcode, shelf, collection, status and any reading level on file.</div>
<div class="actions"><a href="api/export.csv" download><button type="button">Download library.csv</button></a></div></div>
<div class="tool"><strong>Import reading levels</strong>
<div class="small">A CSV with a header row and columns <code>isbn</code>, <code>level</code>, <code>points</code> (and optionally <code>quiz</code>). Existing levels for the same ISBN are updated.</div>
<div class="actions"><input type="file" id="arfile" accept=".csv,text/csv"><button type="button" id="arimport">Import</button></div>
<div id="arresult" class="small" aria-live="polite"></div></div>
</details>
</section>
<section id="view-lend" hidden>
<div class="copybox">
<strong>Return a book</strong>
<form class="scan" id="retform" autocomplete="off" style="margin-top:8px">
<label class="sr" for="ret">Barcode to return</label>
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
<button class="primary" type="submit">Return</button>
</form>
<div class="cam-row"><button type="button" id="retcam">Return with camera</button> <button type="button" id="retlive" hidden>Live return</button></div>
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
</div>
<div class="copybox">
<strong>Check out to&hellip;</strong>
<form class="find-form" id="whoform" autocomplete="off">
<label class="sr" for="who">Name or library card number</label>
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
<button type="submit">Find</button>
</form>
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button> <button type="button" id="wholive" hidden>Live scan</button>
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
<div id="whostatus" class="small"></div>
<div id="whoresults"></div>
<div id="patronbox"></div>
</div>
<h2>Checked out right now <span id="outcount" class="small"></span></h2>
<div id="outlist"></div>
</section>
<section id="view-people" hidden>
<details class="settings" id="newperson" open>
<summary>Add a person</summary>
<form class="manual" id="personform" autocomplete="off">
<input name="firstname" placeholder="First name" aria-label="First name">
<input name="surname" placeholder="Last name" required aria-label="Last name">
<input name="phone" placeholder="Phone (optional)" aria-label="Phone">
<input name="email" type="email" placeholder="Email (optional)" aria-label="Email">
<div class="actions"><button class="primary" type="submit">Add person</button></div>
</form>
<div id="personresult" aria-live="polite"></div>
</details>
<p><a href="#cards"><button type="button">Print everyone's cards</button></a>
<button type="button" id="alllogins">Give everyone a sign-in</button></p>
<details class="settings"><summary>Sign-in passwords for new people</summary>
<fieldset><label class="opt"><input type="radio" name="pwmode" value="generated"> A different easy password for each person (like maple482)</label>
<label class="opt"><input type="radio" name="pwmode" value="same"> The same password for everyone:
<input type="text" id="pwsame" maxlength="40" placeholder="e.g. readmore" style="font:inherit;padding:4px 8px"></label>
<div class="small">People sign in to see their own books with their library card number and this password; they can change it themselves. Existing passwords are never changed by this setting.</div>
<div class="actions"><button type="button" id="pwsave">Save</button> <span id="pwsaved" class="small"></span></div></fieldset>
</details>
<div id="loginresult" aria-live="polite"></div>
<form class="find-form" id="peopleform" autocomplete="off">
<label class="sr" for="pq">Search people</label>
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
<button type="submit">Search</button>
</form>
<div id="peoplelist"></div>
</section>
<section id="view-card" hidden>
<p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcard" class="primary">Print this card</button> <button type="button" id="savecard">Save as picture (to send to a phone)</button></p>
<div id="cardbox"></div>
</section>
<section id="view-mine" hidden>
<div class="copybox"><strong id="mename"></strong> <span class="badge" id="mecard"></span>
<div class="small">What you have borrowed, and when it is due.</div></div>
<div id="meloans" aria-live="polite"></div>
<details class="settings" id="mepw"><summary>Change my password</summary>
<form class="manual" id="mepwform" autocomplete="off">
<input type="password" name="old" placeholder="Current password" autocomplete="current-password" required aria-label="Current password">
<input type="password" name="new" placeholder="New password (4 or more characters)" autocomplete="new-password" required minlength="4" aria-label="New password">
<div class="actions"><button class="primary" type="submit">Change password</button></div>
</form>
<div id="mepwresult" class="small" aria-live="polite"></div>
</details>
</section>
<section id="view-cards" hidden>
<p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcards" class="primary">Print these cards</button>
<span class="small" id="cardsnote"></span></p>
<div class="cardsheet" id="cardsheet"></div>
</section>
<section id="view-book" hidden>
<p><a href="#library" id="back">&larr; Back to the library</a></p>
<div id="bookbox" aria-live="polite"></div>
</section>
<section id="view-add">
@@ -132,6 +293,7 @@
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<button type="button" id="livecam" hidden>Live scan</button>
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
</div>
@@ -179,6 +341,11 @@
<ul class="list" id="earlier"></ul>
</section>
</main>
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Camera scanner">
<div class="frame"><video id="livevideo" playsinline muted></video></div>
<div class="msg" id="livemsg" aria-live="polite"></div>
<button type="button" id="livestop" class="primary">Done</button>
</div>
<script>
(() => {
@@ -211,8 +378,10 @@
};
async function api(path, body) {
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {});
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
// that carry it, which a page on another website cannot add (cross-site request forgery).
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Quickadd': '1' },
body: JSON.stringify(body) } : { headers: { 'X-Quickadd': '1' } });
return r.json();
}
@@ -320,16 +489,17 @@
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto(kind) {
function takePhoto(kind, statusEl) {
statusEl = statusEl || $('status');
return new Promise(resolve => {
const input = $('camfile');
input.onchange = async () => {
const f = input.files[0]; input.value = '';
if (!f) return resolve(null);
$('status').textContent = 'Reading the barcode…';
statusEl.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', 'X-Quickadd': '1' }, body: blob });
resolve(r.ok ? (await r.json()).codes : []);
} catch (e) { resolve([]); }
};
@@ -337,6 +507,57 @@
});
}
// ---- live camera: works where the browser allows camera access (HTTPS, or the machine itself).
// Chrome/Android read codes in the browser (BarcodeDetector); everything else sends small frames to
// the server's decoder a couple of times a second. Plain-HTTP pages fall back to the photo buttons.
const liveOK = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let liveStop = null;
// onCode(code) may return text to show; continuous keeps scanning, otherwise the first code closes it.
async function liveScan(kind, title, onCode, continuous) {
const box = $('live'), video = $('livevideo'), msg = $('livemsg');
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } }, audio: false }); }
catch (e) { alert('The camera is not available (' + (e.name === 'NotAllowedError' ? 'permission was refused' : e.message) + '). Use the photo button instead.'); return; }
box.hidden = false; msg.textContent = title; video.srcObject = stream; await video.play().catch(() => {});
let alive = true, busy = false, lastCode = '', lastAt = 0;
const detector = 'BarcodeDetector' in window ? new BarcodeDetector({ formats: ['qr_code', 'code_39', 'code_128', 'ean_13', 'ean_8', 'upc_a', 'upc_e'] }) : null;
const canvas = document.createElement('canvas');
const finish = () => { alive = false; stream.getTracks().forEach(t => t.stop()); video.srcObject = null; box.hidden = true; liveStop = null; };
liveStop = finish; $('livestop').onclick = finish;
const accept = async code => {
if (!code || busy) return;
if (kind === 'isbn') { code = clean(code); if (!code) return; }
const now = Date.now();
if (code === lastCode && now - lastAt < 4000) return; // the same book still in view
lastCode = code; lastAt = now; busy = true;
beep(SOUND.added);
try { const t = await onCode(code); if (t) msg.textContent = t; } catch (e) {}
busy = false;
if (!continuous) finish();
};
const clean = c => { c = String(c).replace(/[\s-]/g, '').toUpperCase(); if (!/^(\d{8,14}|\d{9}X)$/.test(c)) return ''; return c.length === 13 && c[0] === '0' ? c.slice(1) : c; };
while (alive) {
try {
if (video.readyState >= 2 && !busy) {
if (detector) {
const found = await detector.detect(video);
if (found.length) await accept(found[0].rawValue);
} else {
const k = Math.min(1, 1280 / Math.max(video.videoWidth, video.videoHeight));
canvas.width = Math.round(video.videoWidth * k); canvas.height = Math.round(video.videoHeight * k);
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(ok => canvas.toBlob(ok, 'image/jpeg', 0.85));
const r = await fetch('api/scan?kind=' + (kind === 'isbn' ? 'isbn' : 'sticker'), { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
const codes = r.ok ? (await r.json()).codes : [];
if (codes.length && alive) await accept(codes[0]);
}
}
} catch (e) {}
await new Promise(ok => setTimeout(ok, detector ? 200 : 650));
}
}
window.addEventListener('hashchange', () => { if (liveStop) liveStop(); });
function noBarcode() {
beep(SOUND.notfound);
$('status').textContent = '';
@@ -344,6 +565,8 @@
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
}
$('livecam').hidden = !liveOK;
$('livecam').onclick = () => liveScan('isbn', 'Hold each book\'s barcode in the frame', async code => { await add(code); return 'Added - next book'; }, true);
$('cam').onclick = async () => {
const codes = await takePhoto('isbn');
if (codes === null) { $('status').textContent = ''; return; }
@@ -455,43 +678,542 @@
// ---- 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
let viewer = false, isPatron = 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();
// ---- views: Library / Book / Add books
const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a'];
const tint = t => TINTS[[...(t || '')].reduce((n, c) => (n * 31 + c.charCodeAt(0)) % 997, 7) % TINTS.length];
function art(b, cls) {
const a = el('span', { className: cls || 'art' }, b.title);
a.style.setProperty('--tint', tint(b.title));
if (b.isbn && /^\d{10,13}$/.test(b.isbn.replace(/\D/g, '').slice(0, 13))) {
const img = el('img', { alt: '', hidden: true, loading: 'lazy' });
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + b.isbn.replace(/\D/g, '').slice(0, 13) + '-M.jpg?default=false';
a.append(img);
}
return a;
}
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 libPage = 1, libShelves = [], libCollections = [], libLoaded = false, filtersReady = false;
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, collection: $('f-coll').value, tag: $('f-tag').value, type: $('f-type').value,
status: $('f-status').value, sort: $('f-sort').value });
const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
function tile(b) {
const a = el('a', { className: 'tile', href: '#book/' + b.id });
const picture = art(b);
if (b.copies > 1) picture.append(el('span', { className: 'num' }, '×' + b.copies));
if (!viewer && b.out) picture.append(el('span', { className: 'flag' }, b.out === b.copies ? 'Out' : b.out + ' out'));
else if (viewer && b.out === b.copies && b.copies) picture.append(el('span', { className: 'flag' }, 'Out'));
a.append(picture, el('div', { className: 'tt' }, b.title), el('div', { className: 'aa' }, b.author));
return a;
}
function fillTagFilter(tags) {
const cur = $('f-tag').value;
$('f-tag').replaceChildren(el('option', { value: '' }, 'Any tag'), ...tags.map(t => el('option', { value: t.tag }, t.tag + ' (' + t.count + ')')));
$('f-tag').value = [...$('f-tag').options].some(o => o.value === cur) ? cur : '';
}
let libSeq = 0;
async function loadLibrary(reset) {
const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant
if (reset) libPage = 1;
const grid = $('grid');
if (reset) grid.replaceChildren();
$('libstatus').textContent = 'Loading…';
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')))));
try { r = await api('/api/library?' + filterQuery() + '&page=' + libPage); } catch (e) { r = {}; }
if (seq !== libSeq) return;
if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
if (r.collections) libCollections = r.collections;
if (r.tags) fillTagFilter(r.tags);
if (!filtersReady && r.shelves) {
filtersReady = true;
libShelves = r.shelves;
$('f-coll').replaceChildren(el('option', { value: '' }, 'Any collection'), el('option', { value: '-' }, '(no collection)'),
...(r.collections || []).map(l => el('option', { value: l.value }, l.label)));
$('f-shelf').replaceChildren(el('option', { value: '' }, 'Any shelf'), el('option', { value: '-' }, '(no shelf set)'),
...r.shelves.map(l => el('option', { value: l.value }, l.label)));
$('f-type').replaceChildren(el('option', { value: '' }, 'Any format'),
...Object.entries(r.types || {}).map(([k, v]) => el('option', { value: k }, v)));
}
grid.append(...r.books.map(tile));
$('more').hidden = !r.more;
const shown = grid.children.length;
const f = filters();
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.tag || f.type || f.status ? ' match' : '') :
(f.q || f.shelf || f.collection || f.tag || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
}
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
$('arimport').onclick = async () => {
const f = $('arfile').files[0]; if (!f) { $('arresult').textContent = 'Choose a CSV file first.'; return; }
const r = await api('/api/ar/import', { csv: await f.text() });
$('arresult').textContent = r.status === 'imported' ? 'Imported ' + r.count + ' reading level' + (r.count === 1 ? '' : 's') + (r.skipped ? ' (' + r.skipped + ' rows skipped: no valid ISBN or level)' : '') + '.' : (r.message || 'Import failed.');
};
$('more').onclick = () => { libPage += 1; loadLibrary(false); };
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
for (const id of ['f-shelf', 'f-coll', 'f-tag', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
// ---- one book
async function loadBook(id) {
const box = $('bookbox');
box.replaceChildren(el('div', { className: 'small' }, 'Loading…'));
let b;
try { b = await api('/api/book/' + id); } catch (e) { b = {}; }
if (!b.copies) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, b.message || 'Book not found.'))); return; }
document.title = b.title;
if (b.shelves && b.shelves.length) libShelves = b.shelves;
if (b.collections) libCollections = b.collections;
const facts = [b.author, b.year, b.publisher, b.pages, b.edition, b.series].filter(Boolean);
const head = el('div', { className: 'bookhead' }, art(b),
el('div', { className: 'body' }, el('h2', {}, b.title), b.subtitle ? el('div', { className: 'meta' }, b.subtitle) : '',
el('div', { className: 'meta' }, facts.join(' · ')), b.isbn ? el('div', { className: 'small' }, 'ISBN ' + b.isbn) : '',
b.summary ? el('p', {}, b.summary) : ''));
box.replaceChildren(head);
const refresh = () => loadBook(id);
box.append(tagBox(b));
const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…'));
box.append(moreBox);
api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove());
for (const c of b.copies) box.append(copyBox(b, c, refresh));
if (!b.copies.length) box.append(el('div', { className: 'small' }, 'No copies recorded.'));
if (!viewer) {
const more = el('button', { type: 'button' }, 'Add another copy');
more.onclick = async () => { more.disabled = true; const r = await api('/api/item/copy', { biblio_id: b.id }); if (r.status === 'added') refresh(); else more.disabled = false; };
const del = el('button', { type: 'button', className: 'danger' }, 'Delete this book');
del.onclick = async () => {
if (!confirm('Delete "' + b.title + '" and all its copies from the library?')) return;
const r = await api('/api/book/delete', { biblio_id: b.id });
if (r.status === 'deleted') location.hash = '#library'; else alert(r.message || 'Could not delete it.');
};
const full = el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.id, target: '_blank', rel: 'noopener' }, 'Open the full record in Koha');
box.append(el('div', { className: 'actions' }, more, del), el('p', { className: 'small' }, full, ' (edit title, author and other details there)'));
}
}
// A shelf/collection chooser whose last entry adds a new one (Koha's own screens are not needed).
function valueSelect(kind, label, none, list, current) {
const sel = el('select', { 'aria-label': label });
const fill = (items, cur) => { sel.replaceChildren(el('option', { value: '' }, none), ...items.map(l => el('option', { value: l.value }, l.label)),
el('option', { value: '__new' }, '+ New ' + kind + '…')); sel.value = cur || ''; };
fill(list, current);
let last = current || '';
sel.onchange = async () => {
if (sel.value !== '__new') { last = sel.value; return; }
const name = prompt('Name of the new ' + kind + ':');
if (!name || !name.trim()) { sel.value = last; return; }
const r = await api('/api/value/add', { kind, name });
if (r.status !== 'added') { alert(r.message || 'Could not add that.'); sel.value = last; return; }
const items = (kind === 'shelf' ? libShelves : libCollections); items.push({ value: r.value, label: r.label });
fill(items, r.value); last = r.value;
};
return sel;
}
// Reading level (AR): typed in here or imported from a CSV. AR BookFind itself is only a link: it has no
// public feed and its terms forbid automated look-ups.
function arBox(b, m) {
const ar = m.ar || {};
const f = (ph, v, w) => el('input', { type: 'text', placeholder: ph, value: v || '', 'aria-label': ph, inputMode: 'decimal', style: 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
const lvl = f('Level', ar.level, 5), pts = f('Points', ar.points, 5), quiz = f('Quiz no.', ar.quiz, 7), inter = f('Interest (LG/MG/UG)', ar.interest, 9);
const wrap = el('div', { className: 'small', style: 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' });
const copy = el('button', { type: 'button' }, 'Copy title');
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
const copyTitle = async () => { try { await navigator.clipboard.writeText(b.title); } catch (e) {} };
const arPop = el('button', { type: 'button' }, 'Open beside this page');
arPop.onclick = async () => { await copyTitle();
const w = Math.min(560, Math.floor(screen.availWidth / 2)), h = screen.availHeight - 80;
window.open('https://www.arbookfind.com/', 'arbookfind', 'popup=yes,width=' + w + ',height=' + h + ',left=' + (screen.availWidth - w) + ',top=40'); };
const arHolder = el('div', {});
const arFrame = el('button', { type: 'button' }, 'Show it here');
arFrame.onclick = async () => { await copyTitle();
if (arHolder.firstChild) { arHolder.replaceChildren(); arFrame.textContent = 'Show it here'; return; }
arFrame.textContent = 'Hide it';
arHolder.replaceChildren(el('iframe', { src: 'https://www.arbookfind.com/', title: 'AR BookFind', loading: 'lazy', referrerPolicy: 'no-referrer',
style: 'width:100%;height:520px;border:1px solid var(--line);border-radius:8px;margin-top:8px;background:#fff' }),
el('div', { className: 'small' }, 'If it keeps asking who you are, your browser is blocking its cookies inside another page: use "Open beside this page" instead.')); };
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : 'not recorded'); return wrap; }
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
const msg = el('span', {});
save.onclick = async () => {
const r = await api('/api/ar/set', { isbn: m.isbn, level: lvl.value, points: pts.value, quiz: quiz.value, interest: inter.value });
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
};
// Easiest path: on AR BookFind's book page select everything (Ctrl/Cmd+A), copy, and paste it here.
const paste = el('textarea', { rows: 2, placeholder: 'Paste the AR BookFind book page here (select all, copy, paste)', 'aria-label': 'Pasted AR BookFind text',
style: 'width:100%;margin-top:6px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
paste.oninput = () => {
const t = paste.value, g = re => (t.match(re) || [])[1] || '';
const found = { level: g(/Book\s*Level:?\s*([0-9]{1,2}(?:\.[0-9]{1,2})?)/i), points: g(/(?:AR\s*)?Points:?\s*([0-9]{1,3}(?:\.[0-9]{1,2})?)/i),
quiz: g(/Quiz\s*(?:No\.?|Number|#)\s*:?\s*([0-9]{3,6})/i), interest: g(/Interest\s*Level:?\s*(LG|MG\+?|UG)/i) };
if (found.level) lvl.value = found.level; if (found.points) pts.value = found.points; if (found.quiz) quiz.value = found.quiz; if (found.interest) inter.value = found.interest;
msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : '');
};
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste,
el('div', { style: 'margin-top:6px' }, open, ' ', arPop, ' ', arFrame, ' ', copy),
el('div', { className: 'small' }, 'Search for the title there, select the whole book page, copy, and paste it below. (The title is copied for you when you open it.)'), arHolder);
return wrap;
}
// Tags: any number per book, free words ("to read", "kids", "signed"...). Click one to see every book with it.
function tagBox(b) {
const box = el('div', { className: 'copybox' });
let tags = [...(b.tags || [])];
const all = (b.all_tags || []).map(t => t.tag);
const draw = () => {
const chips = tags.map(t => {
const c = el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' });
const a = el('a', { href: '#library', style: 'text-decoration:none' }, t);
a.onclick = () => { setTimeout(() => { $('f-tag').value = t; loadLibrary(true); }, 0); };
c.append(a);
if (!viewer) { const x = el('button', { type: 'button', 'aria-label': 'Remove tag ' + t, style: 'border:0;background:none;padding:0 0 0 6px;cursor:pointer;color:inherit' }, '×');
x.onclick = () => save(tags.filter(y => y !== t)); c.append(x); }
return c;
});
const parts = [el('strong', {}, 'Tags: '), ...(chips.length ? chips : [el('span', { className: 'small' }, viewer ? 'none' : 'none yet ')])];
if (!viewer) {
const list = el('datalist', { id: 'taglist' }, ...all.filter(t => !tags.includes(t)).map(t => el('option', { value: t })));
const inp = el('input', { type: 'text', placeholder: 'Add a tag, press Enter', 'aria-label': 'Add a tag', maxLength: 30,
style: 'font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);min-width:12em' });
inp.setAttribute('list', 'taglist');
inp.onkeydown = ev => { if (ev.key === 'Enter') { ev.preventDefault(); const v = inp.value.trim(); if (v) save([...tags, v]); } };
parts.push(list, inp);
}
box.replaceChildren(...parts);
};
const save = async next => {
const r = await api('/api/tags/set', { biblio_id: b.id, tags: next });
if (r.status === 'saved') { tags = r.tags; draw(); fillTagFilter(r.all || []); }
};
draw();
return box;
}
function fillMore(box, b, m) {
const parts = [];
if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.'));
if (m.subjects && m.subjects.length) parts.push(el('div', { style: 'margin:8px 0' }, ...m.subjects.map(t => el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' }, t))));
if (m.first_published) parts.push(el('div', { className: 'small' }, 'First published: ' + m.first_published));
const row = el('div', { className: 'small', style: 'margin-top:8px' }, 'Look it up: ');
(m.links || []).forEach((l, i) => { if (i) row.append(' · '); row.append(el('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer' }, l.label)); });
parts.push(row);
if ((m.links || []).some(l => l.note)) parts.push(arBox(b, m));
box.replaceChildren(...parts);
}
function copyBox(b, c, refresh) {
const box = el('div', { className: 'copybox' });
const out = c.status === 'Checked out';
const status = el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status +
(out && c.out ? (c.out.who ? ' to ' + c.out.who : '') + (c.out.due ? ', due ' + c.out.due : '') : ''));
box.append(el('div', {}, c.barcode ? el('span', { className: 'badge' }, c.barcode) : el('span', { className: 'small' }, 'no barcode yet'), ' ', status));
if (viewer) {
box.append(el('div', { className: 'meta' }, [c.shelf_label || 'no shelf set', c.callnumber && 'Call no. ' + c.callnumber].filter(Boolean).join(' · ')));
return box;
}
const sel = valueSelect('shelf', 'Shelf', '(no shelf set)', libShelves, c.shelf);
const csel = valueSelect('collection', 'Collection', '(no collection)', libCollections, c.collection);
const cn = el('input', { type: 'text', value: c.callnumber, placeholder: 'Call number', 'aria-label': 'Call number', maxLength: 80 });
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
save.onclick = async () => {
save.disabled = true;
const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, collection: csel.value, callnumber: cn.value });
save.textContent = r.status === 'saved' ? 'Saved ✓' : 'Failed'; setTimeout(() => { save.textContent = 'Save'; save.disabled = false; }, 1500);
};
const rm = el('button', { type: 'button', className: 'danger' }, 'Remove copy');
rm.disabled = out;
rm.onclick = async () => {
if (!confirm('Remove this copy' + (c.barcode ? ' (barcode ' + c.barcode + ')' : '') + '?')) return;
const r = await api('/api/item/delete', { item_id: c.item_id, biblio_id: b.id });
if (r.status === 'undone') { if (b.copies.length <= 1) location.hash = '#library'; else refresh(); }
};
const lab = el('a', { href: printLink(c.barcode, c.barcode), target: '_blank', rel: 'noopener' }, el('button', { type: 'button' }, 'Print label'));
box.append(el('div', { className: 'row' }, sel, csel, cn, save, c.barcode ? lab : '', rm));
return box;
}
// ---- lend / return
const fmtDue = d => d ? new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
let curPatron = null, lendBusy = false;
function say(box, kind, text) { box.replaceChildren(el('div', { className: 'card ' + kind }, el('div', { className: 'body' }, text))); }
async function doReturn(code, box) {
box.textContent = 'Returning…';
let r; try { r = await api('/api/return', { barcode: code }); } catch (e) { r = { status: 'error', message: 'Could not reach the service.' }; }
if (r.status === 'returned') { beep(SOUND.added); say(box, 'ok', 'Returned: ' + (r.title || code) + (r.patron ? ' (from ' + r.patron + ')' : '')); }
else if (r.status === 'not_out') { beep(SOUND.duplicate); say(box, 'warn', (r.title || code) + ' was not checked out.'); }
else if (r.status === 'unknown_barcode') { beep(SOUND.notfound); say(box, 'warn', 'No book has the barcode ' + code + '.'); }
else { beep(SOUND.error); say(box, 'bad', r.message || 'Could not return that.'); }
loadOut(); if (curPatron) openPatron(curPatron.id, true);
return r;
}
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
$('retlive').hidden = $('wholive').hidden = !liveOK;
$('retlive').onclick = () => liveScan('sticker', 'Scan each returned book', async code => {
const r = await doReturn(code, $('retresult')); return $('retresult').innerText; }, true);
async function pickByCode(code) {
const r = await api('/api/patrons?q=' + encodeURIComponent(code));
const hit = (r.patrons || []).filter(p => p.card === code);
if (hit.length === 1) { location.hash = '#lend/' + hit[0].id; return true; }
return false;
}
$('wholive').onclick = () => liveScan('sticker', 'Hold the library card or the phone QR in the frame', async code => {
if (await pickByCode(code)) return; beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + code + '.'; }, false);
$('retcam').onclick = async () => {
const codes = await takePhoto('sticker', $('retresult'));
if (codes === null) { $('retresult').textContent = ''; return; }
if (!codes.length) { $('retresult').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
doReturn(codes[0], $('retresult'));
};
$('whocam').onclick = async () => {
const codes = await takePhoto('sticker', $('whostatus'));
if (codes === null) { $('whostatus').textContent = ''; return; }
$('whostatus').textContent = '';
if (!codes.length) { $('whostatus').textContent = "Couldn't read a code from that photo - move closer and try again."; return; }
const r = await api('/api/patrons?q=' + encodeURIComponent(codes[0]));
const hit = (r.patrons || []).filter(p => p.card === codes[0]);
if (hit.length === 1) { beep(SOUND.added); location.hash = '#lend/' + hit[0].id; }
else { beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + codes[0] + '.'; }
};
async function loadOut() {
let r; try { r = await api('/api/out'); } catch (e) { return; }
if (!r.loans) return;
$('outcount').textContent = r.loans.length ? '(' + r.loans.length + ')' : '';
const late = r.loans.filter(l => l.overdue).length;
if (!r.loans.length) { $('outlist').replaceChildren(el('div', { className: 'small' }, 'Nothing is checked out.')); return; }
$('outlist').replaceChildren(...(late ? [el('div', { className: 'small' }, late + ' overdue')] : []), ...r.loans.map(l => loanRow(l, true)));
}
function loanRow(l, showWho) {
const ret = el('button', { type: 'button' }, 'Return');
ret.onclick = async () => { ret.disabled = true; await doReturn(l.barcode, $('retresult')); };
const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => {
ren.disabled = true; const r = await api('/api/renew', { checkout_id: l.checkout_id });
if (r.status === 'renewed') { loadOut(); if (curPatron) openPatron(curPatron.id, true); } else { ren.disabled = false; alert(r.message); }
};
const a = el('a', { href: '#book/' + l.biblio_id }, l.title);
return el('div', { className: 'loan' }, el('span', { className: 'badge' }, l.barcode),
el('span', { className: 't' }, a, showWho ? el('div', { className: 'small' }, l.who) : ''),
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, (l.overdue ? 'Overdue, was due ' : 'Due ') + fmtDue(l.due)), ret, ren);
}
async function openPatron(id, quiet) {
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
if (!p.name) { $('patronbox').textContent = 'Could not load that person.'; return; }
curPatron = p;
$('whoresults').replaceChildren();
const scan = el('input', { placeholder: 'Scan a book to check out', inputMode: 'numeric', autocomplete: 'off', 'aria-label': 'Barcode to check out' });
const form = el('form', { className: 'scan' }, scan, el('button', { className: 'primary', type: 'submit' }, 'Check out'));
const out = (quiet && document.getElementById('coresult')) || el('div', { id: 'coresult', role: 'status', className: 'small', style: 'margin-top:8px' });
form.onsubmit = async ev => {
ev.preventDefault(); const code = scan.value.trim(); scan.value = ''; if (!code || lendBusy) return; lendBusy = true;
try {
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
if (r.status === 'checked_out') { beep(SOUND.added); say(out, 'ok', (r.title || code) + ' - due ' + fmtDue(r.due)); }
else if (r.status === 'already_out') {
beep(SOUND.duplicate);
const rb = el('button', { type: 'button', className: 'primary' }, 'Return it, then check it out');
rb.onclick = async () => { rb.disabled = true; await doReturn(code, out); const r2 = await api('/api/checkout', { patron_id: p.id, barcode: code });
if (r2.status === 'checked_out') say(out, 'ok', (r2.title || code) + ' - due ' + fmtDue(r2.due)); await openPatron(p.id, true); loadOut(); };
out.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, (r.title || code) + ' is already checked out. ', rb)));
} else { beep(SOUND.notfound); say(out, 'bad', r.message || 'That did not work.'); }
} catch (e) { say(out, 'bad', 'Could not reach the service.'); }
lendBusy = false; await openPatron(p.id, true); loadOut(); document.querySelector('#patronbox form input').focus();
};
const bookCam = el('button', { type: 'button' }, 'Scan book with camera');
bookCam.onclick = async () => {
const codes = await takePhoto('sticker', out);
if (codes === null) { out.textContent = ''; return; }
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
scan.value = codes[0]; form.requestSubmit();
};
const bookLive = el('button', { type: 'button', hidden: !liveOK }, 'Live scan books');
bookLive.onclick = () => liveScan('sticker', 'Scan each book to check out to ' + p.name, async code => {
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
openPatron(p.id, true); loadOut();
if (r.status === 'checked_out') return (r.title || code) + ' - due ' + fmtDue(r.due);
beep(SOUND.notfound); return (r.title ? r.title + ': ' : '') + (r.message || 'That did not work.');
}, true);
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
form, el('div', { className: 'cam-row' }, bookCam, ' ', bookLive), out, el('h2', {}, 'Has out'), ...loans,
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
el('a', { href: '#lend' }, 'Choose someone else')));
if (!quiet) scan.focus();
}
async function findPeople(term, into, withActions) {
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
if (!r.patrons) { into.textContent = (r && r.message) || 'Search failed.'; return []; }
if (!r.patrons.length) { into.replaceChildren(el('div', { className: 'small' }, term ? 'Nobody matches "' + term + '".' : 'No people yet.')); return []; }
into.replaceChildren(...r.patrons.map(p => el('div', { className: 'person' },
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, [p.card, p.phone, p.email].filter(Boolean).join(' · '))),
...withActions(p))));
return r.patrons;
}
$('whoform').onsubmit = async ev => {
ev.preventDefault(); const term = $('who').value.trim(); if (!term) return;
const list = await findPeople(term, $('whoresults'), p => { const b = el('button', { type: 'button', className: 'primary' }, 'Choose');
b.onclick = () => { location.hash = '#lend/' + p.id; }; return [b]; });
const exact = list.filter(p => p.card === term);
if (exact.length === 1) { $('who').value = ''; location.hash = '#lend/' + exact[0].id; } // a scanned card goes straight through
};
// ---- people
async function loadPeople(term) {
await findPeople(term, $('peoplelist'), p => {
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns'));
const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
const reset = el('button', { type: 'button' }, 'New password');
reset.onclick = async () => { if (!confirm('Give ' + p.name + ' a new sign-in password?')) return; const r = await api('/api/patron/reset-login', { patron_id: p.id });
$('loginresult').replaceChildren(el('div', { className: 'result-note' }, r.status === 'saved' ? r.name + ': sign in with card number ' + r.card + ' and password ' : 'Could not do that.', r.password ? el('strong', {}, r.password) : '')); };
return [lend, card, reset];
});
}
$('alllogins').onclick = async () => {
if (!confirm('Create a sign-in for everyone who does not have one yet? You will see each password once.')) return;
const r = await api('/api/logins/all', {});
if (r.status !== 'done') { $('loginresult').textContent = r.message || 'Could not do that.'; return; }
$('loginresult').replaceChildren(r.made.length ? el('div', { className: 'result-note' }, el('strong', {}, r.made.length + ' sign-ins created - write these down or print this page:'),
...r.made.map(x => el('div', {}, x.name + ' - card ' + x.card + ' - password ', el('strong', {}, x.password)))) : el('div', { className: 'small' }, 'Everyone already has a sign-in.'));
};
let pwModeLoaded = false;
async function loadPwSettings() {
if (pwModeLoaded) return; const v = await api('/api/settings'); const st = v.settings || {}; pwModeLoaded = true;
document.querySelectorAll('input[name=pwmode]').forEach(i => { i.checked = i.value === (st.patron_password_mode || 'generated'); });
$('pwsame').value = st.patron_password || '';
}
$('pwsave').onclick = async () => {
const mode = document.querySelector('input[name=pwmode]:checked').value;
const r = await api('/api/settings', { patron_password_mode: mode, patron_password: $('pwsame').value });
$('pwsaved').textContent = r.settings && r.settings.patron_password_mode === mode ? 'Saved ✓' + (mode === 'same' && !r.settings.patron_password ? ' (enter a password of 4+ characters)' : '') : 'Could not save.';
};
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
$('personform').onsubmit = async ev => {
ev.preventDefault();
const f = ev.target, confirm = f.dataset.confirm === '1'; delete f.dataset.confirm;
const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {}));
if (r.status === 'added') {
f.reset();
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), r.password ? ' (sign-in: card number + password ' : '', r.password ? el('strong', {}, r.password) : '', r.password ? ')' : '', '. ',
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
loadPeople('');
} else if (r.status === 'duplicate') {
const again = el('button', { type: 'button', className: 'primary' }, 'Add another person with this name');
again.onclick = () => { f.requestSubmit && (f.dataset.confirm = '1'); f.requestSubmit(); };
$('personresult').replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message + ' ', again)));
} else $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
};
// ---- printable library card (Code 39 barcode drawn as SVG)
const C39 = { '0': 'nnnwwnwnn', '1': 'wnnwnnnnw', '2': 'nnwwnnnnw', '3': 'wnwwnnnnn', '4': 'nnnwwnnnw', '5': 'wnnwwnnnn',
'6': 'nnwwwnnnn', '7': 'nnnwnnwnw', '8': 'wnnwnnwnn', '9': 'nnwwnnwnn', '*': 'nwnnwnwnn' };
function code39(text) {
const W = 3; let x = 10, bars = '';
for (const ch of '*' + text + '*') {
const pat = C39[ch]; if (!pat) continue;
for (let i = 0; i < 9; i++) { const w = pat[i] === 'w' ? W : 1; if (i % 2 === 0) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>'; x += w; }
x += 1; // narrow gap between characters
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (x + 9) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>';
}
function cardEl(p) {
const svg = el('div', { className: 'bc' });
svg.innerHTML = code39(p.card);
const qr = el('img', { className: 'qr', alt: 'QR code ' + p.card, src: 'api/qr?t=' + encodeURIComponent(p.card) });
return el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
el('div', {}, el('div', { className: 'codes' }, svg, qr), el('div', { className: 'no' }, p.card)));
}
async function loadCard(id) {
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
if (!p.name) { $('cardbox').textContent = 'Could not load that person.'; return; }
curCard = p;
$('cardbox').replaceChildren(cardEl(p));
}
let curCard = null;
async function loadCards() {
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(($('pq').value || '').trim())); } catch (e) { r = {}; }
const list = (r.patrons || []).filter(p => p.card);
$('cardsheet').replaceChildren(...list.map(cardEl));
$('cardsnote').textContent = list.length ? list.length + ' card' + (list.length === 1 ? '' : 's') + (($('pq').value || '').trim() ? ' (matching your search)' : '') : 'Nobody to print yet.';
}
$('printcards').onclick = () => window.print();
// A picture of the QR (name + number under it) the person can keep on their phone and hold up to be scanned.
$('savecard').onclick = async () => {
if (!curCard) return;
const svgText = await (await fetch('api/qr?t=' + encodeURIComponent(curCard.card), { headers: { 'X-Quickadd': '1' } })).text();
const img = new Image(); await new Promise((ok, no) => { img.onload = ok; img.onerror = no; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); });
const c = document.createElement('canvas'); c.width = 640; c.height = 780; const g = c.getContext('2d');
g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = '#000'; g.textAlign = 'center';
g.font = 'bold 34px sans-serif'; g.fillText(libraryDisplay || 'Library card', 320, 60);
g.imageSmoothingEnabled = false; g.drawImage(img, 70, 90, 500, 500);
g.font = 'bold 40px sans-serif'; g.fillText(curCard.name, 320, 650); g.font = '34px monospace'; g.fillText(curCard.card, 320, 705);
const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = 'library-card-' + curCard.card + '.png'; a.click();
};
$('printcard').onclick = () => window.print();
async function loadMine() {
let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
$('mename').textContent = r.name; $('mecard').textContent = r.card;
const today = new Date(); today.setHours(12, 0, 0, 0);
const rows = r.loans.map(l => {
const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000);
const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)');
const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id });
if (x.status === 'renewed') loadMine(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed.'); } };
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('div', { className: 'small' }, l.author)),
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, when), ren);
});
$('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
}
$('mepwform').onsubmit = async ev => {
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
$('mepwresult').textContent = r.status === 'saved' ? 'Password changed. Next time your browser asks, use the new one.' : (r.message || 'Could not change it.');
if (r.status === 'saved') f.reset();
};
function route() {
const h = location.hash.replace(/^#/, '');
let m, view = 'library';
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
else if (h === 'mine' && isPatron) view = 'mine';
else if (viewer) view = 'library';
else if (h === 'add') view = 'add';
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
else if (h === 'people') view = 'people';
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
else if (h === 'cards') view = 'cards';
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards', 'mine']) $('view-' + v).hidden = v !== view;
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people', mine: 'mine' }[view];
for (const t of ['library', 'add', 'lend', 'people', 'mine']) $('tab-' + t).classList.toggle('on', t === tab);
$('tab-mine').hidden = !isPatron;
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
$('csvtools').hidden = viewer;
const names = { library: 'The library', book: 'The library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
document.title = names[view] + ' - Room for Books';
if (view === 'book') loadBook(m[1]);
else if (view === 'mine') loadMine();
else if (view === 'add') $('code').focus();
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
else if (view === 'people') { loadPeople($('pq').value.trim()); loadPwSettings(); }
else if (view === 'card') loadCard(m[1]);
else if (view === 'cards') loadCards();
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
}
window.addEventListener('hashchange', route);
let libraryDisplay = '';
(async () => {
const s = await api('/api/state');
viewer = s.role === 'viewer';
viewer = s.role !== 'admin'; isPatron = s.role === 'patron';
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 || '';
@@ -512,7 +1234,8 @@
}
if (!viewer) api('/api/settings').then(applySettings);
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
showView();
libraryDisplay = s.library_name || '';
route();
})();
})();
</script>
+12 -18
View File
@@ -30,15 +30,12 @@
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
# stays terminal-only for now.
#
# 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
# 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
# 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.
@@ -62,9 +59,9 @@ webui_is_active() {
webui_port() {
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8888"
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090"
else
echo "8888"
echo "8090"
fi
}
@@ -277,15 +274,14 @@ 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. 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 "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
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
local port
port=$(ask_integer "Port to listen on" "8888" 1024 65535)
port=$(ask_integer "Port to listen on" "8090" 1024 65535)
echo "Installing app files and npm dependencies..."
if ! webui_install_app_files; then
@@ -325,8 +321,6 @@ action_webui_reconfigure() {
webui_write_env_file "$port"
sudo systemctl restart kiosk-webui
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true
# The old port is no longer used - close it.
sudo ufw delete allow "${current_port}/tcp" 2>/dev/null || true
log_success "Web UI now listening on port ${port}"
}
-12
View File
@@ -121,18 +121,6 @@ action_upgrade() {
provision_configure_display
provision_configure_firewall
provision_configure_power_management
# The Web UI runs from its own copy (/opt/kiosk-webui), not from
# this checkout - refresh it too, or new settings never show up
# there. Port, service and sudo rules are left as they are.
if [[ -f "$SYSTEMD_DIR/kiosk-webui.service" ]]; then
log_info "Refreshing the Web UI..."
if webui_install_app_files; then
sudo systemctl restart kiosk-webui || log_warning "Web UI copied but did not restart - run: sudo systemctl restart kiosk-webui"
else
log_warning "Couldn't refresh the Web UI - the Web UI keeps its old version"
fi
fi
log_success "Setup refreshed"
echo
-2
View File
@@ -112,8 +112,6 @@ action_complete_uninstall() {
sudo rm -rf "$KIOSK_HOME"
echo "[6/12] Removing remaining systemd units..."
sudo systemctl disable --now kiosk-wifi-helper.socket 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.socket
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
sudo systemctl daemon-reload 2>/dev/null || true
-33
View File
@@ -20,7 +20,6 @@ display_status() {
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
echo "Back/home/fwd btns: $(onoff "$ENABLE_HISTORY_BUTTONS") (opacity ${HISTORY_BUTTONS_OPACITY}%)"
}
display_menu_builder() {
@@ -30,8 +29,6 @@ display_menu_builder() {
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_BUTTON"))"
"Toggle on-screen back/home/forward buttons (currently: $(onoff "$ENABLE_HISTORY_BUTTONS"))"
"Back/home/forward button opacity (currently: ${HISTORY_BUTTONS_OPACITY}%)"
)
MENU_HANDLERS=(
action_set_touch_mode
@@ -39,8 +36,6 @@ display_menu_builder() {
action_toggle_pause_button
action_toggle_keyboard_button
action_toggle_nav_button
action_toggle_history_buttons
action_set_history_opacity
)
}
@@ -136,31 +131,3 @@ action_toggle_nav_button() {
fi
save_config
}
# Page back/forward: Alt+Left / Alt+Right and the mouse's side buttons
# always work; this adds clickable buttons (top middle, half transparent,
# shown on mouse movement or a tap) for screens with no keyboard or mouse
# buttons. Off by default.
action_toggle_history_buttons() {
if [[ "$ENABLE_HISTORY_BUTTONS" == "true" ]]; then
ENABLE_HISTORY_BUTTONS="false"
log_warning "On-screen back/home/forward buttons disabled"
else
ENABLE_HISTORY_BUTTONS="true"
log_success "On-screen back/home/forward buttons enabled"
fi
save_config
}
# How see-through the back/forward buttons are: 100 = solid, 10 = faint.
# They get a little more solid while the mouse is over them.
action_set_history_opacity() {
echo
echo "How solid the on-screen back/home/forward buttons look (10 = faint, 100 = solid)."
HISTORY_BUTTONS_OPACITY=$(ask_integer "Opacity percent" "$HISTORY_BUTTONS_OPACITY" 10 100)
save_config
log_success "Back/home/forward button opacity: ${HISTORY_BUTTONS_OPACITY}%"
if [[ "$ENABLE_HISTORY_BUTTONS" != "true" ]]; then
log_info "The buttons are off - turn them on with the entry above."
fi
}
-2
View File
@@ -89,8 +89,6 @@ 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
@@ -2,10 +2,8 @@ Section "ServerFlags"
# Disable Ctrl+Alt+Backspace (X server kill)
Option "DontZap" "true"
# Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
# default, as the installer says. Advanced -> Virtual Consoles (or the
# install prompt) turns this to "true" to disable them.
Option "DontVTSwitch" "false"
# Disable VT switching (Ctrl+Alt+F1-F12)
Option "DontVTSwitch" "true"
# Don't allow clients to disconnect on exit
Option "AllowClosedownGrabs" "false"
@@ -1,14 +0,0 @@
[Unit]
Description=Kiosk WiFi helper socket (used by the kiosk app's WiFi screen)
[Socket]
ListenStream=/run/kiosk-wifi-helper.sock
SocketUser=root
# Replaced with the kiosk user's group when installed (lib/provision.sh).
SocketGroup=@KIOSK_GROUP@
SocketMode=0660
Accept=yes
MaxConnections=4
[Install]
WantedBy=sockets.target
@@ -1,10 +0,0 @@
[Unit]
Description=Kiosk WiFi helper (one request)
[Service]
ExecStart=/usr/local/bin/kiosk-wifi-helper-socket
StandardInput=socket
StandardOutput=socket
StandardError=journal
# A connect waits up to ~30s for an address, plus netplan; never run forever.
RuntimeMaxSec=180
+8 -104
View File
@@ -4,12 +4,10 @@
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
#
# The kiosk app reaches it through a root-owned socket
# (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
# or, as a fallback, passwordless sudo (/etc/sudoers.d/kiosk-wifi) - it does a
# The kiosk user may run this one script via passwordless sudo
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
# fixed set of things and nothing else:
#
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
@@ -19,10 +17,8 @@
# WiFi country (regulatory domain), system
# locale and keyboard layout; every field optional
#
# Every reply is one JSON line on stdout, exit 0 on success. status, scan
# and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
# to act on that adapter; without it the connected adapter is used (see
# pick_iface). connect writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
# Every reply is one JSON line on stdout, exit 0 on success. connect
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
# gets no address - a typo in the password doesn't lose a WiFi network
# that was working. The password comes in on stdin, never argv (argv is
@@ -172,76 +168,8 @@ default_country() {
echo "${c^^}"
}
# Every wireless interface, by what it is (see detect_iface).
list_wifi_ifaces() {
local d
for d in $SYS_CLASS/net/*; do
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
basename "$d"
fi
done
}
# The wireless adapter that is connected right now (associated and has an
# address), if any. With a built-in card plus a USB dongle, the connected
# one is often not the first in name order - status/scan/connect must act
# on the adapter that is actually carrying the connection.
connected_iface() {
local i
for i in $(list_wifi_ifaces); do
if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
echo "$i"
return 0
fi
done
return 1
}
# Which adapter to use: the connected one, else the one our netplan file
# configures, else the first found.
pick_iface() {
connected_iface || prefer_configured_iface || detect_iface
}
# The request body (JSON on stdin), read once in main. REQ_IFACE is the
# adapter the caller asked for, or empty.
REQ_INPUT=""
REQ_IFACE=""
# Only a real wireless interface name is accepted - never pass caller text
# to a command or a YAML file unchecked.
valid_iface() {
local i
[[ "$1" =~ ^[A-Za-z0-9._-]{1,15}$ ]] || return 1
for i in $(list_wifi_ifaces); do
[[ "$i" == "$1" ]] && return 0
done
return 1
}
# "usb" or "built-in", from where the device sits in sysfs.
iface_kind() {
if readlink -f "$SYS_CLASS/net/$1/device" 2>/dev/null | grep -q '/usb'; then
echo usb
else
echo built-in
fi
}
pick_requested_or_default_iface() {
if [[ -n "$REQ_IFACE" ]]; then
valid_iface "$REQ_IFACE" || return 1
echo "$REQ_IFACE"
else
pick_iface
fi
}
need_iface() {
if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
IFACE=$(pick_requested_or_default_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
}
@@ -288,7 +216,7 @@ set_country() {
do_region_set() {
local input country locale keyboard
input="$REQ_INPUT"
input=$(head -c 4096)
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -323,27 +251,10 @@ do_region_set() {
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
}
do_ifaces() {
local i rows="" sel
sel=$(pick_iface) || sel=""
for i in $(list_wifi_ifaces); do
local ssid ip
ssid=$(current_ssid "$i")
ip=$(iface_ip "$i")
rows+=$(jq -cn --arg n "$i" --arg k "$(iface_kind "$i")" --arg s "$ssid" --arg a "$ip" \
'{name:$n,kind:$k,connected:($s != "" and $a != ""),ssid:$s,ip:$a}')$'\n'
done
jq -cn --arg sel "$sel" --argjson a "$(printf '%s' "$rows" | jq -cs .)" \
'{ok:true,selected:$sel,adapters:$a}'
}
do_status() {
local iface ssid="" ip="" configured
configured=$(configured_ssid)
if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
reply_error "WiFi adapter $REQ_IFACE not found"
fi
if ! iface=$(pick_requested_or_default_iface); then
if ! iface=$(detect_iface); then
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
return 0
fi
@@ -440,7 +351,7 @@ PYEOF
do_connect() {
need_iface
local input ssid password
input="$REQ_INPUT"
input=$(head -c 4096)
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
@@ -543,14 +454,7 @@ trap 'rm -f "$TMP_SCAN"' EXIT
exec 9> "$LOCK_FILE"
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
# One JSON request body on stdin (not when run by hand from a terminal).
if [[ ! -t 0 ]]; then
REQ_INPUT=$(head -c 4096)
REQ_IFACE=$(jq -r '.iface // "" | strings' <<< "$REQ_INPUT" 2>/dev/null) || REQ_IFACE=""
fi
case "${1:-}" in
ifaces) do_ifaces ;;
status) do_status ;;
scan) do_scan ;;
connect) do_connect ;;
@@ -1,38 +0,0 @@
#!/bin/bash
################################################################################
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
#
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
# stdout. The kiosk app connects, sends
#
# <action>\n<JSON request body>
#
# and half-closes; the reply is the helper's one JSON line.
#
# Why a socket and not `sudo`: the kiosk app can end up running with the
# Linux "no new privileges" flag, and sudo then refuses to run as root
# ("The 'no new privileges' flag is set"). A root service reached through a
# socket isn't affected by that flag. Who may connect is decided by the
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
# run the helper through the old sudoers rule, no more.
#
# Only the fixed actions below are passed on; anything else is refused.
################################################################################
set -uo pipefail
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
# First line only; bash's read doesn't consume past the newline on a
# socket, so the JSON body stays on stdin for the helper.
IFS= read -r -t 10 -n 32 action || action=""
case "$action" in
ifaces|status|scan|connect|region-status|region-set)
exec "$HELPER" "$action"
;;
*)
echo '{"ok":false,"error":"unknown action"}'
exit 1
;;
esac
-141
View File
@@ -1,141 +0,0 @@
'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 };
+1 -16
View File
@@ -40,8 +40,6 @@ const SCALAR_DEFAULTS = {
enablePauseButton: true,
enableKeyboardButton: true,
enableNavButton: true,
enableHistoryButtons: false,
historyButtonsOpacity: 50,
enablePasswordProtection: false,
lockoutTimeout: 0,
lockoutAtTime: '',
@@ -179,17 +177,4 @@ function saveConfig(patch) {
return loadConfig();
}
// 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 };
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS };
-30
View File
@@ -1,12 +1,3 @@
// 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
@@ -41,23 +32,6 @@ 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) */
/* ---------------------------------------------------------------------- */
@@ -199,8 +173,6 @@ displayForm.addEventListener('submit', async (e) => {
enablePauseButton: fd.get('enablePauseButton') === 'on',
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
enableNavButton: fd.get('enableNavButton') === 'on',
enableHistoryButtons: fd.get('enableHistoryButtons') === 'on',
historyButtonsOpacity: parseInt(fd.get('historyButtonsOpacity'), 10),
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
};
@@ -310,8 +282,6 @@ function populateAll(config) {
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
displayForm.elements.enableHistoryButtons.checked = !!config.enableHistoryButtons;
displayForm.elements.historyButtonsOpacity.value = config.historyButtonsOpacity;
updateHomeTabOptions(config.tabs);
homeTabSelect.value = String(config.homeTabIndex);
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
+1 -18
View File
@@ -46,7 +46,7 @@
</button>
</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>
<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>
</nav>
<main class="main">
@@ -65,18 +65,6 @@
<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 -->
@@ -107,7 +95,6 @@
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
<label class="checkbox"><input type="checkbox" name="enableHistoryButtons"> On-screen back / home / forward buttons (top middle)</label>
<fieldset>
<legend>Home page</legend>
@@ -117,10 +104,6 @@
<option value="-1">Disabled</option>
</select>
</label>
<label>
Back/home/forward button opacity (10 = faint, 100 = solid)
<input type="number" name="historyButtonsOpacity" min="10" max="100" step="5">
</label>
<label>
Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
-60
View File
@@ -1,60 +0,0 @@
<!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,14 +393,3 @@ 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));
}
+7 -27
View File
@@ -6,12 +6,11 @@
// no privileged system mutation involved (see menus/addon_webui.sh's
// header for why the rest of Core Settings/Addons/Advanced aren't here).
//
// 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).
// 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.
//
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
// installs) - the same user Electron runs as, and the owner of
@@ -23,7 +22,6 @@ 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';
@@ -31,14 +29,6 @@ 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'];
@@ -80,12 +70,6 @@ app.put('/api/config', (req, res) => {
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton;
if (body.enableHistoryButtons !== undefined) patch.enableHistoryButtons = !!body.enableHistoryButtons;
if (body.historyButtonsOpacity !== undefined) {
const pct = Number(body.historyButtonsOpacity);
if (!Number.isInteger(pct) || pct < 10 || pct > 100) return badRequest(res, 'Button opacity must be 10-100 percent');
patch.historyButtonsOpacity = pct;
}
let tabCount;
if (body.tabs !== undefined) {
@@ -189,11 +173,7 @@ app.put('/api/config', (req, res) => {
}
try {
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);
res.json(saveConfig(patch));
} catch (e) {
console.error('saveConfig failed:', e);
res.status(500).json({ error: 'Failed to save configuration' });
@@ -308,7 +288,7 @@ app.put('/api/region', (req, res) => {
runRegionHelper('region-set', input, res);
});
const PORT = process.env.PORT || 8888;
const PORT = process.env.PORT || 8090;
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
if (require.main === module) {
-14
View File
@@ -18,20 +18,6 @@ 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
@@ -1,153 +0,0 @@
'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();
-11
View File
@@ -75,17 +75,6 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
assert.strictEqual(onDisk.enableNavButton, false);
});
check('enableHistoryButtons defaults off and round-trips', () => {
fs.rmSync(process.env.CONFIG_PATH, { force: true });
assert.strictEqual(loadConfig().enableHistoryButtons, false);
saveConfig({ enableHistoryButtons: true });
assert.strictEqual(JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')).enableHistoryButtons, true);
assert.strictEqual(loadConfig().enableHistoryButtons, true);
assert.strictEqual(loadConfig().historyButtonsOpacity, 50);
saveConfig({ historyButtonsOpacity: 80 });
assert.strictEqual(loadConfig().historyButtonsOpacity, 80);
});
check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
const result = saveConfig({
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],