24 Commits
Author SHA1 Message Date
Outis c6532c1367 Merge pull request #137 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 22:20:00 -05:00
Claude 6fdc5f4dd1 library: simple home page (look up, check out, return, add, add person, recently added), hamburger menu, footer; add books by title with matches to choose
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-07 03:15:01 +00:00
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
Outis 9eadf266f9 Merge pull request #136 from outis1one/claude/pensive-wright-ko0ipl
Claude/pensive wright ko0ipl
2026-11-06 17:15:49 -05:00
Claude 770a6a1dfb library: login for everything - public catalogue off, read-only guest for Find a book
- the public catalogue is bound to the machine itself by default (OPAC_BIND) and,
  if ever exposed, requires a library-card login (OpacPublic=0)
- a read-only guest login (VIEWER_USER/VIEWER_PASSWORD) in Quick Add: it may load the
  page, read state and search; every other endpoint is admin-only (403); the page
  shows only Find a book
- the guest deliberately has NO Koha account: Koha's 'catalogue' permission, the
  only one that allows searching, also allows creating/editing label layouts and
  templates (tested: a guest created a layout), so it cannot be read-only
- koha/ensure.pl (every start): staff-home 'Library tools' block, and removal of a
  guest account an earlier version created
- kiosk add-on shows Find a book signed in as the guest; the admin login never
  goes into the kiosk config (tested, incl. a mutation check); old public-catalogue
  tabs are cleaned up
- existing installs keep their current public-catalogue behaviour on update

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 22:09:06 +00:00
Claude 522a33b127 library: one address - Add books inside the staff site, plus Find a book
- Apache in the Koha container serves Quick Add at /add/ on the staff site
  (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves
- Quick Add: relative API paths so it works at / and /add/; Add books / Find a
  book tabs; search by title, author or ISBN showing each copy's shelf and status
  (/api/search); Manage and Public catalogue links
- staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05
  stores it) linking to Add books
- installer, kiosk add-on and READMEs advertise one main address; the public
  catalogue stays on its own port (Koha gives public and staff sites the same URL
  shapes, so they cannot share one address)

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:47:00 +00:00
Outis 84e8f074e8 Merge pull request #134 from outis1one/claude/optimistic-clarke-27gpk1
Web UI default port 8888; Lock now in the power menu
2026-11-06 13:37:28 -05:00
Claude 551df48687 Web UI default port 8888; Lock now in the power menu
New installs put the Web UI on port 8888 instead of 8090 (existing installs
keep their port; change it under Addons -> Web UI, which now also closes the
old port in the firewall). The power menu gets a Lock now button, shown only
when password protection is on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:37:13 +00:00
Outis f09bd6bb95 Merge pull request #133 from outis1one/claude/optimistic-clarke-27gpk1
Home button between back and forward; Alt+Home
2026-11-06 13:31:51 -05:00
Claude 9c1f22382e Home button between back and forward; Alt+Home
The on-screen buttons are now back / home / forward. Home (and Alt+Home)
loads the site on screen from the address it was set up with under Sites &
Page Timing, hidden sites included. A shared buildTabUrl() builds that
address for both the first load and the home button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:31:38 +00:00
Outis b836fd6771 Merge pull request #132 from outis1one/claude/optimistic-clarke-27gpk1
Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
2026-11-06 13:23:30 -05:00
Claude fa0d602505 Back/forward buttons: adjustable opacity; Upgrade refreshes the Web UI copy
New historyButtonsOpacity setting (10-100 percent, default 50) in the
terminal menu (Display & Interaction) and the Web UI. Upgrade now also
copies the Web UI app to /opt/kiosk-webui and restarts it, so new Web UI
settings appear after an upgrade (it previously kept its old copy).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 18:21:50 +00:00
Outis e7cf15a802 Merge pull request #131 from outis1one/claude/optimistic-clarke-27gpk1
Page back/forward (Alt+Left/Right, mouse side buttons, optional on-screen buttons) and CLAUDE.md
2026-11-06 13:16:16 -05:00
Claude 441615da7a Add CLAUDE.md: no user data in the repo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 17:03:16 +00:00
Claude 1775220312 Page back/forward: Alt+Left/Right, mouse side buttons, optional on-screen buttons
Alt+Left/Right now go back/forward within the page (the standard browser
keys) instead of switching sites; sites switch with Ctrl+Tab, Ctrl+[ ] and
the standard Ctrl+PageUp/PageDown. New enableHistoryButtons option (off by
default) shows half-transparent back/forward buttons at the top middle.
Settable from the terminal menu and the Web UI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
2026-11-06 16:47:53 +00:00
37 changed files with 3286 additions and 142 deletions
+22
View File
@@ -0,0 +1,22 @@
# 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.
+9 -4
View File
@@ -91,7 +91,7 @@ Useful options (`iso/build-iso.sh --help` for all):
| Option | Effect | | 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>:8090`) 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>:8888`) 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`. | | `--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. | | `--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. | | `--auto-network` / `--auto-storage` | Skip the network screen (DHCP on ethernet) / wipe the largest disk without asking. |
@@ -249,6 +249,7 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys) - **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
- **On-screen keyboard** - Auto-shows on text fields or click keyboard icon - **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 - **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 ### On-Screen Keyboard
- **HTML-based keyboard** with full QWERTY layout - **HTML-based keyboard** with full QWERTY layout
@@ -506,6 +507,7 @@ Host myserver
- **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install - **Virtual consoles** (Ctrl+Alt+F1-F8) - Optional, configurable during install
- **X server key combinations disabled** (Ctrl+Alt+Backspace) - **X server key combinations disabled** (Ctrl+Alt+Backspace)
- **Right-click disabled** in kiosk app - **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 - **Screen blanking disabled** with schedule awareness
- **DPMS management** - Aggressive keep-alive with schedule respect - **DPMS management** - Aggressive keep-alive with schedule respect
@@ -775,7 +777,10 @@ smb://WORKGROUP/COMPUTER/PrinterName
**Keyboard Shortcuts:** **Keyboard Shortcuts:**
- `Ctrl+Tab` or `Ctrl+]` - Next tab - `Ctrl+Tab` or `Ctrl+]` - Next tab
- `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab - `Ctrl+Shift+Tab` or `Ctrl+[` - Previous tab
- `Alt+Right/Left` - Next/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
- `F10` or `Ctrl+H` - Toggle hidden tabs - `F10` or `Ctrl+H` - Toggle hidden tabs
- `Escape` - Return to normal tabs (from hidden) - `Escape` - Return to normal tabs (from hidden)
- `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu - `Ctrl+Alt+Delete` or `Ctrl+Alt+P` - Power menu
@@ -1581,7 +1586,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`). - **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):** **Previous (v2.17.0):**
- **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. - **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.
- **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. - **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. - **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. - 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.
@@ -1592,7 +1597,7 @@ full migration pass.
**Previous (v2.16.0):** **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. - **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. - `webui/lib/config.js` re-implements `lib/config.sh`'s exact field list, defaults, and merge-on-save contract in JS — `kiosk-app/main.js` already reads the same `config.json` directly in JS, so this isn't a new pattern — meaning it can never silently clobber fields it doesn't track (Authelia's credentials, the unused quiet-hours fields, etc), the same failure mode previously fixed in `lib/config.sh`'s own history.
- **No login of its own, by design.** Authelia runs elsewhere; the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected — Authelia integration is explicitly out of scope for this repo. Direct LAN access with no proxy in front has no authentication at all — treat it like SSH access to the kiosk. - **Login follows the kiosk's password protection.** With password protection on (Core Settings → Password Protection & Lockout), the Web UI asks for the same PIN/password the lock screen uses (a session lasts 12 hours; 5 wrong tries in a row slow it down like the lock screen does; changing the code signs every browser out). With protection off there is no login, so beyond a trusted LAN turn protection on or put it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth — Authelia integration is out of scope for this repo. Direct LAN access with protection off has no authentication at all — treat it like SSH access to the kiosk.
- Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only — a network-facing process shouldn't be handed `sudo`-level system mutation (netplan, `timedatectl`, `apt`, systemd timers) without a lot more thought than this pass gives it. A "restart kiosk display" action was left out for the same reason — would need a narrow polkit grant, a follow-up. - 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. - 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. - 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 # ~/ubuntu-based-kiosk - interactively by default (same prompts as a
# manual install), or with --unattended taking every default and # manual install), or with --unattended taking every default and
# rebooting straight into the kiosk. Configure sites afterwards via # rebooting straight into the kiosk. Configure sites afterwards via
# the Web UI (http://<ip>:8090) or ./install.sh. # the Web UI (http://<ip>:8888) or ./install.sh.
# #
# Nothing in the original ISO is changed except /boot/grub/grub.cfg # Nothing in the original ISO is changed except /boot/grub/grub.cfg
# (menu title, plus the `autoinstall` kernel argument with --no-confirm); # (menu title, plus the `autoinstall` kernel argument with --no-confirm);
+116 -13
View File
@@ -60,6 +60,8 @@ let allowNavigation='same-origin';
let enablePauseButton=true; let enablePauseButton=true;
let enableKeyboardButton=true; let enableKeyboardButton=true;
let enableNavButton=true; let enableNavButton=true;
let enableHistoryButtons=false;
let historyButtonsOpacity=50;
let enablePasswordProtection=false; let enablePasswordProtection=false;
let lockoutPassword=""; let lockoutPassword="";
let lockoutTimeout=0; let lockoutTimeout=0;
@@ -102,6 +104,11 @@ function loadConfig(){
enablePauseButton=(config.enablePauseButton!==false); enablePauseButton=(config.enablePauseButton!==false);
enableKeyboardButton=(config.enableKeyboardButton!==false); enableKeyboardButton=(config.enableKeyboardButton!==false);
enableNavButton=(config.enableNavButton!==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); enablePasswordProtection=(config.enablePasswordProtection===true);
lockoutPassword=config.lockoutPassword||""; lockoutPassword=config.lockoutPassword||"";
lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms lockoutTimeout=(config.lockoutTimeout||0)*60000; // Convert minutes to ms
@@ -122,6 +129,7 @@ function loadConfig(){
console.log('[CONFIG] Navigation:',allowNavigation); console.log('[CONFIG] Navigation:',allowNavigation);
console.log('[CONFIG] Pause button:',enablePauseButton); console.log('[CONFIG] Pause button:',enablePauseButton);
console.log('[CONFIG] Keyboard button:',enableKeyboardButton); console.log('[CONFIG] Keyboard button:',enableKeyboardButton);
console.log('[CONFIG] Back/forward buttons:',enableHistoryButtons,'opacity',historyButtonsOpacity+'%');
console.log('[CONFIG] Password protection:',enablePasswordProtection); console.log('[CONFIG] Password protection:',enablePasswordProtection);
console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes'); console.log('[CONFIG] Lockout timeout:',lockoutTimeout/60000,'minutes');
if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime);
@@ -764,6 +772,23 @@ 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){ function attachView(i){
closeHTMLKeyboard(); closeHTMLKeyboard();
@@ -1145,6 +1170,75 @@ function returnToTabs(){
markActivity(); 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(){ function showPinEntry(){
if(pinWindow&&!pinWindow.isDestroyed()){ if(pinWindow&&!pinWindow.isDestroyed()){
pinWindow.focus(); pinWindow.focus();
@@ -1464,7 +1558,7 @@ function showPowerMenu(){
// For normal mode, use custom overlay with 30-second timeout // For normal mode, use custom overlay with 30-second timeout
console.log('[POWER] Showing custom power menu overlay'); console.log('[POWER] Showing custom power menu overlay');
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()}; const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim(),canLock:!!(enablePasswordProtection&&lockoutPassword)};
// Send to all views (preload.js runs in views, not mainWindow) // Send to all views (preload.js runs in views, not mainWindow)
for(const view of views){ for(const view of views){
if(view&&view.webContents&&!view.webContents.isDestroyed()){ if(view&&view.webContents&&!view.webContents.isDestroyed()){
@@ -1526,6 +1620,11 @@ async function createWindow(){
mainWindow.on('focus',()=>markActivity()); mainWindow.on('focus',()=>markActivity());
mainWindow.webContents.on('before-input-event',()=>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){ if(!tabs.length){
mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>'); mainWindow.loadURL('data:text/html,<body>No Sites Configured</body>');
@@ -1545,16 +1644,7 @@ async function createWindow(){
mainWindow.addBrowserView(view); mainWindow.addBrowserView(view);
let url=t.url; const url=buildTabUrl(t);
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; const initialOrigin=new URL(t.url).origin;
@@ -1611,6 +1701,7 @@ async function createWindow(){
console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton); console.log('[MAIN] Page loaded - sending keyboard-button-enabled: '+enableKeyboardButton);
view.webContents.send('nav-button-enabled',enableNavButton); 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 - sending nav-button-enabled: '+enableNavButton);
console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url); console.log('[MAIN] Page loaded - resending pause-button-visibility: '+shouldShow+' for '+t.url);
@@ -1652,8 +1743,13 @@ async function createWindow(){
if(action==='shutdown')exec('systemctl poweroff'); if(action==='shutdown')exec('systemctl poweroff');
else if(action==='restart')exec('systemctl reboot'); else if(action==='restart')exec('systemctl reboot');
else if(action==='reload'){app.relaunch();app.quit();} else if(action==='reload'){app.relaunch();app.quit();}
else if(action==='lock')lockNow();
}); });
ipcMain.on('toggle-hidden',toggleHidden); 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('return-to-tabs',forceReturnToTabs);
ipcMain.on('user-activity',markActivity); ipcMain.on('user-activity',markActivity);
ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();}); ipcMain.on('show-keyboard',()=>{showHTMLKeyboard();});
@@ -1843,10 +1939,17 @@ async function createWindow(){
globalShortcut.register('Control+Shift+Tab',()=>{prevTab();}); globalShortcut.register('Control+Shift+Tab',()=>{prevTab();});
globalShortcut.register('Control+]',()=>{nextTab();}); globalShortcut.register('Control+]',()=>{nextTab();});
globalShortcut.register('Control+[',()=>{prevTab();}); globalShortcut.register('Control+[',()=>{prevTab();});
globalShortcut.register('Alt+Right',()=>{nextTab();}); // The standard browser keys for switching tabs. (Alt+Left/Right used to
globalShortcut.register('Alt+Left',()=>{prevTab();}); // switch sites; they are now page back/forward, below.)
globalShortcut.register('Control+PageDown',()=>{nextTab();});
globalShortcut.register('Control+PageUp',()=>{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('F10',toggleHidden);
globalShortcut.register('Control+H',toggleHidden); globalShortcut.register('Control+H',toggleHidden);
globalShortcut.register('Escape',forceReturnToTabs); globalShortcut.register('Escape',forceReturnToTabs);
+94
View File
@@ -115,6 +115,24 @@ 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)=>{ ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
navButtonEnabled=enabled; navButtonEnabled=enabled;
console.log('[NAV-BTN] Navigation button enabled: '+enabled); console.log('[NAV-BTN] Navigation button enabled: '+enabled);
@@ -253,6 +271,57 @@ window.addEventListener('DOMContentLoaded',()=>{
document.body.appendChild(navButton); 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) // Power button in top-right corner (follows same show/hide logic as nav button)
let powerButton=null; let powerButton=null;
let powerButtonHideTimer=null; let powerButtonHideTimer=null;
@@ -597,6 +666,16 @@ window.addEventListener('DOMContentLoaded',()=>{
ipcRenderer.send('power-action','reload'); 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'); const cancelBtn=document.createElement('button');
cancelBtn.textContent='Cancel'; cancelBtn.textContent='Cancel';
cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;'; cancelBtn.style.cssText=btnStyle+'background:#7f8c8d;color:white;';
@@ -604,6 +683,7 @@ window.addEventListener('DOMContentLoaded',()=>{
hidePowerMenu(); hidePowerMenu();
}); });
buttonsDiv.appendChild(lockBtn);
buttonsDiv.appendChild(shutdownBtn); buttonsDiv.appendChild(shutdownBtn);
buttonsDiv.appendChild(restartBtn); buttonsDiv.appendChild(restartBtn);
buttonsDiv.appendChild(reloadBtn); buttonsDiv.appendChild(reloadBtn);
@@ -639,6 +719,8 @@ window.addEventListener('DOMContentLoaded',()=>{
} }
infoDiv.innerHTML=infoText; infoDiv.innerHTML=infoText;
} }
const lockBtn=document.getElementById('power-menu-lock');
if(lockBtn)lockBtn.style.display=(info&&info.canLock)?'':'none';
powerMenu.style.display='flex'; powerMenu.style.display='flex';
powerMenuVisible=true; powerMenuVisible=true;
@@ -918,11 +1000,23 @@ window.addEventListener('DOMContentLoaded',()=>{
if(navButtonEnabled){ if(navButtonEnabled){
showNavButton(); showNavButton();
} }
showHistoryBar();
// Always show power button on user interaction // Always show power button on user interaction
showPowerButton(); 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 // Show pause button on any user interaction
const pauseButtonTriggers=['mousedown','touchstart','keydown']; const pauseButtonTriggers=['mousedown','touchstart','keydown'];
pauseButtonTriggers.forEach(eventType=>{ pauseButtonTriggers.forEach(eventType=>{
+15 -2
View File
@@ -79,6 +79,8 @@ INACTIVITY_TIMEOUT=120
ENABLE_PAUSE_BUTTON="true" ENABLE_PAUSE_BUTTON="true"
ENABLE_KEYBOARD_BUTTON="true" ENABLE_KEYBOARD_BUTTON="true"
ENABLE_NAV_BUTTON="true" ENABLE_NAV_BUTTON="true"
ENABLE_HISTORY_BUTTONS="false"
HISTORY_BUTTONS_OPACITY=50
ENABLE_PASSWORD_PROTECTION="false" ENABLE_PASSWORD_PROTECTION="false"
LOCKOUT_PASSWORD="" LOCKOUT_PASSWORD=""
LOCKOUT_TIMEOUT=0 LOCKOUT_TIMEOUT=0
@@ -203,7 +205,7 @@ load_existing_config() {
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin") 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") SWIPE_MODE=$(sudo -u "$KIOSK_USER" jq -r '.swipeMode // "dual"' "$CONFIG_PATH" 2>/dev/null || echo "dual")
local pause_btn keyboard_btn nav_btn password_enabled boot_password local pause_btn keyboard_btn nav_btn history_btn password_enabled boot_password
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null) 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" [[ "$pause_btn" == "true" ]] && ENABLE_PAUSE_BUTTON="true" || ENABLE_PAUSE_BUTTON="false"
@@ -213,6 +215,12 @@ load_existing_config() {
nav_btn=$(sudo -u "$KIOSK_USER" jq -r '.enableNavButton // true' "$CONFIG_PATH" 2>/dev/null) 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" [[ "$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=$(sudo -u "$KIOSK_USER" jq -r '.enablePasswordProtection // false' "$CONFIG_PATH" 2>/dev/null)
[[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false" [[ "$password_enabled" == "true" ]] && ENABLE_PASSWORD_PROTECTION="true" || ENABLE_PASSWORD_PROTECTION="false"
@@ -263,6 +271,9 @@ save_config() {
local nav_btn_json="true" local nav_btn_json="true"
[[ "$ENABLE_NAV_BUTTON" == "false" ]] && nav_btn_json="false" [[ "$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" local password_json="false"
[[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true" [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]] && password_json="true"
@@ -301,6 +312,8 @@ save_config() {
--argjson enablePauseButton "$pause_btn_json" \ --argjson enablePauseButton "$pause_btn_json" \
--argjson enableKeyboardButton "$keyboard_btn_json" \ --argjson enableKeyboardButton "$keyboard_btn_json" \
--argjson enableNavButton "$nav_btn_json" \ --argjson enableNavButton "$nav_btn_json" \
--argjson enableHistoryButtons "$history_btn_json" \
--argjson historyButtonsOpacity "${HISTORY_BUTTONS_OPACITY:-50}" \
--argjson enablePasswordProtection "$password_json" \ --argjson enablePasswordProtection "$password_json" \
--arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \ --arg lockoutPassword "${LOCKOUT_PASSWORD:-}" \
--argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \ --argjson lockoutTimeout "${LOCKOUT_TIMEOUT:-0}" \
@@ -314,7 +327,7 @@ save_config() {
--arg autheliaURL "${AUTHELIA_URL:-}" \ --arg autheliaURL "${AUTHELIA_URL:-}" \
--arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \
--arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ --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,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,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"
if [[ ${#URLS[@]} -gt 0 ]]; then if [[ ${#URLS[@]} -gt 0 ]]; then
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
+3 -3
View File
@@ -328,7 +328,7 @@ provision_configure_webui() {
return 0 return 0
fi fi
local port="8090" local port="8888"
webui_write_env_file "$port" webui_write_env_file "$port"
webui_write_unit_file webui_write_unit_file
webui_write_helper_script webui_write_helper_script
@@ -424,8 +424,8 @@ run_first_time_install() {
echo " - Audio support (PipeWire)" echo " - Audio support (PipeWire)"
echo " - Time synchronization (NTP)" echo " - Time synchronization (NTP)"
echo " - Web UI (browser-based Sites/Display/Lockout editor, plus" echo " - Web UI (browser-based Sites/Display/Lockout editor, plus"
echo " addon install/reconfigure and Update - no login of its own," echo " addon install/reconfigure and Update - asks for the kiosk's"
echo " see Addons -> Web UI)" echo " PIN/password when password protection is on, see Addons -> Web UI)"
echo echo
echo "OPTIONAL (configure after install, via Addons):" echo "OPTIONAL (configure after install, via Addons):"
echo " - Lyrion Music Server (LMS) / Squeezelite" echo " - Lyrion Music Server (LMS) / Squeezelite"
+94 -5
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: A self-hosted library catalogue you can set up in one command:
@@ -12,12 +12,19 @@ it never overwrites your `.env` (passwords) or your data.
| What | Address | Login | | What | Address | Login |
|---|---|---| |---|---|---|
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none | | **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password | | **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login | | **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
Ports move up to the next free number if one is taken; the final addresses are printed Ports move up to the next free number if one is taken; the final addresses are printed
at the end and by `sudo ./install.sh --status`. at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
(`8082`), which is handy behind a proxy but not needed otherwise.
## What it is made of ## What it is made of
@@ -196,3 +203,85 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
servers, label templates/layouts) servers, label templates/layouts)
quickadd/ the scan-to-add service (Python standard library + yaz) 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.
## The home page (the Room for Books site at `/add/`)
One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books.
**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list
of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have
are marked, with an *Add another copy* button.
## Everyday use (details)
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.
+9 -4
View File
@@ -30,8 +30,12 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
quickadd:
condition: service_started
environment: environment:
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
KOHA_QUICKADD_URL: http://quickadd:8082
KOHA_SSO_SECRET: ${SSO_SECRET:-}
KOHA_DB_HOST: db KOHA_DB_HOST: db
KOHA_DB_PASS: ${DB_PASSWORD} KOHA_DB_PASS: ${DB_PASSWORD}
KOHA_ADMIN_USER: ${ADMIN_USER:-admin} KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
@@ -41,7 +45,8 @@ services:
KOHA_SHELVES: ${SHELVES:-} KOHA_SHELVES: ${SHELVES:-}
KOHA_ITEM_TYPES: ${ITEM_TYPES:-} KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
ports: ports:
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80" # The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081" - "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
volumes: volumes:
- koha-data:/var/lib/koha - koha-data:/var/lib/koha
@@ -53,14 +58,14 @@ services:
image: library-quickadd:local image: library-quickadd:local
container_name: library-quickadd container_name: library-quickadd
restart: unless-stopped restart: unless-stopped
depends_on:
koha:
condition: service_started
environment: environment:
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
KOHA_API: http://koha:8081/api/v1 KOHA_API: http://koha:8081/api/v1
SSO_SECRET: ${SSO_SECRET:-}
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VIEWER_USER: ${VIEWER_USER:-guest}
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME} KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
OPAC_PORT: ${OPAC_PORT:-8080} OPAC_PORT: ${OPAC_PORT:-8080}
STAFF_PORT: ${STAFF_PORT:-8081} STAFF_PORT: ${STAFF_PORT:-8081}
+20 -4
View File
@@ -79,10 +79,14 @@ show_status() {
fi fi
dc ps dc ps
echo echo
echo " Catalogue (public): http://$ip:$OPAC_PORT" echo " Your library: http://$ip:$STAFF_PORT"
echo " Staff interface: http://$ip:$STAFF_PORT" echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)" echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)" echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
echo " It opens the Find a book page: search only, it cannot add or change anything."
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
fi
echo echo
} }
@@ -117,8 +121,14 @@ OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}") STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}") QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
BIND_ADDR=${BIND_ADDR:-0.0.0.0} BIND_ADDR=${BIND_ADDR:-0.0.0.0}
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=$(rand 16) 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_ROOT_PASSWORD=$(rand 32)
DB_PASSWORD=$(rand 32) DB_PASSWORD=$(rand 32)
KOHA_SUITE=stable KOHA_SUITE=stable
@@ -131,6 +141,12 @@ else
if ! grep -q '^QUICKADD_PORT=' .env; then if ! grep -q '^QUICKADD_PORT=' .env; then
echo "QUICKADD_PORT=$(free_port 8082)" >> .env echo "QUICKADD_PORT=$(free_port 8082)" >> .env
fi fi
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 if ! grep -q '^BIND_ADDR=' .env; then
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
fi fi
+7 -1
View File
@@ -39,7 +39,13 @@ 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; }; \ command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
done done
COPY bootstrap.pl entrypoint.sh /opt/koha-init/ # 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/ COPY seed/ /opt/koha-init/seed/
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
+2 -2
View File
@@ -160,11 +160,11 @@ my %prefs = (
finesMode => 'off', # a home library does not charge overdue fines finesMode => 'off', # a home library does not charge overdue fines
itemcallnumber => '082ab', # Dewey call number copied from the MARC record itemcallnumber => '082ab', # Dewey call number copied from the MARC record
RESTBasicAuth => 1, # lets the quick-add page use the REST API RESTBasicAuth => 1, # lets the quick-add page use the REST API
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
OpenLibraryCovers => 1, # cover images in the catalogue OpenLibraryCovers => 1, # cover images in the catalogue
OPACSearchForTitleIn => '', OPACSearchForTitleIn => '',
BiblioDefaultView => 'normal', BiblioDefaultView => 'normal',
OpacPublic => 1, opacuserlogin => 1,
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
OpacMaintenance => 0, OpacMaintenance => 0,
ExpireReservesMaxPickUpDelay => 0, ExpireReservesMaxPickUpDelay => 0,
AllowOfflineCirculation => 0, AllowOfflineCirculation => 0,
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/perl
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
# the schema upgrade), so existing installs pick these up too:
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
# * the "Library tools" block on the staff home page
use Modern::Perl;
use C4::Context;
use Koha::Patrons;
use Koha::Patron::Categories;
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:
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
$p->delete;
}
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
}
# 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)});
$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 (?, '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 );
}
+37 -4
View File
@@ -56,12 +56,40 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE" -e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
# Run Apache itself as the instance user instead of mpm_itk's per-vhost # One address: serve the Add books page (Quick Add) inside the staff site at /add/.
# uid switching (which needs extra capabilities and is pointless here: # (The staff and public catalogue sites cannot share one address: Koha gives both the
# Plack does the real work as that user already). # 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
# koha-create insists on mpm_itk being enabled, but inside a container it can # 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 # 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.* rm -f /etc/apache2/mods-enabled/mpm_itk.*
a2enmod mpm_prefork >/dev/null 2>&1 || true a2enmod mpm_prefork >/dev/null 2>&1 || true
sed -i '/AssignUserID/d' "$SITE" sed -i '/AssignUserID/d' "$SITE"
@@ -94,6 +122,11 @@ else
FIRST_RUN=0 FIRST_RUN=0
fi fi
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
perl /opt/koha-init/ensure.pl \
|| log "WARNING: ensure.pl reported a problem (see above)"
# --- 5. background services (their `daemon` processes die with the container, # --- 5. background services (their `daemon` processes die with the container,
# so they are started explicitly on every start) ------------------------ # so they are started explicitly on every start) ------------------------
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
+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 ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \ 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/* && rm -rf /var/lib/apt/lists/*
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if # 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/ COPY certs/ /usr/local/share/ca-certificates/extra/
RUN update-ca-certificates 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 \ RUN useradd --system --uid 10001 --home-dir /app quickadd \
&& mkdir -p /app /data && chown quickadd /data && mkdir -p /app /data && chown quickadd /data
Binary file not shown.
+1112 -17
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37 -27
View File
@@ -1,29 +1,30 @@
#!/bin/bash #!/bin/bash
################################################################################ ################################################################################
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library # menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
# catalogue (Koha) running in Docker, shown on the kiosk as a public # catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
# search terminal.
# #
# What gets installed (all from this checkout's library/ directory, deployed to # What gets installed (all from this checkout's library/ directory, deployed to
# $LIBRARY_DIR - see library/README.md for the stack itself): # $LIBRARY_DIR - see library/README.md for the stack itself):
# - Koha: the catalogue ("OPAC", public search, no login) and the staff # - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
# interface (cataloguing, Z39.50 lookup, label printing). # - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
# - MariaDB. # - MariaDB.
# Docker itself is installed on demand (lib/docker.sh). # Docker itself is installed on demand (lib/docker.sh).
# #
# On the kiosk the catalogue is added as one of the Sites (tabs) with # Everything needs a login. The kiosk shows the "Find a book" page signed in as a
# duration 0 (stay put - it's a search terminal, not a rotating display). # READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
# Staff and Quick Add are meant for the librarian's own device; whether they # all, because Koha's own search permission also allows editing label layouts); the page
# are reachable from the LAN or only from this kiosk is the "Network access" # list stores that guest login like any other Basic Auth site. The admin login never
# choice below. NOTE: Docker publishes ports straight through iptables and # goes into the kiosk's config. The public catalogue (OPAC) is off by default.
# bypasses ufw, so "this kiosk only" is enforced by binding the published #
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules. # Whether other devices on the LAN can reach the library site is the "Network access"
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
# (BIND_ADDR in library/.env), not by firewall rules.
# #
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first. # Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
################################################################################ ################################################################################
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue" LIBRARY_CATALOGUE_TAB_NAME="Find a book"
################################################################################ ################################################################################
# Detection / state # Detection / state
@@ -42,7 +43,13 @@ library_is_running() {
[[ "$(docker_container_state library-koha)" == "running" ]] [[ "$(docker_container_state library-koha)" == "running" ]]
} }
# The page the kiosk shows: Quick Add's Find a book view on the library site.
library_catalogue_url() { library_catalogue_url() {
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
}
# Pages from earlier versions of this add-on pointed at the public catalogue.
library_old_catalogue_url() {
echo "http://localhost:$(library_env_get OPAC_PORT)/" echo "http://localhost:$(library_env_get OPAC_PORT)/"
} }
@@ -66,19 +73,19 @@ addon_library_status() {
health="$(docker_container_health library-koha)" health="$(docker_container_health library-koha)"
ip="$(get_ip_address)" ip="$(get_ip_address)"
echo "Library Catalog: ${state}${health:+ ($health)}" echo "Library Catalog: ${state}${health:+ ($health)}"
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)" echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
echo " Network: this kiosk only" echo " Network: this kiosk only"
else else
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/" echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
fi fi
} }
addon_library_menu_builder() { addon_library_menu_builder() {
if library_is_installed; then if library_is_installed; then
load_existing_config load_existing_config
local tab_label="Show the catalogue on the kiosk" local tab_label="Show Find a book on the kiosk"
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk" library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall") MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall) MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
else else
@@ -111,13 +118,13 @@ library_sync_files() {
library_add_tab() { library_add_tab() {
load_existing_config load_existing_config
if library_tab_exists; then if library_tab_exists; then
log_info "The catalogue is already one of the kiosk's pages." log_info "Find a book is already one of the kiosk's pages."
return 0 return 0
fi fi
URLS+=("$(library_catalogue_url)") URLS+=("$(library_catalogue_url)")
DURS+=("0") DURS+=("0")
USERS+=("") USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
PASSES+=("") PASSES+=("$(library_env_get VIEWER_PASSWORD)")
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME") NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
save_config save_config
} }
@@ -125,11 +132,12 @@ library_add_tab() {
# Drop every page that points at the catalogue's local address. # Drop every page that points at the catalogue's local address.
library_remove_tab() { library_remove_tab() {
load_existing_config load_existing_config
local url idx local url old idx
url="$(library_catalogue_url)" url="$(library_catalogue_url)"
old="$(library_old_catalogue_url)"
local -a nu=() nd=() nus=() np=() nn=() local -a nu=() nd=() nus=() np=() nn=()
for idx in "${!URLS[@]}"; do for idx in "${!URLS[@]}"; do
[[ "${URLS[$idx]}" == "$url" ]] && continue [[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}") nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}") np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
done done
@@ -199,7 +207,7 @@ action_library_install() {
return 1 return 1
fi fi
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
library_add_tab library_add_tab
fi fi
echo echo
@@ -217,10 +225,12 @@ action_library_show_access() {
ip="localhost" ip="localhost"
echo "(Network access is limited to this kiosk.)" echo "(Network access is limited to this kiosk.)"
fi fi
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/" echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/" echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/" echo " Admin login: ${user} / ${pass}"
echo " Login (staff and add books): ${user} / ${pass}" echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
echo " it cannot add or change anything (and it has no login on the staff site itself)."
echo echo
echo " Passwords live in $LIBRARY_DIR/.env (root only)." echo " Passwords live in $LIBRARY_DIR/.env (root only)."
pause pause
+18 -12
View File
@@ -30,12 +30,15 @@
# Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings) # Factory Reset, Virtual Consoles, Emergency Hotspot, Clone Settings)
# stays terminal-only for now. # stays terminal-only for now.
# #
# No login of its own, by design: this addon assumes it'll be put behind # Login: when the kiosk's password protection is on (Core Settings ->
# a reverse proxy (e.g. Caddy) with Authelia forward-auth in front, the # Password Protection & Lockout), the Web UI asks for the same PIN/password
# same way other self-hosted apps get protected - Authelia integration # the lock screen uses (webui/lib/auth.js). With protection off it has no
# is explicitly out of scope for this repo (Authelia runs elsewhere). # login, so on a network you don't fully trust, turn protection on or put
# Direct LAN access with no proxy in front has no authentication at all - # it behind a reverse proxy (e.g. Caddy) with Authelia forward-auth in
# treat it the same as SSH access to this kiosk, which is also the # front, the same way other self-hosted apps get protected - Authelia
# integration is explicitly out of scope for this repo (Authelia runs
# elsewhere). Direct LAN access with protection off has no authentication
# at all - treat it the same as SSH access to this kiosk, which is also the
# access level the privileged helper effectively grants if reached # access level the privileged helper effectively grants if reached
# without a proxy in front: bounded to its fixed action list, not a # without a proxy in front: bounded to its fixed action list, not a
# root shell, but real system mutation all the same. # root shell, but real system mutation all the same.
@@ -59,9 +62,9 @@ webui_is_active() {
webui_port() { webui_port() {
if sudo test -f "$WEBUI_ENV_DIR/webui.env" 2>/dev/null; then 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 "8090" sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8888"
else else
echo "8090" echo "8888"
fi fi
} }
@@ -274,14 +277,15 @@ action_webui_install() {
echo "This installs a small web app for editing Sites & Page Timing," echo "This installs a small web app for editing Sites & Page Timing,"
echo "Display & Interaction, and Password Protection & Lockout from a" echo "Display & Interaction, and Password Protection & Lockout from a"
echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite," echo "browser - plus installing/reconfiguring CUPS, LMS/Squeezelite,"
echo "and Asterisk Intercom, and checking for updates. It has no login" echo "and Asterisk Intercom, and checking for updates. When the kiosk's"
echo "of its own - put it behind your own reverse proxy (e.g. Caddy +" echo "password protection is on it asks for the same PIN/password; when it"
echo "Authelia) if it needs to be reachable beyond a trusted LAN." 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 echo
ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; } ask_yes_no "Install the Web UI?" "n" || { echo "Cancelled"; return; }
local port local port
port=$(ask_integer "Port to listen on" "8090" 1024 65535) port=$(ask_integer "Port to listen on" "8888" 1024 65535)
echo "Installing app files and npm dependencies..." echo "Installing app files and npm dependencies..."
if ! webui_install_app_files; then if ! webui_install_app_files; then
@@ -321,6 +325,8 @@ action_webui_reconfigure() {
webui_write_env_file "$port" webui_write_env_file "$port"
sudo systemctl restart kiosk-webui sudo systemctl restart kiosk-webui
sudo ufw allow "${port}/tcp" comment 'Kiosk Web UI' 2>/dev/null || true 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}" log_success "Web UI now listening on port ${port}"
} }
+12
View File
@@ -121,6 +121,18 @@ action_upgrade() {
provision_configure_display provision_configure_display
provision_configure_firewall provision_configure_firewall
provision_configure_power_management 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" log_success "Setup refreshed"
echo echo
+33
View File
@@ -20,6 +20,7 @@ display_status() {
echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")" echo "Pause button: $(onoff "$ENABLE_PAUSE_BUTTON")"
echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")" echo "Keyboard button: $(onoff "$ENABLE_KEYBOARD_BUTTON")"
echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")" echo "Navigation button: $(onoff "$ENABLE_NAV_BUTTON")"
echo "Back/home/fwd btns: $(onoff "$ENABLE_HISTORY_BUTTONS") (opacity ${HISTORY_BUTTONS_OPACITY}%)"
} }
display_menu_builder() { display_menu_builder() {
@@ -29,6 +30,8 @@ display_menu_builder() {
"Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))" "Toggle pause button (currently: $(onoff "$ENABLE_PAUSE_BUTTON"))"
"Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))" "Toggle on-screen keyboard button (currently: $(onoff "$ENABLE_KEYBOARD_BUTTON"))"
"Toggle navigation/help button (currently: $(onoff "$ENABLE_NAV_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=( MENU_HANDLERS=(
action_set_touch_mode action_set_touch_mode
@@ -36,6 +39,8 @@ display_menu_builder() {
action_toggle_pause_button action_toggle_pause_button
action_toggle_keyboard_button action_toggle_keyboard_button
action_toggle_nav_button action_toggle_nav_button
action_toggle_history_buttons
action_set_history_opacity
) )
} }
@@ -131,3 +136,31 @@ action_toggle_nav_button() {
fi fi
save_config 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,6 +89,8 @@ sites_status() {
else else
echo "Auto-rotation: off (all pages manual/hidden)" echo "Auto-rotation: off (all pages manual/hidden)"
fi 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 if [[ "$HOME_TAB_INDEX" != "-1" ]]; then
echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout" echo "Home page: #$((HOME_TAB_INDEX + 1)), ${INACTIVITY_TIMEOUT}s inactivity timeout"
else else
+28 -14
View File
@@ -74,6 +74,8 @@ STAFF_PORT=8081
QUICKADD_PORT=8082 QUICKADD_PORT=8082
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=s3cretPASS ADMIN_PASSWORD=s3cretPASS
VIEWER_USER=guest
VIEWER_PASSWORD=guestPASS
BIND_ADDR=${BIND_ADDR:-0.0.0.0} BIND_ADDR=${BIND_ADDR:-0.0.0.0}
ENV ENV
echo running > "$STUB_STATE" echo running > "$STUB_STATE"
@@ -98,7 +100,7 @@ source "$REPO/menus/addon_library.sh"
export KIOSK_UNATTENDED=0 export KIOSK_UNATTENDED=0
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; } tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
# ============================================================================ # ============================================================================
echo "== lib/docker.sh" echo "== lib/docker.sh"
@@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG" check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG" check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
check "is_installed is now true" library_is_installed check "is_installed is now true" library_is_installed
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs) check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs) check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
addon_library_status > "$T/status.out" addon_library_status > "$T/status.out"
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out" check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
@@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
# ============================================================================ # ============================================================================
echo "== menu while installed" echo "== menu while installed"
addon_library_menu_builder addon_library_menu_builder
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}" check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}" check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
echo "== tab toggling is idempotent" echo "== tab toggling is idempotent"
library_add_tab >/dev/null 2>&1 library_add_tab >/dev/null 2>&1
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1 check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0 check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs) check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
addon_library_menu_builder addon_library_menu_builder
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}" check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1 check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
# ============================================================================ # ============================================================================
echo "== an earlier version's tab (public catalogue address) is cleaned up"
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
library_remove_tab >/dev/null 2>&1
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
library_add_tab >/dev/null 2>&1
echo "== network access" echo "== network access"
: > "$STUB_LOG" : > "$STUB_LOG"
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
@@ -168,8 +180,10 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
# ============================================================================ # ============================================================================
echo "== access screen" echo "== access screen"
printf '\n' | action_library_show_access > "$T/access.out" 2>&1 printf '\n' | action_library_show_access > "$T/access.out" 2>&1
check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out" check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out" check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
# ============================================================================ # ============================================================================
echo "== update" echo "== update"
@@ -185,7 +199,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG" check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'" check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env" check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0 check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
echo "== purge needs the typed confirmation" echo "== purge needs the typed confirmation"
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1 printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
@@ -195,8 +209,8 @@ library_add_tab >/dev/null 2>&1
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1 printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG" check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
check "install dir removed" test ! -e "$LIBRARY_DIR" check "install dir removed" test ! -e "$LIBRARY_DIR"
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0 check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs) check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
echo echo
echo "passed: $PASS failed: $FAIL" echo "passed: $PASS failed: $FAIL"
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// webui/lib/auth.js - login for the Web UI, using the SAME code the kiosk's
// lock screen uses (Core Settings -> Password Protection & Lockout: a PIN or
// a password).
//
// Only when the kiosk's password protection is on. With it off, the Web UI
// is open as before (it is meant for a trusted LAN, or behind a reverse
// proxy with its own login - see server.js).
//
// A correct code gives the browser a random session cookie (kept in memory;
// a restart of the service logs everyone out). Wrong codes are slowed down
// the same way the lock screen does it: after 5 wrong tries in a row, a
// wait of 30 s that doubles up to 5 minutes. Changing the kiosk's code logs
// every browser out, because sessions are tied to the stored code.
const crypto = require('crypto');
const { readLockSettings, hashPassword } = require('./config');
const COOKIE = 'kioskwebui';
const SESSION_MS = 12 * 60 * 60 * 1000; // 12 hours
const FREE_TRIES = 5;
const BASE_WAIT_S = 30;
const MAX_WAIT_S = 300;
const sessions = new Map(); // token -> { expires, fp }
let failures = 0;
let blockedUntil = 0;
// A short fingerprint of the stored code: sessions stop working when it changes.
const fingerprint = (hash) => crypto.createHash('sha256').update('fp:' + hash).digest('hex');
function parseCookies(header) {
const out = {};
for (const part of String(header || '').split(';')) {
const i = part.indexOf('=');
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
function currentSession(req, lock) {
const token = parseCookies(req.headers.cookie)[COOKIE];
if (!token) return null;
const s = sessions.get(token);
if (!s) return null;
if (s.expires < Date.now() || s.fp !== fingerprint(lock.hash)) {
sessions.delete(token);
return null;
}
return token;
}
function setCookie(res, token, maxAgeS) {
// HttpOnly + SameSite=Strict: not readable by page scripts, and not sent
// on requests that start from another site. No Secure flag - this is
// normally plain http on a LAN.
res.setHeader('Set-Cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeS}`);
}
function issueSession(res, lock) {
for (const [t, s] of sessions) if (s.expires < Date.now()) sessions.delete(t);
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { expires: Date.now() + SESSION_MS, fp: fingerprint(lock.hash) });
setCookie(res, token, SESSION_MS / 1000);
}
function codeMatches(code, hash) {
const a = Buffer.from(hashPassword(code), 'hex');
const b = Buffer.from(hash, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// POST /api/login { code }
function login(req, res) {
const lock = readLockSettings();
if (!lock.enabled) return res.json({ ok: true, required: false });
const wait = Math.ceil((blockedUntil - Date.now()) / 1000);
if (wait > 0) {
return res.status(429).json({ ok: false, waitSeconds: wait, error: `Too many wrong tries - wait ${wait}s` });
}
const code = req.body && typeof req.body.code === 'string' ? req.body.code : '';
if (code && codeMatches(code, lock.hash)) {
failures = 0;
issueSession(res, lock);
return res.json({ ok: true });
}
failures++;
const what = lock.codeType === 'pin' ? 'PIN' : 'password';
if (failures >= FREE_TRIES) {
const waitS = Math.min(MAX_WAIT_S, BASE_WAIT_S * 2 ** (failures - FREE_TRIES));
blockedUntil = Date.now() + waitS * 1000;
return res.status(429).json({ ok: false, waitSeconds: waitS, error: `Too many wrong tries - wait ${waitS}s` });
}
return res.status(401).json({ ok: false, error: `Incorrect ${what}` });
}
// POST /api/logout
function logout(req, res) {
const token = parseCookies(req.headers.cookie)[COOKIE];
if (token) sessions.delete(token);
setCookie(res, '', 0);
res.json({ ok: true });
}
// GET /api/auth - open to everyone: tells the page whether to show a login.
function status(req, res) {
const lock = readLockSettings();
res.json({
required: lock.enabled,
authed: !lock.enabled || !!currentSession(req, lock),
codeType: lock.codeType,
});
}
// Gate for everything else. With protection off it lets everything through.
// Without a session: API calls get a 401, page requests get the login page.
function requireLogin(loginPage) {
return (req, res, next) => {
const lock = readLockSettings();
if (!lock.enabled) return next();
if (currentSession(req, lock)) return next();
// The few things the login page itself needs.
if (req.path === '/api/login' || req.path === '/api/auth' || req.path === '/style.css') return next();
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'Login required', loginRequired: true });
}
res.status(401).sendFile(loginPage);
};
}
// After a successful save that left protection on (e.g. the code was just
// set or changed), keep the person who saved it logged in.
function keepLoggedInAfterSave(res) {
const lock = readLockSettings();
if (lock.enabled) issueSession(res, lock);
}
module.exports = { requireLogin, login, logout, status, keepLoggedInAfterSave, _sessions: sessions };
+16 -1
View File
@@ -40,6 +40,8 @@ const SCALAR_DEFAULTS = {
enablePauseButton: true, enablePauseButton: true,
enableKeyboardButton: true, enableKeyboardButton: true,
enableNavButton: true, enableNavButton: true,
enableHistoryButtons: false,
historyButtonsOpacity: 50,
enablePasswordProtection: false, enablePasswordProtection: false,
lockoutTimeout: 0, lockoutTimeout: 0,
lockoutAtTime: '', lockoutAtTime: '',
@@ -177,4 +179,17 @@ function saveConfig(patch) {
return loadConfig(); return loadConfig();
} }
module.exports = { loadConfig, saveConfig, CONFIG_PATH, SCALAR_DEFAULTS }; // What the Web UI login needs: whether the kiosk's password protection is
// on and the stored SHA-256 of its unlock code (the same one the lock screen
// checks). Only used server-side to verify a login - never sent to a browser.
function readLockSettings() {
const existing = readExisting();
const hash = typeof existing.lockoutPassword === 'string' ? existing.lockoutPassword : '';
return {
enabled: existing.enablePasswordProtection === true && hash.length > 0,
hash,
codeType: existing.lockoutCodeType === 'pin' ? 'pin' : 'password',
};
}
module.exports = { loadConfig, saveConfig, readLockSettings, hashPassword, CONFIG_PATH, SCALAR_DEFAULTS };
+30
View File
@@ -1,3 +1,12 @@
// Signed out (or the kiosk's code changed): any 401 from the server sends
// the page back to the login screen.
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const res = await nativeFetch(...args);
if (res.status === 401 && !String(args[0]).includes('/api/login')) location.reload();
return res;
};
'use strict'; 'use strict';
// webui/public/app.js - vanilla JS, no framework/build step. Every // webui/public/app.js - vanilla JS, no framework/build step. Every
@@ -32,6 +41,23 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
document.getElementById('brand-sub').textContent = location.host || 'this kiosk'; 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) */ /* Config API (Sites / Display / Lockout) */
/* ---------------------------------------------------------------------- */ /* ---------------------------------------------------------------------- */
@@ -173,6 +199,8 @@ displayForm.addEventListener('submit', async (e) => {
enablePauseButton: fd.get('enablePauseButton') === 'on', enablePauseButton: fd.get('enablePauseButton') === 'on',
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on', enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
enableNavButton: fd.get('enableNavButton') === 'on', enableNavButton: fd.get('enableNavButton') === 'on',
enableHistoryButtons: fd.get('enableHistoryButtons') === 'on',
historyButtonsOpacity: parseInt(fd.get('historyButtonsOpacity'), 10),
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10), homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10), inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
}; };
@@ -282,6 +310,8 @@ function populateAll(config) {
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton; displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton; displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
displayForm.elements.enableNavButton.checked = !!config.enableNavButton; displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
displayForm.elements.enableHistoryButtons.checked = !!config.enableHistoryButtons;
displayForm.elements.historyButtonsOpacity.value = config.historyButtonsOpacity;
updateHomeTabOptions(config.tabs); updateHomeTabOptions(config.tabs);
homeTabSelect.value = String(config.homeTabIndex); homeTabSelect.value = String(config.homeTabIndex);
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60); displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
+18 -1
View File
@@ -46,7 +46,7 @@
</button> </button>
</div> </div>
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div> <div class="sidebar-footer" id="sidebar-footer">Open to anyone on this network while the kiosk's password protection is off. Turn it on (Lockout) and this page asks for the same PIN/password. Beyond a trusted LAN, front it with your own reverse proxy + Authelia.</div>
</nav> </nav>
<main class="main"> <main class="main">
@@ -65,6 +65,18 @@
<button type="button" id="save-sites">Save Sites &amp; Page Timing</button> <button type="button" id="save-sites">Save Sites &amp; Page Timing</button>
</div> </div>
</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> </section>
<!-- Display & Interaction --> <!-- Display & Interaction -->
@@ -95,6 +107,7 @@
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label> <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="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="enableNavButton"> Navigation/help button</label>
<label class="checkbox"><input type="checkbox" name="enableHistoryButtons"> On-screen back / home / forward buttons (top middle)</label>
<fieldset> <fieldset>
<legend>Home page</legend> <legend>Home page</legend>
@@ -104,6 +117,10 @@
<option value="-1">Disabled</option> <option value="-1">Disabled</option>
</select> </select>
</label> </label>
<label>
Back/home/forward button opacity (10 = faint, 100 = solid)
<input type="number" name="historyButtonsOpacity" min="10" max="100" step="5">
</label>
<label> <label>
Inactivity timeout (minutes) Inactivity timeout (minutes)
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1"> <input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kiosk Web UI - sign in</title>
<link rel="stylesheet" href="style.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.login { width: min(360px, 92vw); padding: 28px; border-radius: 14px; background: var(--surface, #fff);
box-shadow: 0 6px 30px rgba(0,0,0,.18); }
.login h1 { font-size: 20px; margin: 0 0 6px; }
.login p { margin: 0 0 16px; opacity: .75; font-size: 14px; }
.login input { width: 100%; box-sizing: border-box; padding: 12px; font-size: 18px; margin-bottom: 12px; }
.login button { width: 100%; padding: 12px; font-size: 16px; cursor: pointer; }
#err { color: #c0392b; min-height: 20px; margin-top: 10px; font-size: 14px; }
</style>
</head>
<body>
<form class="login" id="form">
<h1>Kiosk Web UI</h1>
<p id="hint">Enter the kiosk's code to continue.</p>
<input id="code" type="password" autocomplete="current-password" autofocus>
<button type="submit" id="go">Sign in</button>
<div id="err"></div>
</form>
<script>
const $ = (id) => document.getElementById(id);
fetch('/api/auth').then((r) => r.json()).then((s) => {
if (s.authed) { location.href = '/'; return; }
if (s.codeType === 'pin') {
$('hint').textContent = "Enter the kiosk's PIN to continue.";
$('code').setAttribute('inputmode', 'numeric');
} else {
$('hint').textContent = "Enter the kiosk's password to continue.";
}
}).catch(() => {});
$('form').addEventListener('submit', async (e) => {
e.preventDefault();
$('go').disabled = true;
$('err').textContent = '';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: $('code').value }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok) { location.href = '/'; return; }
$('err').textContent = data.error || 'Sign in failed';
$('code').value = '';
$('code').focus();
} catch (err) {
$('err').textContent = 'Could not reach the kiosk';
}
$('go').disabled = false;
});
</script>
</body>
</html>
+11
View File
@@ -393,3 +393,14 @@ button.danger:hover { background: var(--danger-soft); }
.sidebar-footer { display: none; } .sidebar-footer { display: none; }
.main { padding: 20px; } .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));
}
+27 -7
View File
@@ -6,11 +6,12 @@
// no privileged system mutation involved (see menus/addon_webui.sh's // no privileged system mutation involved (see menus/addon_webui.sh's
// header for why the rest of Core Settings/Addons/Advanced aren't here). // header for why the rest of Core Settings/Addons/Advanced aren't here).
// //
// No login of its own by design: Authelia runs elsewhere, and the admin // Login: when the kiosk's password protection is on, the Web UI asks for the
// site goes behind the user's own Caddy reverse proxy with Authelia // same PIN/password the lock screen uses (lib/auth.js); with it off it is
// forward-auth in front of it, the same way every other self-hosted app // open. Beyond that, no login of its own: Authelia runs elsewhere, and the
// they run is protected. This process only binds where it's told to // admin site can go behind the user's own Caddy reverse proxy with Authelia
// (BIND_ADDR/PORT below) and trusts whatever's in front of it. // forward-auth in front of it. This process only binds where it's told to
// (BIND_ADDR/PORT below).
// //
// Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh // Runs as $KIOSK_USER (see the systemd unit menus/addon_webui.sh
// installs) - the same user Electron runs as, and the owner of // installs) - the same user Electron runs as, and the owner of
@@ -22,6 +23,7 @@ const express = require('express');
const { loadConfig, saveConfig } = require('./lib/config'); const { loadConfig, saveConfig } = require('./lib/config');
const { ACTIONS } = require('./lib/actions'); const { ACTIONS } = require('./lib/actions');
const { startJob, getJob } = require('./lib/jobs'); 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 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'; const WIFI_HELPER_PATH = process.env.WIFI_HELPER_PATH || '/usr/local/bin/kiosk-wifi-helper';
@@ -29,6 +31,14 @@ const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'su
const app = express(); const app = express();
app.use(express.json({ limit: '256kb' })); 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'))); app.use(express.static(path.join(__dirname, 'public')));
const SWIPE_MODES = ['dual', 'standard']; const SWIPE_MODES = ['dual', 'standard'];
@@ -70,6 +80,12 @@ app.put('/api/config', (req, res) => {
if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton; if (body.enablePauseButton !== undefined) patch.enablePauseButton = !!body.enablePauseButton;
if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton; if (body.enableKeyboardButton !== undefined) patch.enableKeyboardButton = !!body.enableKeyboardButton;
if (body.enableNavButton !== undefined) patch.enableNavButton = !!body.enableNavButton; 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; let tabCount;
if (body.tabs !== undefined) { if (body.tabs !== undefined) {
@@ -173,7 +189,11 @@ app.put('/api/config', (req, res) => {
} }
try { try {
res.json(saveConfig(patch)); const saved = saveConfig(patch);
// If this save set or changed the kiosk's code, every old session
// just stopped working - keep the person who saved it signed in.
auth.keepLoggedInAfterSave(res);
res.json(saved);
} catch (e) { } catch (e) {
console.error('saveConfig failed:', e); console.error('saveConfig failed:', e);
res.status(500).json({ error: 'Failed to save configuration' }); res.status(500).json({ error: 'Failed to save configuration' });
@@ -288,7 +308,7 @@ app.put('/api/region', (req, res) => {
runRegionHelper('region-set', input, res); runRegionHelper('region-set', input, res);
}); });
const PORT = process.env.PORT || 8090; const PORT = process.env.PORT || 8888;
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0'; const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
if (require.main === module) { if (require.main === module) {
+14
View File
@@ -18,6 +18,20 @@ process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helpe
const app = require('../server'); 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; let failures = 0;
async function check(label, fn) { async function check(label, fn) {
try { try {
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// webui/test/auth.test.js - the Web UI login: with the kiosk's password
// protection off the UI is open; with it on, everything needs the kiosk's
// PIN/password. Starts the real server on a random port against a scratch
// config.json. Run with: node test/auth.test.js
const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-auth-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
process.env.SUDO_CMD = '';
const app = require('../server');
const { saveConfig } = require('../lib/config');
let failures = 0;
async function check(label, fn) {
try {
await fn();
console.log(`PASS: ${label}`);
} catch (e) {
failures++;
console.log(`FAIL: ${label} - ${e.message}`);
}
}
async function main() {
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (url, body, cookie) => fetch(base + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body || {}),
});
const get = (url, cookie) => fetch(base + url, { headers: cookie ? { cookie } : {} });
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
await check('protection off: no login needed, /api/auth says so', async () => {
assert.strictEqual((await get('/api/config')).status, 200);
const a = await (await get('/api/auth')).json();
assert.strictEqual(a.required, false);
assert.strictEqual(a.authed, true);
});
saveConfig({ enablePasswordProtection: true, newLockoutPassword: 'hunter22' });
await check('protection on: API calls are refused without a login', async () => {
const res = await get('/api/config');
assert.strictEqual(res.status, 401);
assert.strictEqual((await res.json()).loginRequired, true);
assert.strictEqual((await post('/api/actions/anything/run')).status, 401);
});
await check('protection on: pages get the login screen, not the app', async () => {
const res = await get('/');
assert.strictEqual(res.status, 401);
const html = await res.text();
assert.ok(html.includes('id="code"'));
assert.ok(!html.includes('nav-item'));
assert.strictEqual((await get('/app.js')).status, 401);
});
await check('/api/auth reports a login is required, and the code type', async () => {
const a = await (await get('/api/auth')).json();
assert.strictEqual(a.required, true);
assert.strictEqual(a.authed, false);
assert.strictEqual(a.codeType, 'password');
});
await check('a wrong code is refused and gives no session', async () => {
const res = await post('/api/login', { code: 'nope' });
assert.strictEqual(res.status, 401);
assert.strictEqual(res.headers.get('set-cookie'), null);
});
let cookie = '';
await check('the right code gives an HttpOnly session that unlocks the API', async () => {
const res = await post('/api/login', { code: 'hunter22' });
assert.strictEqual(res.status, 200);
const raw = res.headers.get('set-cookie');
assert.ok(/HttpOnly/i.test(raw) && /SameSite=Strict/i.test(raw));
cookie = cookieOf(res);
assert.strictEqual((await get('/api/config', cookie)).status, 200);
assert.strictEqual((await get('/', cookie)).status, 200);
assert.strictEqual((await (await get('/api/auth', cookie)).json()).authed, true);
});
await check('the stored code hash is never sent to the browser', async () => {
const text = await (await get('/api/config', cookie)).text();
assert.ok(!text.includes('lockoutPassword"'));
assert.ok(!/[0-9a-f]{64}/.test(text));
});
await check('saving settings keeps you signed in', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify({ enableNavButton: false }),
});
assert.strictEqual(res.status, 200);
});
await check('changing the kiosk code signs every browser out', async () => {
saveConfig({ newLockoutPassword: 'another-one' });
assert.strictEqual((await get('/api/config', cookie)).status, 401);
assert.strictEqual((await post('/api/login', { code: 'hunter22' })).status, 401);
const ok = await post('/api/login', { code: 'another-one' });
assert.strictEqual(ok.status, 200);
cookie = cookieOf(ok);
});
await check('setting a new code from the Web UI keeps that browser signed in', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify({ newLockoutPassword: 'third-code' }),
});
assert.strictEqual(res.status, 200);
const fresh = cookieOf(res);
assert.ok(fresh && fresh !== cookie);
assert.strictEqual((await get('/api/config', fresh)).status, 200);
assert.strictEqual((await get('/api/config', cookie)).status, 401);
cookie = fresh;
});
await check('sign out ends the session', async () => {
assert.strictEqual((await post('/api/logout', {}, cookie)).status, 200);
assert.strictEqual((await get('/api/config', cookie)).status, 401);
});
await check('five wrong codes in a row slow logins down - even the right code waits', async () => {
let last;
for (let i = 0; i < 5; i++) last = await post('/api/login', { code: 'wrong' + i });
assert.strictEqual(last.status, 429);
assert.ok((await last.json()).waitSeconds >= 30);
assert.strictEqual((await post('/api/login', { code: 'third-code' })).status, 429);
});
server.close();
fs.rmSync(scratchDir, { recursive: true, force: true });
if (failures > 0) {
console.log(`${failures} FAILURE(S)`);
process.exit(1);
}
console.log('ALL DONE');
}
main();
+11
View File
@@ -75,6 +75,17 @@ check('saveConfig merge preserves fields this app never tracks (the previously-f
assert.strictEqual(onDisk.enableNavButton, false); 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', () => { check('saveConfig tabs: new password gets hashed, never stored/returned as plaintext', () => {
const result = saveConfig({ const result = saveConfig({
tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }], tabs: [{ url: 'https://a.example.com', duration: 30, name: 'A', username: 'bob', password: 'hunter2' }],