Compare commits
58
Commits
46903b93a4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6532c1367 | ||
|
|
6fdc5f4dd1 | ||
|
|
0af833a39d | ||
|
|
442b45c2e0 | ||
|
|
6f877d1107 | ||
|
|
3d02d28a8c | ||
|
|
7f230c8e73 | ||
|
|
b784d98c9d | ||
|
|
d01919e2ac | ||
|
|
10e991e158 | ||
|
|
9eadf266f9 | ||
|
|
770a6a1dfb | ||
|
|
522a33b127 | ||
|
|
886745a979 | ||
|
|
e0fa69d470 | ||
|
|
84e8f074e8 | ||
|
|
551df48687 | ||
|
|
f09bd6bb95 | ||
|
|
9c1f22382e | ||
|
|
b836fd6771 | ||
|
|
fa0d602505 | ||
|
|
e7cf15a802 | ||
|
|
441615da7a | ||
|
|
1775220312 | ||
|
|
7ed2d4744c | ||
|
|
9b646b99f8 | ||
|
|
a2811cced6 | ||
|
|
07e7d2866f | ||
|
|
0fe631b4ed | ||
|
|
5fc7450af7 | ||
|
|
023a64b62c | ||
|
|
49c35f765b | ||
|
|
76866e8bbc | ||
|
|
aa7950aa9e | ||
|
|
e08133dfc0 | ||
|
|
24c95c91f2 | ||
|
|
2ef8f68b9b | ||
|
|
f4da2608cd | ||
|
|
f0a0c4ad40 | ||
|
|
b4c844b124 | ||
|
|
a15478b382 | ||
|
|
7244cfd918 | ||
|
|
5b79608695 | ||
|
|
e828805b9c | ||
|
|
7cb4a661f9 | ||
|
|
eb7bc14a9c | ||
|
|
ead4500406 | ||
|
|
4085866134 | ||
|
|
763162fc57 | ||
|
|
d30f7c7260 | ||
|
|
d673d04259 | ||
|
|
6cc5296cd6 | ||
|
|
5a4e8a8734 | ||
|
|
3e665688c5 | ||
|
|
7f8947ade2 | ||
|
|
d0d39e48bb | ||
|
|
9221fc85aa | ||
|
|
b88d648e14 |
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -427,11 +428,36 @@ Both can be used at the same time — they serve different purposes:
|
|||||||
- **IPP Everywhere support**
|
- **IPP Everywhere support**
|
||||||
- **PDF printing** via cups-pdf
|
- **PDF printing** via cups-pdf
|
||||||
|
|
||||||
|
### Library Catalog (Koha)
|
||||||
|
- **Public search terminal** - a self-hosted library catalogue shown as one of the kiosk's pages (duration 0: it stays on screen), so visitors can search by title, author or subject and see whether a book is in
|
||||||
|
- **Add books by scanning** - a Quick Add page: scan or type an ISBN (or tap "Scan with camera" on a phone) and the book is looked up (Library of Congress via Z39.50, then Open Library) and added with the next barcode. Duplicates, "add another copy", manual entry and Undo are built in
|
||||||
|
- **Spine labels and barcodes** - Avery 5160/5167 and Demco spine templates, with Dewey call numbers split across lines
|
||||||
|
- **Docker is installed for you** if it is missing (`lib/docker.sh`); the stack is deployed from this checkout's `library/` to `/opt/kiosk-library`
|
||||||
|
- **Who can reach it** - Addons → Library Catalog → Network access: *this kiosk only* (ports bound to `127.0.0.1`) or *other devices on the network*. Docker bypasses `ufw`, so the bind address, not the firewall, is what limits access
|
||||||
|
- **Uninstall keeps your books** unless you also choose to delete the data (typed `DELETE` confirmation); Complete Uninstall removes everything
|
||||||
|
- Needs roughly 2 GB RAM and 3 GB disk; the first build takes 5-10 minutes. See [`library/README.md`](library/README.md) for the stack itself, and run `bash tests/addon_library.test.sh` for the add-on's tests
|
||||||
|
|
||||||
### Remote Access
|
### Remote Access
|
||||||
- **VNC** - x11vnc for remote desktop
|
- **VNC** - x11vnc for remote desktop
|
||||||
- **WireGuard VPN** - Config paste support
|
- **WireGuard VPN** - Config paste support
|
||||||
- **Tailscale VPN** - Auth key support
|
- **Tailscale VPN** - Auth key support
|
||||||
- **Netbird VPN** - Setup key support
|
- **Netbird VPN** - Setup key support
|
||||||
|
- Headless join, no browser login (get a setup key from the Netbird dashboard):
|
||||||
|
```bash
|
||||||
|
sudo netbird up --setup-key YOUR_SETUP_KEY --allow-server-ssh
|
||||||
|
```
|
||||||
|
`--allow-server-ssh` enables Netbird's built-in SSH server (peers also need an SSH policy in the dashboard).
|
||||||
|
|
||||||
|
#### SSH Host aliases
|
||||||
|
|
||||||
|
`~/.ssh/config` lets you `ssh <alias>` instead of typing `ssh user@1.2.3.4` every time — especially handy once machines are reachable over a VPN/Netbird overlay network where the IP is easy to forget:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host myserver
|
||||||
|
HostName 100.x.x.x
|
||||||
|
User someuser
|
||||||
|
Port 22
|
||||||
|
```
|
||||||
|
|
||||||
### Advanced
|
### Advanced
|
||||||
- **Emergency WiFi Hotspot** - Auto-starts if no internet after boot (configurable during install)
|
- **Emergency WiFi Hotspot** - Auto-starts if no internet after boot (configurable during install)
|
||||||
@@ -481,6 +507,7 @@ Both can be used at the same time — they serve different purposes:
|
|||||||
- **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
|
||||||
|
|
||||||
@@ -512,7 +539,9 @@ It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kios
|
|||||||
|
|
||||||
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
|
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
|
||||||
|
|
||||||
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
|
**More than one WiFi adapter** (e.g. a built-in card and a USB dongle): a *WiFi adapter* dropdown appears at the top, and the status box lists every adapter with what it's connected to. Status, scan and connect act on the one you pick; by default it's the adapter that's connected right now.
|
||||||
|
|
||||||
|
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect and region only). The app reaches it through a root-owned socket, `/run/kiosk-wifi-helper.sock` (`kiosk-wifi-helper.socket`, group-restricted to the kiosk user, `0660`), rather than `sudo`: the app can end up running with Linux's "no new privileges" flag, and then sudo refuses with *"The no new privileges flag is set"*. A passwordless-sudo rule (`/etc/sudoers.d/kiosk-wifi`) stays as a fallback. Existing kiosks get both with Advanced → Upgrade.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -748,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
|
||||||
@@ -1326,7 +1358,13 @@ This project bundles or installs several open-source components under their resp
|
|||||||
- **Repository:** https://github.com/netbirdio/netbird
|
- **Repository:** https://github.com/netbirdio/netbird
|
||||||
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
|
- **Full License:** https://github.com/netbirdio/netbird/blob/main/LICENSE
|
||||||
|
|
||||||
|
### Koha (Library Catalog add-on)
|
||||||
|
- **License:** GPL-3.0-or-later
|
||||||
|
- **Source:** https://koha-community.org
|
||||||
|
- **Packages:** installed from the Koha project's own Debian repository (https://debian.koha-community.org) inside a Docker image built on your machine
|
||||||
|
|
||||||
|
### MariaDB and Docker (Library Catalog add-on)
|
||||||
|
- **MariaDB** - GPL-2.0 (https://mariadb.org); **Docker Engine** - Apache-2.0 (https://www.docker.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1548,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.
|
||||||
@@ -1559,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.
|
||||||
|
|||||||
+11
-3
@@ -38,7 +38,11 @@
|
|||||||
# Display/Lockout, the webui/ Node app installed as a systemd
|
# Display/Lockout, the webui/ Node app installed as a systemd
|
||||||
# service; no login of its own, put it behind your own reverse proxy
|
# service; no login of its own, put it behind your own reverse proxy
|
||||||
# with Authelia forward-auth if it needs to be reachable beyond a
|
# with Authelia forward-auth if it needs to be reachable beyond a
|
||||||
# trusted LAN - see the file header).
|
# trusted LAN - see the file header), Library Catalog (menus/
|
||||||
|
# addon_library.sh - a self-hosted Koha library catalogue in Docker with
|
||||||
|
# Z39.50 lookup, label printing and a scan-to-add page, shown on the
|
||||||
|
# kiosk as a public search terminal; installs Docker on demand via
|
||||||
|
# lib/docker.sh).
|
||||||
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
|
||||||
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
# audio/network), Electron Maintenance (menus/advanced_electron.sh -
|
||||||
# manual update, fix blank screen), Factory Reset
|
# manual update, fix blank screen), Factory Reset
|
||||||
@@ -67,6 +71,8 @@ source "$SCRIPT_DIR/lib/menu.sh"
|
|||||||
source "$SCRIPT_DIR/lib/config.sh"
|
source "$SCRIPT_DIR/lib/config.sh"
|
||||||
# shellcheck source=lib/electron.sh
|
# shellcheck source=lib/electron.sh
|
||||||
source "$SCRIPT_DIR/lib/electron.sh"
|
source "$SCRIPT_DIR/lib/electron.sh"
|
||||||
|
# shellcheck source=lib/docker.sh
|
||||||
|
source "$SCRIPT_DIR/lib/docker.sh"
|
||||||
# shellcheck source=menus/sites.sh
|
# shellcheck source=menus/sites.sh
|
||||||
source "$SCRIPT_DIR/menus/sites.sh"
|
source "$SCRIPT_DIR/menus/sites.sh"
|
||||||
# shellcheck source=menus/display.sh
|
# shellcheck source=menus/display.sh
|
||||||
@@ -97,6 +103,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
|
|||||||
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh"
|
||||||
# shellcheck source=menus/addon_webui.sh
|
# shellcheck source=menus/addon_webui.sh
|
||||||
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
source "$SCRIPT_DIR/menus/addon_webui.sh"
|
||||||
|
# shellcheck source=menus/addon_library.sh
|
||||||
|
source "$SCRIPT_DIR/menus/addon_library.sh"
|
||||||
# shellcheck source=menus/advanced_electron.sh
|
# shellcheck source=menus/advanced_electron.sh
|
||||||
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
source "$SCRIPT_DIR/menus/advanced_electron.sh"
|
||||||
# shellcheck source=menus/advanced_upgrade.sh
|
# shellcheck source=menus/advanced_upgrade.sh
|
||||||
@@ -179,8 +187,8 @@ core_settings_menu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addons_menu_builder() {
|
addons_menu_builder() {
|
||||||
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI")
|
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Library Catalog (Koha)")
|
||||||
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu)
|
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_library_menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
addons_menu() {
|
addons_menu() {
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
|
# KIOSK_OFFLINE_WAIT=N on an ISO built with --offline: seconds to wait
|
||||||
# for internet before installing from the bundled
|
# for internet before installing from the bundled
|
||||||
# packages instead (default 90).
|
# packages instead (default 90).
|
||||||
|
# KIOSK_STALL_TIMEOUT=N on an --offline ISO, unattended: if the install
|
||||||
|
# prints nothing for N seconds (a stalled download on
|
||||||
|
# a flaky link), stop it and finish from the bundled
|
||||||
|
# packages instead (default 600).
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -40,6 +44,7 @@ OFFLINE_FLAG="$STATE_DIR/offline-mode"
|
|||||||
|
|
||||||
KIOSK_UNATTENDED=0
|
KIOSK_UNATTENDED=0
|
||||||
KIOSK_OFFLINE_WAIT=90
|
KIOSK_OFFLINE_WAIT=90
|
||||||
|
KIOSK_STALL_TIMEOUT=600
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
[[ -f "$CONF" ]] && source "$CONF"
|
[[ -f "$CONF" ]] && source "$CONF"
|
||||||
|
|
||||||
@@ -61,6 +66,21 @@ if [[ "${1:-}" == "--cleanup" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
have_bundle() { [[ -f "$OFFLINE_BUNDLE/apt/Packages" ]]; }
|
||||||
|
|
||||||
|
# Finish this install from the bundled packages instead of the internet
|
||||||
|
# (cleanup() turns it back off). install.sh notices via lib/config.sh's
|
||||||
|
# offline_mode_active.
|
||||||
|
switch_to_offline() {
|
||||||
|
touch "$OFFLINE_FLAG"
|
||||||
|
if ! "$OFFLINE_APT" on; then
|
||||||
|
echo "ERROR: couldn't switch apt to the offline bundle."
|
||||||
|
rm -f "$OFFLINE_FLAG"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
|
# Make sure tty1 gets its login prompt back. getty@tty1 is ordered after
|
||||||
# this unit and normally starts by itself once we exit; this just covers
|
# this unit and normally starts by itself once we exit; this just covers
|
||||||
# a getty that was stopped or never queued (e.g. started by hand later).
|
# a getty that was stopped or never queued (e.g. started by hand later).
|
||||||
@@ -83,13 +103,15 @@ banner() {
|
|||||||
# drop to a shell as the admin user to fix it, or give up until next boot.
|
# drop to a shell as the admin user to fix it, or give up until next boot.
|
||||||
# Returns 0 to retry, 1 to give up.
|
# Returns 0 to retry, 1 to give up.
|
||||||
offer_retry() {
|
offer_retry() {
|
||||||
local choice
|
local choice offline_opt=""
|
||||||
|
have_bundle && [[ ! -f "$OFFLINE_FLAG" ]] && offline_opt=" [O] Install from the offline bundle on this disk"
|
||||||
while true; do
|
while true; do
|
||||||
echo
|
echo
|
||||||
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt"
|
echo " [R] Retry [S] Shell (as $ADMIN_USER, exit to come back) [Q] Quit to login prompt$offline_opt"
|
||||||
read -r -p "Choice [R]: " choice
|
read -r -p "Choice [R]: " choice
|
||||||
case "${choice,,}" in
|
case "${choice,,}" in
|
||||||
""|r) return 0 ;;
|
""|r) return 0 ;;
|
||||||
|
o) [[ -n "$offline_opt" ]] && switch_to_offline && return 0 ;;
|
||||||
s) runuser -l "$ADMIN_USER" ;;
|
s) runuser -l "$ADMIN_USER" ;;
|
||||||
q)
|
q)
|
||||||
echo "Setup will run again on next boot, or run it now with:"
|
echo "Setup will run again on next boot, or run it now with:"
|
||||||
@@ -227,6 +249,39 @@ run_install() {
|
|||||||
|
|
||||||
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
|
# runuser rather than sudo -u: sudo would set SUDO_USER=root, which
|
||||||
# config.sh reads as "the admin running this tool".
|
# config.sh reads as "the admin running this tool".
|
||||||
|
# A stalled download on a flaky connection would otherwise hang an
|
||||||
|
# unattended install forever. With the offline bundle as a safety net,
|
||||||
|
# watch the log: no new output for KIOSK_STALL_TIMEOUT seconds means
|
||||||
|
# stuck, so the whole process group is stopped (the caller then
|
||||||
|
# finishes from the bundle).
|
||||||
|
if [[ "$KIOSK_UNATTENDED" == "1" ]] && have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||||
|
touch "$LOG"
|
||||||
|
setsid bash -c '
|
||||||
|
runuser -u "$1" -- env -i "${@:4}" bash -c "cd \"\$1\" && exec ./install.sh" _ "$2" 2>&1 | tee -a "$3"
|
||||||
|
exit "${PIPESTATUS[0]}"
|
||||||
|
' _ "$ADMIN_USER" "$REPO_DEST" "$LOG" "${envs[@]}" &
|
||||||
|
local pid=$!
|
||||||
|
(
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
sleep 20
|
||||||
|
age=$(( $(date +%s) - $(stat -c %Y "$LOG") ))
|
||||||
|
if (( age > KIOSK_STALL_TIMEOUT )); then
|
||||||
|
echo
|
||||||
|
echo "No output for ${age}s - the online install looks stuck; stopping it."
|
||||||
|
kill -TERM -- "-$pid" 2>/dev/null
|
||||||
|
sleep 30
|
||||||
|
kill -KILL -- "-$pid" 2>/dev/null
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
local watcher=$!
|
||||||
|
wait "$pid"
|
||||||
|
local rc=$?
|
||||||
|
kill "$watcher" 2>/dev/null
|
||||||
|
wait "$watcher" 2>/dev/null
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
|
runuser -u "$ADMIN_USER" -- env -i "${envs[@]}" \
|
||||||
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
|
bash -c 'cd "$1" && exec ./install.sh' _ "$REPO_DEST" \
|
||||||
2>&1 | tee -a "$LOG"
|
2>&1 | tee -a "$LOG"
|
||||||
@@ -249,6 +304,15 @@ while true; do
|
|||||||
echo
|
echo
|
||||||
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
|
echo "install.sh did not finish (exit code $rc). Full output: $LOG"
|
||||||
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
|
if [[ "$KIOSK_UNATTENDED" == "1" ]]; then
|
||||||
|
# Online attempt failed or stalled: the bundle on this disk is
|
||||||
|
# always going to work, so use it rather than retry the same thing.
|
||||||
|
if have_bundle && [[ ! -f "$OFFLINE_FLAG" ]]; then
|
||||||
|
echo "Online install didn't finish - continuing from the offline bundle on this disk."
|
||||||
|
if switch_to_offline; then
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if (( attempt >= 3 )); then
|
if (( attempt >= 3 )); then
|
||||||
echo "Giving up after $attempt attempts; will try again next boot."
|
echo "Giving up after $attempt attempts; will try again next boot."
|
||||||
release_tty
|
release_tty
|
||||||
|
|||||||
+188
-20
@@ -1,5 +1,6 @@
|
|||||||
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
|
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
|
||||||
const {exec,spawn}=require('child_process');
|
const {exec,spawn}=require('child_process');
|
||||||
|
const net=require('net');
|
||||||
const fs=require('fs');
|
const fs=require('fs');
|
||||||
const path=require('path');
|
const path=require('path');
|
||||||
const os=require('os');
|
const os=require('os');
|
||||||
@@ -59,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;
|
||||||
@@ -101,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
|
||||||
@@ -121,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);
|
||||||
@@ -763,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();
|
||||||
|
|
||||||
@@ -1144,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();
|
||||||
@@ -1215,7 +1310,56 @@ const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
|
|||||||
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
|
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
|
||||||
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
|
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
|
||||||
|
|
||||||
function runWifiHelper(action,input){
|
const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
|
||||||
|
|
||||||
|
// Parse the helper's one JSON reply line (the last line of its output).
|
||||||
|
function parseWifiReply(out){
|
||||||
|
const line=out.trim().split('\n').pop();
|
||||||
|
return JSON.parse(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
|
||||||
|
// sudo it keeps working when this app has the "no new privileges" flag set.
|
||||||
|
// Resolves null when the socket isn't usable (not installed yet, or this
|
||||||
|
// user isn't allowed), so the caller falls back to sudo.
|
||||||
|
function runWifiHelperSocket(action,input){
|
||||||
|
return new Promise(resolve=>{
|
||||||
|
let out='',done=false;
|
||||||
|
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
|
||||||
|
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
|
||||||
|
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
|
||||||
|
sock.on('connect',()=>{
|
||||||
|
// Request = action line, then the JSON body; half-close so the
|
||||||
|
// helper sees end of input.
|
||||||
|
sock.end(action+'\n'+(input?JSON.stringify(input):''));
|
||||||
|
});
|
||||||
|
sock.on('data',d=>{out+=d;});
|
||||||
|
sock.on('error',e=>{
|
||||||
|
if(out){
|
||||||
|
// A reset after the reply arrived - use the reply.
|
||||||
|
try{finish(parseWifiReply(out));return;}catch(_){}
|
||||||
|
}
|
||||||
|
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
|
||||||
|
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
|
||||||
|
finish(null);
|
||||||
|
}else{
|
||||||
|
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.on('close',()=>{
|
||||||
|
if(done)return;
|
||||||
|
try{
|
||||||
|
finish(parseWifiReply(out));
|
||||||
|
}catch(e){
|
||||||
|
console.error('[WIFI] helper socket output:',out);
|
||||||
|
finish({ok:false,error:'The WiFi helper gave no usable answer'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
|
||||||
|
function runWifiHelperSudo(action,input){
|
||||||
return new Promise(resolve=>{
|
return new Promise(resolve=>{
|
||||||
let out='',err='',done=false;
|
let out='',err='',done=false;
|
||||||
const finish=result=>{if(!done){done=true;resolve(result);}};
|
const finish=result=>{if(!done){done=true;resolve(result);}};
|
||||||
@@ -1232,12 +1376,11 @@ function runWifiHelper(action,input){
|
|||||||
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
|
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
|
||||||
child.on('close',()=>{
|
child.on('close',()=>{
|
||||||
clearTimeout(killer);
|
clearTimeout(killer);
|
||||||
const line=out.trim().split('\n').pop();
|
|
||||||
try{
|
try{
|
||||||
finish(JSON.parse(line));
|
finish(parseWifiReply(out));
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Not our JSON - typically sudo refusing ("a password is required")
|
// Not our JSON - typically sudo refusing ("a password is required",
|
||||||
// on a kiosk set up before this feature: Upgrade installs the rule.
|
// or the "no new privileges" flag) - Upgrade installs the socket.
|
||||||
console.error('[WIFI] helper output:',out,err);
|
console.error('[WIFI] helper output:',out,err);
|
||||||
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
|
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
|
||||||
}
|
}
|
||||||
@@ -1247,6 +1390,12 @@ function runWifiHelper(action,input){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runWifiHelper(action,input){
|
||||||
|
const viaSocket=await runWifiHelperSocket(action,input);
|
||||||
|
if(viaSocket!==null)return viaSocket;
|
||||||
|
return runWifiHelperSudo(action,input);
|
||||||
|
}
|
||||||
|
|
||||||
function resetWifiIdleTimer(){
|
function resetWifiIdleTimer(){
|
||||||
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
|
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
|
||||||
wifiIdleTimer=setTimeout(()=>{
|
wifiIdleTimer=setTimeout(()=>{
|
||||||
@@ -1327,10 +1476,20 @@ ipcMain.handle('wifi-request',async(event,req)=>{
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
|
||||||
if(action==='status'||action==='scan')return runWifiHelper(action);
|
if(action==='ifaces')return runWifiHelper('ifaces');
|
||||||
|
if(action==='status'||action==='scan'){
|
||||||
|
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
|
||||||
|
}
|
||||||
|
if(action==='region-status')return runWifiHelper('region-status');
|
||||||
|
if(action==='region-set'){
|
||||||
|
const input={};
|
||||||
|
for(const k of['country','locale','keyboard'])if(req[k])input[k]=String(req[k]);
|
||||||
|
console.log('[WIFI] Region set:',JSON.stringify(input));
|
||||||
|
return runWifiHelper('region-set',input);
|
||||||
|
}
|
||||||
if(action==='connect'){
|
if(action==='connect'){
|
||||||
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
|
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
|
||||||
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
|
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
|
||||||
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
|
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1399,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()){
|
||||||
@@ -1461,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>');
|
||||||
@@ -1480,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;
|
||||||
|
|
||||||
@@ -1546,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);
|
||||||
@@ -1587,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();});
|
||||||
@@ -1778,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);
|
||||||
|
|||||||
@@ -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=>{
|
||||||
|
|||||||
+117
-9
@@ -26,6 +26,10 @@
|
|||||||
color: #ecf0f1;
|
color: #ecf0f1;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
width: 100%; padding: 14px; font-size: 18px; border: 2px solid #34495e;
|
||||||
|
border-radius: 10px; background: #34495e; color: #ecf0f1;
|
||||||
|
}
|
||||||
input:focus { border-color: #3498db; }
|
input:focus { border-color: #3498db; }
|
||||||
.row { display: flex; gap: 10px; align-items: center; }
|
.row { display: flex; gap: 10px; align-items: center; }
|
||||||
.row input { flex: 1; }
|
.row input { flex: 1; }
|
||||||
@@ -91,7 +95,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="wifi-step" class="hidden">
|
<div id="wifi-step" class="hidden">
|
||||||
<div id="status">Checking…</div>
|
<div id="adapter-row" class="hidden">
|
||||||
|
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
|
||||||
|
<select id="adapter"></select>
|
||||||
|
</div>
|
||||||
|
<div id="status" style="margin-top: 12px">Checking…</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top: 14px">
|
<div class="row" style="margin-top: 14px">
|
||||||
<label style="margin: 0; flex: 1">Networks nearby</label>
|
<label style="margin: 0; flex: 1">Networks nearby</label>
|
||||||
@@ -114,6 +122,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details id="region-panel" class="hidden" style="margin-top: 18px">
|
||||||
|
<summary style="font-size: 17px; cursor: pointer; color: #bdc3c7">🌍 Region (country, locale, keyboard)</summary>
|
||||||
|
<label for="country">WiFi country (2 letters, e.g. US) - needed to join many 5 GHz networks</label>
|
||||||
|
<input id="country" type="text" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
|
||||||
|
<label for="locale">Language / locale</label>
|
||||||
|
<select id="locale"></select>
|
||||||
|
<label for="keyboard">Keyboard layout</label>
|
||||||
|
<select id="keyboard"></select>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" id="region-save">Save region</button>
|
||||||
|
<button id="region-from-locale">Country from locale</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
<div class="hint">Esc closes this screen. Closes by itself after 5 minutes untouched.</div>
|
||||||
|
|
||||||
@@ -121,6 +143,7 @@
|
|||||||
const {ipcRenderer} = require('electron');
|
const {ipcRenderer} = require('electron');
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
|
||||||
|
|
||||||
function request(action, extra) {
|
function request(action, extra) {
|
||||||
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
|
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
|
||||||
@@ -135,7 +158,7 @@
|
|||||||
|
|
||||||
function setBusy(on) {
|
function setBusy(on) {
|
||||||
busy = on;
|
busy = on;
|
||||||
for (const id of ['scan', 'connect', 'pin-ok']) $(id).disabled = on;
|
for (const id of ['scan', 'connect', 'pin-ok', 'region-save']) $(id).disabled = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalBars(dbm) {
|
function signalBars(dbm) {
|
||||||
@@ -145,16 +168,47 @@
|
|||||||
return '▂';
|
return '▂';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adapterLabel(a) {
|
||||||
|
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
|
||||||
|
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the dropdown from the helper's adapter list. Shown only when
|
||||||
|
// there is more than one adapter; the choice sticks across refreshes.
|
||||||
|
function fillAdapters(r) {
|
||||||
|
const row = $('adapter-row');
|
||||||
|
const sel = $('adapter');
|
||||||
|
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
|
||||||
|
const names = r.adapters.map(a => a.name);
|
||||||
|
if (!names.includes(adapter)) adapter = r.selected || names[0];
|
||||||
|
sel.textContent = '';
|
||||||
|
for (const a of r.adapters) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = a.name;
|
||||||
|
o.textContent = adapterLabel(a);
|
||||||
|
if (a.name === adapter) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
row.classList.toggle('hidden', r.adapters.length < 2);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
const s = await request('status');
|
const ad = fillAdapters(await request('ifaces'));
|
||||||
|
const s = await request('status', adapter ? {iface: adapter} : {});
|
||||||
const box = $('status');
|
const box = $('status');
|
||||||
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
|
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
|
||||||
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
|
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
|
||||||
box.textContent = '';
|
box.textContent = '';
|
||||||
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
|
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
|
||||||
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
|
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
|
||||||
|
// Every adapter at a glance, the selected one marked.
|
||||||
|
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
|
||||||
|
} else {
|
||||||
|
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
|
||||||
|
line(`Adapter: ${s.iface}`);
|
||||||
|
}
|
||||||
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
|
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
|
||||||
line(`Adapter: ${s.iface}`);
|
|
||||||
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
|
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +217,7 @@
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
message('Scanning…', 'busy');
|
message('Scanning…', 'busy');
|
||||||
const list = $('networks');
|
const list = $('networks');
|
||||||
const r = await request('scan');
|
const r = await request('scan', adapter ? {iface: adapter} : {});
|
||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
message(r.error, 'error');
|
message(r.error, 'error');
|
||||||
@@ -199,7 +253,7 @@
|
|||||||
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
|
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
|
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
|
||||||
const r = await request('connect', {ssid, password});
|
const r = await request('connect', {ssid, password, iface: adapter});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
|
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
|
||||||
$('password').value = '';
|
$('password').value = '';
|
||||||
@@ -210,6 +264,52 @@
|
|||||||
refreshStatus();
|
refreshStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillSelect(sel, items, current) {
|
||||||
|
sel.textContent = '';
|
||||||
|
const list = items.slice();
|
||||||
|
if (current && !list.includes(current)) list.unshift(current);
|
||||||
|
for (const v of list) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = v; o.textContent = v;
|
||||||
|
if (v === current) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegion() {
|
||||||
|
const r = await request('region-status');
|
||||||
|
if (!r.ok) return;
|
||||||
|
$('country').value = r.country || '';
|
||||||
|
fillSelect($('locale'), r.locales || [], r.locale);
|
||||||
|
fillSelect($('keyboard'), r.keyboards || [], r.keyboard);
|
||||||
|
$('region-panel').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRegion() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
message('Saving region…', 'busy');
|
||||||
|
const r = await request('region-set', {
|
||||||
|
country: $('country').value.trim(),
|
||||||
|
locale: $('locale').value,
|
||||||
|
keyboard: $('keyboard').value,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
message(r.restart ? '✓ Saved. Locale and keyboard apply after a restart; the country applies now.' : '✓ Saved', 'ok');
|
||||||
|
loadRegion();
|
||||||
|
} else {
|
||||||
|
message(r.error || 'Could not save', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// en_US.UTF-8 -> US
|
||||||
|
function countryFromLocale() {
|
||||||
|
const m = /_([A-Z]{2})/.exec($('locale').value);
|
||||||
|
if (m) $('country').value = m[1];
|
||||||
|
else message("That locale doesn't name a country", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
async function unlock() {
|
async function unlock() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -223,13 +323,21 @@
|
|||||||
message('');
|
message('');
|
||||||
$('pin-step').classList.add('hidden');
|
$('pin-step').classList.add('hidden');
|
||||||
$('wifi-step').classList.remove('hidden');
|
$('wifi-step').classList.remove('hidden');
|
||||||
refreshStatus();
|
refreshStatus().then(() => scan());
|
||||||
scan();
|
loadRegion();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('pin-ok').addEventListener('click', unlock);
|
$('pin-ok').addEventListener('click', unlock);
|
||||||
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
|
||||||
$('scan').addEventListener('click', scan);
|
$('scan').addEventListener('click', scan);
|
||||||
|
$('adapter').addEventListener('change', () => {
|
||||||
|
adapter = $('adapter').value;
|
||||||
|
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
|
||||||
|
refreshStatus();
|
||||||
|
scan();
|
||||||
|
});
|
||||||
|
$('region-save').addEventListener('click', saveRegion);
|
||||||
|
$('region-from-locale').addEventListener('click', countryFromLocale);
|
||||||
$('connect').addEventListener('click', connect);
|
$('connect').addEventListener('click', connect);
|
||||||
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
$('password').addEventListener('keydown', e => { if (e.key === 'Enter') connect(); });
|
||||||
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
$('ssid').addEventListener('keydown', e => { if (e.key === 'Enter') $('password').focus(); });
|
||||||
|
|||||||
+20
-2
@@ -42,6 +42,11 @@
|
|||||||
# other addon's own scripts.
|
# other addon's own scripts.
|
||||||
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
: "${WEBUI_HELPER_PATH:=$BIN_DIR/kiosk-webui-helper}"
|
||||||
|
|
||||||
|
# Library Catalog addon (menus/addon_library.sh): the Koha + Quick Add Docker
|
||||||
|
# stack is deployed here from this checkout's library/ directory. Root-owned
|
||||||
|
# on purpose - it holds the generated database/admin passwords in .env.
|
||||||
|
: "${LIBRARY_DIR:=/opt/kiosk-library}"
|
||||||
|
|
||||||
# Offline install bundle - only present on systems installed from an ISO
|
# Offline install bundle - only present on systems installed from an ISO
|
||||||
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
# built with `iso/build-iso.sh --offline`: a local apt repository (every
|
||||||
# package provisioning and the CUPS addon install) plus prebuilt
|
# package provisioning and the CUPS addon install) plus prebuilt
|
||||||
@@ -74,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
|
||||||
@@ -198,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"
|
||||||
|
|
||||||
@@ -208,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"
|
||||||
|
|
||||||
@@ -258,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"
|
||||||
|
|
||||||
@@ -296,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}" \
|
||||||
@@ -309,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
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# lib/docker.sh - Docker / Compose tooling shared by every addon that runs a
|
||||||
|
# containerised service (the Library Catalog addon is the first).
|
||||||
|
#
|
||||||
|
# Everything here goes through `sudo docker ...`: the kiosk admin runs
|
||||||
|
# install.sh as a regular user and is deliberately NOT put in the `docker`
|
||||||
|
# group (docker-group membership is root-equivalent).
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh (log_* helpers) being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Overridable so tests can substitute stubs for the real binaries.
|
||||||
|
: "${DOCKER_BIN:=docker}"
|
||||||
|
|
||||||
|
docker_is_installed() {
|
||||||
|
command -v "$DOCKER_BIN" >/dev/null 2>&1 \
|
||||||
|
&& sudo "$DOCKER_BIN" compose version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
docker_daemon_running() {
|
||||||
|
sudo "$DOCKER_BIN" info >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install Docker Engine + the Compose plugin if they are missing.
|
||||||
|
#
|
||||||
|
# Ubuntu 24.04+ ships both in its own archive (docker.io, docker-compose-v2):
|
||||||
|
# preferred because it also works through the offline apt bundle's mirror and
|
||||||
|
# gets security updates with everything else. On releases without
|
||||||
|
# docker-compose-v2 (22.04), fall back to Docker's official install script.
|
||||||
|
docker_ensure_installed() {
|
||||||
|
if docker_is_installed; then
|
||||||
|
docker_daemon_running || sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
log_success "Docker $(sudo "$DOCKER_BIN" --version 2>/dev/null | awk '{print $3}' | tr -d ,) is installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installing Docker..."
|
||||||
|
if ! sudo apt-get update -qq; then
|
||||||
|
log_error "apt update failed - check the network/package sources and try again"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 \
|
||||||
|
|| { log_error "Installing docker.io / docker-compose-v2 failed"; return 1; }
|
||||||
|
else
|
||||||
|
log_info "This Ubuntu release has no docker-compose-v2 package - using Docker's install script"
|
||||||
|
have_internet 2>/dev/null || { log_error "No internet connection - can't download Docker"; return 1; }
|
||||||
|
local script
|
||||||
|
script=$(mktemp)
|
||||||
|
if ! curl -fsSL https://get.docker.com -o "$script"; then
|
||||||
|
rm -f "$script"
|
||||||
|
log_error "Could not download Docker's install script"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo sh "$script" || { rm -f "$script"; log_error "Docker's install script failed"; return 1; }
|
||||||
|
rm -f "$script"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
if ! docker_is_installed || ! docker_daemon_running; then
|
||||||
|
log_error "Docker was installed but is not working - check: sudo systemctl status docker"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_success "Docker installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_compose DIR args... -> sudo docker compose --project-directory DIR args...
|
||||||
|
docker_compose() {
|
||||||
|
local dir="$1"; shift
|
||||||
|
sudo "$DOCKER_BIN" compose --project-directory "$dir" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_container_state NAME -> running | exited | restarting | ... | missing
|
||||||
|
docker_container_state() {
|
||||||
|
sudo "$DOCKER_BIN" inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_container_health NAME -> healthy | unhealthy | starting | none | missing
|
||||||
|
docker_container_health() {
|
||||||
|
sudo "$DOCKER_BIN" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$1" 2>/dev/null \
|
||||||
|
|| echo "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_wait_http URL [TIMEOUT_SECONDS]
|
||||||
|
# Polls until URL answers 2xx/3xx; returns 1 on timeout.
|
||||||
|
docker_wait_http() {
|
||||||
|
local url="$1" timeout="${2:-300}" waited=0
|
||||||
|
while (( waited < timeout )); do
|
||||||
|
if curl -fsS -o /dev/null -m 5 "$url" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited + 5))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# docker_free_port START -> prints the first TCP port >= START nothing listens on.
|
||||||
|
docker_free_port() {
|
||||||
|
local port="$1"
|
||||||
|
while ss -tlnH "sport = :${port}" 2>/dev/null | grep -q .; do
|
||||||
|
port=$((port + 1))
|
||||||
|
done
|
||||||
|
echo "$port"
|
||||||
|
}
|
||||||
+31
-5
@@ -127,7 +127,7 @@ provision_install_nodejs() {
|
|||||||
# The bundle carries NodeSource's own nodejs package.
|
# The bundle carries NodeSource's own nodejs package.
|
||||||
sudo apt install -y nodejs
|
sudo apt install -y nodejs
|
||||||
else
|
else
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
curl -fsSL --connect-timeout 20 --max-time 300 https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
sudo apt install -y nodejs
|
sudo apt install -y nodejs
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -198,7 +198,12 @@ provision_configure_display() {
|
|||||||
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
|
# a fresh kiosk ships with; menus/advanced_virtual_consoles.sh can
|
||||||
# flip this later, and does below if declined at the end of setup.
|
# flip this later, and does below if declined at the end of setup.
|
||||||
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
|
provision_install_file "etc/X11/xorg.conf.d/99-finger-libinput.conf" /etc/X11/xorg.conf.d/99-finger-libinput.conf
|
||||||
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
# Only on first install: this file is also where Advanced -> Virtual
|
||||||
|
# Consoles records the admin's choice, and Upgrade re-runs this step -
|
||||||
|
# overwriting it would silently turn consoles back off (or on).
|
||||||
|
if [[ ! -f /etc/X11/xorg.conf.d/10-serverflags.conf ]]; then
|
||||||
|
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||||
|
fi
|
||||||
|
|
||||||
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
|
provision_install_file "usr/local/bin/kiosk-mirror-display.sh" /usr/local/bin/kiosk-mirror-display.sh 755
|
||||||
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
|
provision_install_file "usr/local/bin/kiosk-audio-route.sh" /usr/local/bin/kiosk-audio-route.sh 755
|
||||||
@@ -242,6 +247,23 @@ provision_install_wifi_helper() {
|
|||||||
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
|
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
|
|
||||||
|
# The same helper through a root-owned socket, which is what the kiosk
|
||||||
|
# app uses first: it can end up running with the "no new privileges"
|
||||||
|
# flag, and sudo then refuses to run as root. The sudoers rule above
|
||||||
|
# stays as a fallback. Socket access is limited to the kiosk user's
|
||||||
|
# group (0660) - the same reach as the sudoers rule.
|
||||||
|
provision_install_file "usr/local/bin/kiosk-wifi-helper-socket" "$BIN_DIR/kiosk-wifi-helper-socket" 755
|
||||||
|
provision_install_file "etc/systemd/system/kiosk-wifi-helper@.service" "$SYSTEMD_DIR/kiosk-wifi-helper@.service"
|
||||||
|
provision_install_file "etc/systemd/system/kiosk-wifi-helper.socket" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
|
||||||
|
local kiosk_group
|
||||||
|
kiosk_group=$(id -gn "$KIOSK_USER" 2>/dev/null || echo "$KIOSK_USER")
|
||||||
|
sudo sed -i "s/@KIOSK_GROUP@/${kiosk_group}/" "$SYSTEMD_DIR/kiosk-wifi-helper.socket"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
if ! sudo systemctl enable kiosk-wifi-helper.socket &>/dev/null || ! sudo systemctl restart kiosk-wifi-helper.socket; then
|
||||||
|
log_warning "Couldn't start the kiosk WiFi helper socket - the WiFi screen will fall back to sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
|
# Flaky cheap dongles (RTL8188CUS...) need WiFi power saving off.
|
||||||
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
|
provision_install_file "etc/udev/rules.d/70-kiosk-wifi-power.rules" /etc/udev/rules.d/70-kiosk-wifi-power.rules
|
||||||
sudo udevadm control --reload-rules 2>/dev/null || true
|
sudo udevadm control --reload-rules 2>/dev/null || true
|
||||||
@@ -306,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
|
||||||
@@ -349,6 +371,10 @@ provision_configure_kiosk_settings() {
|
|||||||
log_info "Configure later: Advanced -> Emergency Hotspot"
|
log_info "Configure later: Advanced -> Emergency Hotspot"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${KIOSK_UNATTENDED:-0}" != "1" ]]; then
|
||||||
|
ssh_first_time_setup
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
|
echo "Virtual consoles (Ctrl+Alt+F1-F8) are ENABLED by default."
|
||||||
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
|
if ! ask_yes_no "Keep virtual consoles enabled?" "y"; then
|
||||||
@@ -398,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"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Generated by install.sh - contains passwords
|
||||||
|
.env
|
||||||
|
# Extra CA certificates dropped in for building behind a proxy
|
||||||
|
koha/certs/*.crt
|
||||||
|
quickadd/certs/*.crt
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It installs Docker if it is missing, generates the passwords, builds the images,
|
||||||
|
starts everything and prints the addresses and your login. Running it again is safe;
|
||||||
|
it never overwrites your `.env` (passwords) or your data.
|
||||||
|
|
||||||
|
| What | Address | Login |
|
||||||
|
|---|---|---|
|
||||||
|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8081` | `admin` and the generated password |
|
||||||
|
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8081/add/` | same login (asked once more) |
|
||||||
|
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8080` | none |
|
||||||
|
|
||||||
|
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
|
||||||
|
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
|
||||||
|
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
|
||||||
|
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
|
||||||
|
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
|
||||||
|
|
||||||
|
Ports move up to the next free number if one is taken; the final addresses are printed
|
||||||
|
at the end and by `sudo ./install.sh --status`. 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
|
||||||
|
|
||||||
|
* **Koha**: the open-source library system, built from the Koha project's own Debian
|
||||||
|
packages (no third-party Koha images). Installed and configured automatically; there is
|
||||||
|
no web installer to click through.
|
||||||
|
* **Quick Add** (`quickadd/`): a small page that turns "scan an ISBN" into "book added".
|
||||||
|
* **MariaDB**: the database.
|
||||||
|
|
||||||
|
Needs about 2 GB of RAM and 3 GB of disk. The first build takes 5-10 minutes.
|
||||||
|
|
||||||
|
## Adding books
|
||||||
|
|
||||||
|
Open **Add books** and scan (or type) an ISBN, then Enter. A USB or Bluetooth barcode
|
||||||
|
scanner works as a keyboard, so just scan.
|
||||||
|
|
||||||
|
1. The ISBN is looked up on the Library of Congress over **Z39.50**. If that server cannot
|
||||||
|
be reached (many networks block port 210), **Open Library** over HTTPS is used instead.
|
||||||
|
2. If you already have the title, you are asked whether to add **another copy**.
|
||||||
|
3. Otherwise the record is added with a barcode (see **Barcodes** below), the Dewey (or LC) call
|
||||||
|
number from the record, the item type chosen at the top (Book, Movie, Music, Game) and the shelf
|
||||||
|
you picked.
|
||||||
|
4. Nothing found? A short form lets you type the title and it is added anyway.
|
||||||
|
5. **Undo** removes an item (and the record, if it was the only copy). Freed barcode numbers
|
||||||
|
are never reused, because a label may already be printed.
|
||||||
|
|
||||||
|
DVDs and CDs work by scanning the **UPC** under the barcode; they are matched against what you
|
||||||
|
own the same way ISBNs are. Few UPCs are in the free catalogues, so expect to type a title
|
||||||
|
for many of them.
|
||||||
|
|
||||||
|
The page beeps: high for added, double for a duplicate, low for not found. You can scan a
|
||||||
|
stack of books without looking at the screen.
|
||||||
|
|
||||||
|
### Scanning with a phone camera
|
||||||
|
|
||||||
|
Open the Add books page in your phone's browser and tap **Scan with camera**. Your phone's own camera app
|
||||||
|
opens; take a photo with the barcode filling the frame, and the book is looked up and added just as if you had
|
||||||
|
typed it. In "scan a pre-printed sticker" mode the same button appears on the sticker step.
|
||||||
|
|
||||||
|
* It works over plain HTTP on both iPhone and Android: the photo is taken by the phone's camera app, shrunk on
|
||||||
|
the phone, and read on the server (`zbarimg`). Only JPEG and PNG photos are accepted.
|
||||||
|
* It reads ISBN-13 and ISBN-10 barcodes, and UPC/EAN codes on discs and games.
|
||||||
|
* Tips: get close so the barcode fills most of the frame, keep it flat and well lit, and hold steady. If it
|
||||||
|
can't read the photo you are told, and nothing is added.
|
||||||
|
* A live viewfinder that reads continuously would need HTTPS (browsers only allow that for secure pages); a
|
||||||
|
Bluetooth scanner paired to the phone also works, since it types into the page.
|
||||||
|
|
||||||
|
## Barcodes: your choice
|
||||||
|
|
||||||
|
Barcodes are optional. Open **Barcodes and settings** at the bottom of the Add books page:
|
||||||
|
|
||||||
|
| Choice | What happens |
|
||||||
|
|---|---|
|
||||||
|
| **Number them automatically** (default) | Each new item gets the next number, zero-padded to 7 digits (`0000042`). |
|
||||||
|
| **I stick a pre-printed barcode on each one** | After the ISBN is recognised you scan the sticker on the book. A sticker that is already in use is refused. |
|
||||||
|
| **No barcodes for now** | Items are added without one; the catalogue still shows them. |
|
||||||
|
|
||||||
|
Later, in the same panel:
|
||||||
|
|
||||||
|
* **Give them barcodes** numbers every item that has none, in one go, and shows the range to print.
|
||||||
|
* **Print a sheet of barcodes ahead of time** reserves a block (say 80 numbers), and the link opens
|
||||||
|
Koha's label printer with that range filled in. Print the sheet, and stick the labels on whenever you like.
|
||||||
|
Reserved numbers are never handed out again.
|
||||||
|
|
||||||
|
Good to know:
|
||||||
|
|
||||||
|
* Koha's range printer needs both ends to be the same length, so barcodes are fixed-width digits. They are
|
||||||
|
**at least 7 digits** on purpose: 6-digit all-number Code 39 barcodes can be misread as the pharmacy
|
||||||
|
"Code 32" format by some scanners (checked with a decoder: 7 digits read back perfectly, 6 did not).
|
||||||
|
* Prefixes (such as `HOME-0042`) are not offered: Koha's range printer cannot print them.
|
||||||
|
* Barcode types Koha can print: Code 39 (default, read by every scanner), Code 39 with a Modulo-43 or
|
||||||
|
Modulo-10 check digit (needs a scanner set up for check digits), EAN-13 (exactly 12-13 digits),
|
||||||
|
COOP 2 of 5 and Industrial 2 of 5. Change it under *Cataloging → Label creator → Label layouts*.
|
||||||
|
* Koha's own automatic barcode is switched off so there is one numbering scheme. Items added in the staff
|
||||||
|
interface have no barcode until you press **Give them barcodes**.
|
||||||
|
|
||||||
|
## Printing labels
|
||||||
|
|
||||||
|
From the Add books page, **Print labels** opens Koha's label printer with the barcode range of what you
|
||||||
|
just added filled in (you may be asked to log in to the staff interface). Pick a template and layout
|
||||||
|
and print at 100% scale (not "fit to page") so the labels line up.
|
||||||
|
|
||||||
|
| Template | Sheet |
|
||||||
|
|---|---|
|
||||||
|
| Avery 5160, 1 x 2-5/8 | 30 per sheet; spine label + barcode |
|
||||||
|
| Avery 5167, 1/2 x 1-3/4 | 80 per sheet; barcode only |
|
||||||
|
| Demco spine, 1 x 1.5 | 50 per sheet; call number on up to four lines |
|
||||||
|
|
||||||
|
| Layout | Prints |
|
||||||
|
|---|---|
|
||||||
|
| Spine + barcode | call number (split over lines) with the barcode below |
|
||||||
|
| Spine - call number | call number only |
|
||||||
|
| Barcode only | barcode |
|
||||||
|
|
||||||
|
## People and lending
|
||||||
|
|
||||||
|
Lending is set up for a home library: 3-week loans, up to 25 items, 3 renewals, **no fines**.
|
||||||
|
Change it under *Administration → Circulation and fines rules*.
|
||||||
|
|
||||||
|
1. **Add a person**: staff interface → *Patrons → New patron → Friend / borrower*. Only the name is
|
||||||
|
required; the **library card number is assigned automatically** (`1000001`, `1000002`, ...). Numbers
|
||||||
|
start well away from item barcodes so a scan can never mean both.
|
||||||
|
2. **Print the card**: *Tools → Patron card creator → New batch*, add the patron's number, *Export card
|
||||||
|
batch*, template **Avery 5371**, layout **Library card**. The card carries the name and a scannable barcode.
|
||||||
|
3. **Check out**: *Circulation → Check out*. Scan the person's card, then scan each book. The due date appears
|
||||||
|
on screen.
|
||||||
|
4. **Check in**: *Circulation → Check in*, scan the book.
|
||||||
|
|
||||||
|
A USB or Bluetooth scanner types the number and presses Enter, so it works in all of these boxes.
|
||||||
|
|
||||||
|
## Finding the books
|
||||||
|
|
||||||
|
The public catalogue searches title, author, subject and ISBN, and shows each copy's
|
||||||
|
availability, call number and **shelving location**.
|
||||||
|
|
||||||
|
On the Add books page, pick the **Shelf / room** before you scan; every book you add
|
||||||
|
gets it. The choice is saved on the server, so every device shares it. If all your books are in one
|
||||||
|
place, leave it on *(not set / all in one place)*; nothing is shown and nothing needs changing. Change it
|
||||||
|
whenever you move to another shelf.
|
||||||
|
The list comes from the staff interface (*Administration → Authorized values → LOC*), which
|
||||||
|
starts with a few rooms such as Fiction, General stacks and Reference. Add your own there,
|
||||||
|
for example "Living room - bookcase 2". (Koha has no way to add a room from the Add books page.)
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install.sh --status # addresses, login, container health
|
||||||
|
sudo ./install.sh --update # rebuild with the latest Koha release (your data is kept)
|
||||||
|
sudo ./install.sh --stop # stop everything
|
||||||
|
sudo ./install.sh --start # start again
|
||||||
|
sudo docker logs -f library-koha
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings live in `.env` (created on first run, mode 600). Useful ones:
|
||||||
|
|
||||||
|
| Setting | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `BIND_ADDR` | `0.0.0.0` (default) reachable from the network, `127.0.0.1` this machine only. Docker bypasses `ufw`, so this is the setting that actually limits access |
|
||||||
|
| `OPAC_PORT`, `STAFF_PORT`, `QUICKADD_PORT` | published ports |
|
||||||
|
| `QUICKADD_SOURCES` | lookup order, e.g. `z3950:lx2.loc.gov:210/LCDB,openlibrary`. Add more `z3950:host:port/database` entries |
|
||||||
|
| `LIBRARY_NAME` | name shown on the catalogue (first install only) |
|
||||||
|
|
||||||
|
After editing `.env` run `sudo docker compose up -d`.
|
||||||
|
|
||||||
|
## Behind a proxy or on a locked-down network
|
||||||
|
|
||||||
|
* Building behind a TLS-inspecting proxy: `sudo EXTRA_CA=/path/to/ca.crt ./install.sh`.
|
||||||
|
* Docker Hub limits anonymous pulls (HTTP 429). The installer retries with a growing pause.
|
||||||
|
* Z39.50 needs outbound TCP port 210. Without it, Open Library is used automatically.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Your books live in two Docker volumes: `library_db-data` (the catalogue) and
|
||||||
|
`library_koha-data` (search index, uploads). Back up the database with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker exec library-db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction koha_library' > library-backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
The search index is rebuilt automatically if it is lost.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
* The staff interface and Quick Add are protected by the generated password, over plain HTTP.
|
||||||
|
Keep them on a trusted network, or set `BIND_ADDR=127.0.0.1` and put a reverse proxy with
|
||||||
|
HTTPS in front.
|
||||||
|
* Quick Add uses the staff `admin` login to talk to Koha's API.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
install.sh one-command setup and --status/--update/--stop/--start
|
||||||
|
docker-compose.yml db + koha + quickadd
|
||||||
|
koha/ Dockerfile (Koha from the project's Debian repo), entrypoint,
|
||||||
|
bootstrap.pl (headless install), seed/ (item types, Z39.50
|
||||||
|
servers, label templates/layouts)
|
||||||
|
quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Signing in once
|
||||||
|
|
||||||
|
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: library
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
container_name: library-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
MARIADB_DATABASE: koha_library
|
||||||
|
MARIADB_USER: koha_library
|
||||||
|
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=128M"]
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
koha:
|
||||||
|
build:
|
||||||
|
context: ./koha
|
||||||
|
args:
|
||||||
|
KOHA_SUITE: ${KOHA_SUITE:-stable}
|
||||||
|
image: library-koha:local
|
||||||
|
container_name: library-koha
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
quickadd:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||||
|
KOHA_SSO_SECRET: ${SSO_SECRET:-}
|
||||||
|
KOHA_DB_HOST: db
|
||||||
|
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||||
|
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
KOHA_ADMIN_PASS: ${ADMIN_PASSWORD}
|
||||||
|
KOHA_LIBRARY_NAME: ${LIBRARY_NAME:-My Library}
|
||||||
|
KOHA_LIBRARY_CODE: ${LIBRARY_CODE:-HOME}
|
||||||
|
KOHA_SHELVES: ${SHELVES:-}
|
||||||
|
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||||
|
ports:
|
||||||
|
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
|
||||||
|
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
||||||
|
volumes:
|
||||||
|
- koha-data:/var/lib/koha
|
||||||
|
|
||||||
|
# Scan an ISBN -> the book is added (Z39.50 lookup, then Koha's REST API).
|
||||||
|
quickadd:
|
||||||
|
build:
|
||||||
|
context: ./quickadd
|
||||||
|
image: library-quickadd:local
|
||||||
|
container_name: library-quickadd
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
KOHA_API: http://koha:8081/api/v1
|
||||||
|
SSO_SECRET: ${SSO_SECRET:-}
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||||
|
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
|
||||||
|
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||||
|
OPAC_PORT: ${OPAC_PORT:-8080}
|
||||||
|
STAFF_PORT: ${STAFF_PORT:-8081}
|
||||||
|
OPAC_URL: ${OPAC_URL:-}
|
||||||
|
STAFF_URL: ${STAFF_URL:-}
|
||||||
|
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
|
||||||
|
ports:
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
|
||||||
|
volumes:
|
||||||
|
- quickadd-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
koha-data:
|
||||||
|
quickadd-data:
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# One-command setup for a self-hosted home library (Koha) on Ubuntu/Debian.
|
||||||
|
#
|
||||||
|
# sudo ./install.sh # install Docker if needed, build, start
|
||||||
|
# sudo ./install.sh --status # show URLs and container health
|
||||||
|
# sudo ./install.sh --update # rebuild with the latest Koha and restart
|
||||||
|
# sudo ./install.sh --stop | --start
|
||||||
|
#
|
||||||
|
# Optional environment variables:
|
||||||
|
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
|
||||||
|
# LIBRARY_CODE=HOME short code for the library (letters/digits)
|
||||||
|
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
|
||||||
|
# ITEM_TYPES="CD=Compact disc" extra item types (first install only)
|
||||||
|
# OPAC_PORT=8080 STAFF_PORT=8081 preferred host ports (next free is used)
|
||||||
|
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$HERE"
|
||||||
|
|
||||||
|
c_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
|
||||||
|
c_ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*"; }
|
||||||
|
c_warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
|
||||||
|
c_err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || { c_err "Run with sudo: sudo $0 $*"; exit 1; }
|
||||||
|
RUN_USER="${SUDO_USER:-root}"
|
||||||
|
|
||||||
|
# --- Docker -------------------------------------------------------------------
|
||||||
|
ensure_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
c_ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) with Compose is installed"
|
||||||
|
else
|
||||||
|
c_info "Installing Docker (official get.docker.com script)..."
|
||||||
|
command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq curl ca-certificates; }
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin
|
||||||
|
c_ok "Docker installed"
|
||||||
|
fi
|
||||||
|
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||||
|
if [ "$RUN_USER" != root ] && ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then
|
||||||
|
usermod -aG docker "$RUN_USER" && c_info "Added $RUN_USER to the docker group (log out/in to use docker without sudo)"
|
||||||
|
fi
|
||||||
|
docker info >/dev/null 2>&1 || { c_err "The Docker daemon is not running."; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
port_in_use() {
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -tlnH "sport = :$1" 2>/dev/null | grep -q .
|
||||||
|
else # minimal systems without iproute2
|
||||||
|
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
free_port() { local p="$1"; while port_in_use "$p"; do p=$((p + 1)); done; echo "$p"; }
|
||||||
|
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$1"; }
|
||||||
|
lan_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
|
dc() { docker compose --project-directory "$HERE" "$@"; }
|
||||||
|
|
||||||
|
# Docker Hub rate-limits anonymous pulls (HTTP 429), which is common on shared
|
||||||
|
# IPs; retry with a growing pause instead of failing the whole install.
|
||||||
|
with_retry() {
|
||||||
|
local n=1 max=5 wait=15
|
||||||
|
until "$@"; do
|
||||||
|
[ "$n" -ge "$max" ] && { c_err "Failed after $max attempts: $*"; return 1; }
|
||||||
|
c_warn "Attempt $n failed (Docker Hub rate limit or network?) - retrying in ${wait}s..."
|
||||||
|
sleep "$wait"; n=$((n + 1)); wait=$((wait * 2))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
[ -f .env ] || { c_err "Not installed yet - run: sudo ./install.sh"; exit 1; }
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
local ip; ip="$(lan_ip)"; ip="${ip:-localhost}"
|
||||||
|
if [ "${BIND_ADDR:-0.0.0.0}" = "127.0.0.1" ]; then
|
||||||
|
ip="localhost"
|
||||||
|
echo " (Limited to this machine: BIND_ADDR=127.0.0.1 in .env)"
|
||||||
|
fi
|
||||||
|
dc ps
|
||||||
|
echo
|
||||||
|
echo " Your library: http://$ip:$STAFF_PORT"
|
||||||
|
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
|
||||||
|
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||||
|
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
|
||||||
|
echo " It opens the Find a book page: search only, it cannot add or change anything."
|
||||||
|
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
|
||||||
|
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--status) show_status; exit 0 ;;
|
||||||
|
--stop) dc stop; exit 0 ;;
|
||||||
|
--start) dc start; exit 0 ;;
|
||||||
|
--update)
|
||||||
|
ensure_docker
|
||||||
|
with_retry dc build --pull --no-cache koha
|
||||||
|
dc up -d
|
||||||
|
show_status; exit 0 ;;
|
||||||
|
"" ) ;;
|
||||||
|
*) c_err "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ensure_docker
|
||||||
|
|
||||||
|
# --- Settings (.env is created once and never overwritten) ------------------------
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
c_info "Generating settings and passwords..."
|
||||||
|
TZ_NOW="$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
|
||||||
|
umask 077
|
||||||
|
cat > .env << ENVEOF
|
||||||
|
# Generated by install.sh - keep this file private.
|
||||||
|
LIBRARY_NAME="${LIBRARY_NAME:-My Library}"
|
||||||
|
LIBRARY_CODE=${LIBRARY_CODE:-HOME}
|
||||||
|
SHELVES="${SHELVES:-}"
|
||||||
|
ITEM_TYPES="${ITEM_TYPES:-}"
|
||||||
|
TZ=${TZ_NOW}
|
||||||
|
OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
|
||||||
|
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
||||||
|
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
||||||
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
|
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
|
||||||
|
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=$(rand 16)
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=$(rand 14)
|
||||||
|
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
|
||||||
|
SSO_SECRET=$(rand 40)
|
||||||
|
DB_ROOT_PASSWORD=$(rand 32)
|
||||||
|
DB_PASSWORD=$(rand 32)
|
||||||
|
KOHA_SUITE=stable
|
||||||
|
ENVEOF
|
||||||
|
umask 022
|
||||||
|
[ "$RUN_USER" = root ] || chown "$RUN_USER" .env
|
||||||
|
else
|
||||||
|
c_info "Re-using existing .env"
|
||||||
|
# Settings added in later versions: append them to an older .env.
|
||||||
|
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||||
|
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
||||||
|
fi
|
||||||
|
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||||
|
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${EXTRA_CA:-}" ]; then
|
||||||
|
cp "$EXTRA_CA" koha/certs/extra-ca.crt
|
||||||
|
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
|
||||||
|
c_info "Trusting extra CA during the build: $EXTRA_CA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Build and start ---------------------------------------------------------------
|
||||||
|
c_info "Building the Koha image (first time: 5-10 minutes, downloads ~1 GB)..."
|
||||||
|
with_retry dc build
|
||||||
|
c_info "Starting..."
|
||||||
|
with_retry dc up -d
|
||||||
|
|
||||||
|
# --- Wait until the site answers ---------------------------------------------------
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
c_info "First start installs the database - waiting until Koha reports healthy..."
|
||||||
|
for i in $(seq 1 180); do
|
||||||
|
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' library-koha 2>/dev/null || echo missing)"
|
||||||
|
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8082}/healthz" 2>/dev/null; then READY=1; break; fi
|
||||||
|
# One restart is tolerated (Docker's restart policy); a crash loop is not.
|
||||||
|
if [ "$(docker inspect -f '{{.RestartCount}}' library-koha 2>/dev/null || echo 99)" -ge 3 ]; then
|
||||||
|
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
|
||||||
|
|
||||||
|
c_ok "Your library is running."
|
||||||
|
echo
|
||||||
|
show_status
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Koha (open-source library system) built from the Koha project's own Debian
|
||||||
|
# packages - no third-party Koha images involved.
|
||||||
|
#
|
||||||
|
# KOHA_SUITE stable (default) | oldstable | unstable
|
||||||
|
# KEEP_TRANSLATIONS no (default) trims ~650 MB of non-English .po files;
|
||||||
|
# set "yes" if you want to install other languages later
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG KOHA_SUITE=stable
|
||||||
|
ARG KEEP_TRANSLATIONS=no
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
LANG=C.UTF-8
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg tzdata
|
||||||
|
|
||||||
|
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||||
|
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||||
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
# Each step stands alone and fails the build on error: a swallowed failure here once produced a
|
||||||
|
# "successful" image with no Koha in it. memcached and cron are named explicitly rather than
|
||||||
|
# trusted to arrive as dependencies of koha-common.
|
||||||
|
RUN curl -fsSL https://debian.koha-community.org/koha/gpg.asc | gpg --dearmor -o /usr/share/keyrings/koha.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/koha.gpg] https://debian.koha-community.org/koha ${KOHA_SUITE} main bookworm" \
|
||||||
|
> /etc/apt/sources.list.d/koha.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends koha-common mariadb-client memcached cron
|
||||||
|
|
||||||
|
RUN a2enmod rewrite headers proxy_http cgi deflate >/dev/null \
|
||||||
|
&& (a2dissite 000-default >/dev/null 2>&1 || true)
|
||||||
|
|
||||||
|
RUN if [ "$KEEP_TRANSLATIONS" != "yes" ]; then rm -rf /usr/share/koha/misc/translator/po; fi \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /usr/share/doc/*
|
||||||
|
|
||||||
|
# Refuse to produce an image that cannot run.
|
||||||
|
RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-schema apache2ctl memcached cron mariadb zebrasrv; do \
|
||||||
|
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
|
||||||
|
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||||
|
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
|
||||||
|
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
|
||||||
|
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
|
||||||
|
|
||||||
|
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||||
|
COPY seed/ /opt/koha-init/seed/
|
||||||
|
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||||
|
|
||||||
|
EXPOSE 80 8081
|
||||||
|
VOLUME ["/var/lib/koha"]
|
||||||
|
HEALTHCHECK --interval=10s --timeout=10s --start-period=300s --retries=6 \
|
||||||
|
CMD test -f /run/koha-ready && curl -fsS -o /dev/null http://127.0.0.1:80/ && curl -fsS -o /dev/null http://127.0.0.1:8081/ || exit 1
|
||||||
|
ENTRYPOINT ["/opt/koha-init/entrypoint.sh"]
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Headless replacement for Koha's web installer + onboarding wizard.
|
||||||
|
# Run as the instance user: koha-shell -c "perl bootstrap.pl" <instance>
|
||||||
|
#
|
||||||
|
# Mirrors what /cgi-bin/koha/installer/install.pl does step by step (so a
|
||||||
|
# future Koha upgrade keeps working), then seeds a home-library setup.
|
||||||
|
use Modern::Perl;
|
||||||
|
use C4::Context;
|
||||||
|
use C4::Installer;
|
||||||
|
use Koha;
|
||||||
|
use Koha::Libraries;
|
||||||
|
use Koha::Patrons;
|
||||||
|
use Koha::Patron::Categories;
|
||||||
|
use Koha::CirculationRules;
|
||||||
|
use Koha::ItemTypes;
|
||||||
|
use Koha::AuthorisedValues;
|
||||||
|
|
||||||
|
$| = 1;
|
||||||
|
my $seed_dir = $ENV{KOHA_SEED_DIR} || '/opt/koha-init/seed';
|
||||||
|
my $admin_pass = $ENV{KOHA_ADMIN_PASS};
|
||||||
|
die "KOHA_ADMIN_PASS not set\n" unless defined $admin_pass && length $admin_pass;
|
||||||
|
my %env = (
|
||||||
|
admin_user => $ENV{KOHA_ADMIN_USER} || 'admin',
|
||||||
|
admin_pass => $admin_pass,
|
||||||
|
library => $ENV{KOHA_LIBRARY_NAME} || 'My Library',
|
||||||
|
code => uc( ( $ENV{KOHA_LIBRARY_CODE} || 'HOME' ) =~ s/[^A-Za-z0-9]//gr ) || 'HOME',
|
||||||
|
);
|
||||||
|
|
||||||
|
my $installer = C4::Installer->new();
|
||||||
|
my $dbh = C4::Context->dbh;
|
||||||
|
my $intranet = C4::Context->config('intranetdir');
|
||||||
|
my $data = "$intranet/installer/data/mysql";
|
||||||
|
|
||||||
|
# Already installed? (the entrypoint checks too; this is a safety net)
|
||||||
|
my ($tables) = $dbh->selectrow_array(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()");
|
||||||
|
if ($tables) { say "bootstrap: database already has $tables tables - nothing to do."; exit 0; }
|
||||||
|
|
||||||
|
say "bootstrap: loading database schema...";
|
||||||
|
if ( my $err = $installer->load_db_schema() ) { die "schema load failed: $err\n"; }
|
||||||
|
|
||||||
|
$installer->set_marcflavour_syspref('marc21');
|
||||||
|
|
||||||
|
# Same ordering rules the web installer applies (sysprefs first, userflags last).
|
||||||
|
my @files = (
|
||||||
|
sort( glob("$data/en/mandatory/*.sql"), glob("$data/en/mandatory/*.yml") ),
|
||||||
|
sort( glob("$data/en/marcflavour/marc21/mandatory/*.yml"), glob("$data/en/marcflavour/marc21/mandatory/*.sql") ),
|
||||||
|
);
|
||||||
|
# Optional data worth having: matching rules, "Fast add" framework (a short
|
||||||
|
# cataloguing form - title/author/ISBN/call number/barcode), MARC-coded values.
|
||||||
|
for my $f (qw(
|
||||||
|
marcflavour/marc21/optional/marc21_default_matching_rules.yml
|
||||||
|
marcflavour/marc21/optional/marc21_sample_fastadd_framework.yml
|
||||||
|
optional/marc21_holdings_coded_values.yml
|
||||||
|
optional/marc21_relatorterms.yml
|
||||||
|
optional/auth_val.yml
|
||||||
|
optional/csv_profiles.yml
|
||||||
|
)) {
|
||||||
|
push @files, "$data/en/$f" if -f "$data/en/$f";
|
||||||
|
}
|
||||||
|
|
||||||
|
say "bootstrap: loading ", scalar(@files), " framework / data files...";
|
||||||
|
my ( undef, $list ) = $installer->load_sql_in_order( 'en', [], @files );
|
||||||
|
for my $lvl (@$list) {
|
||||||
|
for my $f ( @{ $lvl->{fwklist} } ) {
|
||||||
|
say " ! $f->{fwkname}: $f->{error}" if $f->{error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$installer->set_version_syspref();
|
||||||
|
$installer->set_languages_syspref('en');
|
||||||
|
|
||||||
|
# ---- Home-library seed data (YAML in the same format Koha ships) ----------
|
||||||
|
for my $y (sort glob("$seed_dir/*.yml")) {
|
||||||
|
my $err = $installer->load_sql($y);
|
||||||
|
say " seed $y: ", ( $err ? "ERROR $err" : 'ok' );
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Library, patron categories, owner account ----------------------------
|
||||||
|
Koha::Library->new({
|
||||||
|
branchcode => $env{code}, branchname => $env{library},
|
||||||
|
})->store unless Koha::Libraries->find( $env{code} );
|
||||||
|
|
||||||
|
for my $c ( [ 'OWNER', 'Owner / librarian', 'S' ], [ 'FRIEND', 'Friend / borrower', 'A' ] ) {
|
||||||
|
Koha::Patron::Category->new({
|
||||||
|
categorycode => $c->[0], description => $c->[1], category_type => $c->[2],
|
||||||
|
enrolmentperiod => 999, overduenoticerequired => 0,
|
||||||
|
})->store unless Koha::Patron::Categories->find( $c->[0] );
|
||||||
|
}
|
||||||
|
|
||||||
|
my $admin = Koha::Patron->new({
|
||||||
|
cardnumber => '1000000', surname => 'Librarian', userid => $env{admin_user},
|
||||||
|
branchcode => $env{code}, categorycode => 'OWNER', flags => 1, # 1 = superlibrarian
|
||||||
|
})->store;
|
||||||
|
$admin->set_password({ password => $env{admin_pass}, skip_validation => 1 });
|
||||||
|
|
||||||
|
# ---- Make "add a book" need as few decisions as possible -------------------
|
||||||
|
# The item type (942$c) is mandatory in Koha's frameworks; default it to Book so
|
||||||
|
# a Z39.50 import can be saved without touching that field.
|
||||||
|
$dbh->do(q{UPDATE marc_subfield_structure SET defaultvalue = 'BOOK'
|
||||||
|
WHERE tagfield = '942' AND tagsubfield = 'c'});
|
||||||
|
|
||||||
|
# UPC/EAN barcodes (DVDs, CDs, games) live in 024$a, which the stock framework
|
||||||
|
# leaves unmapped - so Koha can neither search nor de-duplicate on them. Map it
|
||||||
|
# to the catalogue's `ean` column (used by the quick-add page's duplicate check).
|
||||||
|
$dbh->do(q{UPDATE marc_subfield_structure SET kohafield = 'biblioitems.ean'
|
||||||
|
WHERE tagfield = '024' AND tagsubfield = 'a' AND (kohafield IS NULL OR kohafield = '')});
|
||||||
|
|
||||||
|
# ---- Extra item types and shelves chosen at install time -------------------
|
||||||
|
# KOHA_ITEM_TYPES / KOHA_SHELVES: "CODE=Description;CODE=Description". Koha has no
|
||||||
|
# API for adding shelves later, so they are declared here (more can be added in
|
||||||
|
# Administration -> Authorized values -> LOC).
|
||||||
|
sub pairs {
|
||||||
|
my ($raw) = @_;
|
||||||
|
return map { my ( $c, $d ) = split /=/, $_, 2; $c =~ s/[^A-Za-z0-9_]//g; ( uc $c, ( $d // $c ) =~ s/^\s+|\s+$//gr ) }
|
||||||
|
grep { /\S/ } split /;/, ( $raw // '' );
|
||||||
|
}
|
||||||
|
{
|
||||||
|
my @t = pairs( $ENV{KOHA_ITEM_TYPES} );
|
||||||
|
while ( my ( $c, $d ) = splice @t, 0, 2 ) {
|
||||||
|
next unless length $c;
|
||||||
|
Koha::ItemType->new( { itemtype => substr( $c, 0, 10 ), description => $d } )->store
|
||||||
|
unless Koha::ItemTypes->find( substr( $c, 0, 10 ) );
|
||||||
|
}
|
||||||
|
my @s = pairs( $ENV{KOHA_SHELVES} );
|
||||||
|
while ( my ( $c, $d ) = splice @s, 0, 2 ) {
|
||||||
|
next unless length $c;
|
||||||
|
Koha::AuthorisedValue->new( { category => 'LOC', authorised_value => substr( $c, 0, 80 ), lib => $d, lib_opac => $d } )->store
|
||||||
|
unless Koha::AuthorisedValues->search( { category => 'LOC', authorised_value => substr( $c, 0, 80 ) } )->count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Lending rules --------------------------------------------------------
|
||||||
|
# A fresh Koha refuses every checkout ("No circulation rule is defined for this
|
||||||
|
# patron and itemtype combination") until a default rule exists. One catch-all
|
||||||
|
# rule for a home library: 3 weeks, 25 items, 3 renewals, no fines.
|
||||||
|
Koha::CirculationRules->set_rules({
|
||||||
|
branchcode => undef, categorycode => undef, itemtype => undef,
|
||||||
|
rules => {
|
||||||
|
issuelength => 21, lengthunit => 'days', maxissueqty => 25, maxonsiteissueqty => '',
|
||||||
|
renewalsallowed => 3, renewalperiod => 21, norenewalbefore => '', noautorenewalbefore => '',
|
||||||
|
auto_renew => 0, no_auto_renewal_after => '', no_auto_renewal_after_hard_limit => '',
|
||||||
|
unseen_renewals_allowed => '',
|
||||||
|
fine => 0, finedays => 0, firstremind => 0, chargeperiod => 0, chargeperiod_charge_at => 0,
|
||||||
|
overduefinescap => '', cap_fine_to_replacement_price => 0, rentaldiscount => 0,
|
||||||
|
maxsuspensiondays => '', suspension_chargeperiod => 1,
|
||||||
|
hardduedate => '', hardduedatecompare => -1,
|
||||||
|
reservesallowed => 5, holds_per_record => 1, holds_per_day => '', onshelfholds => 1,
|
||||||
|
opacitemholds => 'N', decreaseloanholds => '', article_requests => 'no',
|
||||||
|
recalls_allowed => undef, recalls_per_record => undef, on_shelf_recalls => undef,
|
||||||
|
recall_due_date_interval => undef, recall_overdue_fine => undef,
|
||||||
|
recall_shelf_time => undef, holds_pickup_period => undef,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
# ---- Preferences ----------------------------------------------------------
|
||||||
|
my %prefs = (
|
||||||
|
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
||||||
|
autoMemberNum => 1, # new patrons get the next library-card number automatically
|
||||||
|
finesMode => 'off', # a home library does not charge overdue fines
|
||||||
|
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
||||||
|
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
||||||
|
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
|
||||||
|
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||||
|
OPACSearchForTitleIn => '',
|
||||||
|
BiblioDefaultView => 'normal',
|
||||||
|
opacuserlogin => 1,
|
||||||
|
OpacMaintenance => 0,
|
||||||
|
ExpireReservesMaxPickUpDelay => 0,
|
||||||
|
AllowOfflineCirculation => 0,
|
||||||
|
marcflavour => 'MARC21',
|
||||||
|
LibraryName => $env{library},
|
||||||
|
timezone => $ENV{TZ} || 'UTC',
|
||||||
|
);
|
||||||
|
C4::Context->set_preference( $_, $prefs{$_} ) for sort keys %prefs;
|
||||||
|
|
||||||
|
say "bootstrap: done. Staff login: $env{admin_user}";
|
||||||
@@ -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 );
|
||||||
|
}
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Container entrypoint: (re)creates the Koha instance config from environment
|
||||||
|
# variables on every start, installs the database on the first start, then runs
|
||||||
|
# Apache in the foreground. All durable state lives in MariaDB and /var/lib/koha.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INST="${KOHA_INSTANCE:-library}"
|
||||||
|
DB_HOST="${KOHA_DB_HOST:-db}"
|
||||||
|
DB_NAME="${KOHA_DB_NAME:-koha_library}"
|
||||||
|
DB_USER="${KOHA_DB_USER:-koha_library}"
|
||||||
|
: "${KOHA_DB_PASS:?KOHA_DB_PASS is required}"
|
||||||
|
: "${KOHA_ADMIN_PASS:?KOHA_ADMIN_PASS is required}"
|
||||||
|
STAFF_PORT=8081
|
||||||
|
export TZ="${TZ:-UTC}"
|
||||||
|
|
||||||
|
log() { echo "[koha-init] $*"; }
|
||||||
|
|
||||||
|
# Marker the container healthcheck waits for. Before the database exists Koha
|
||||||
|
# serves its web-installer page with HTTP 200, so "port answers" is not "ready".
|
||||||
|
rm -f /run/koha-ready
|
||||||
|
|
||||||
|
# --- 1. memcached (Koha caches sysprefs and sessions in it) -----------------
|
||||||
|
memcached -u memcache -m 64 -d
|
||||||
|
cron
|
||||||
|
|
||||||
|
# --- 2. wait for MariaDB ----------------------------------------------------
|
||||||
|
log "waiting for database at $DB_HOST..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
mariadb -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" -e 'select 1' >/dev/null 2>&1 && break
|
||||||
|
[ "$i" = 120 ] && { log "database never became ready"; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 3. instance config (regenerated each time a container is created) ------
|
||||||
|
if [ ! -d "/etc/koha/sites/$INST" ]; then
|
||||||
|
log "creating Koha instance '$INST'..."
|
||||||
|
sed -i \
|
||||||
|
-e 's|^DOMAIN=.*|DOMAIN=""|' \
|
||||||
|
-e 's|^INTRASUFFIX=.*|INTRASUFFIX=""|' \
|
||||||
|
-e "s|^INTRAPORT=.*|INTRAPORT=\"$STAFF_PORT\"|" \
|
||||||
|
/etc/koha/koha-sites.conf
|
||||||
|
echo "$INST:$DB_USER:$KOHA_DB_PASS:$DB_NAME:$DB_HOST" > /etc/koha/passwd
|
||||||
|
koha-create --use-db --dbhost "$DB_HOST" --timezone "$TZ" \
|
||||||
|
--memcached-servers 127.0.0.1:11211 "$INST" 2>&1 | grep -v AH00558 || true
|
||||||
|
|
||||||
|
# No RabbitMQ in this stack: workers fall back to polling the database.
|
||||||
|
CONF="/etc/koha/sites/$INST/koha-conf.xml"
|
||||||
|
perl -0pi -e 's|\s*<message_broker>.*?</message_broker>||s' "$CONF"
|
||||||
|
|
||||||
|
# Serve both interfaces through Plack (much faster than plain CGI).
|
||||||
|
SITE="/etc/apache2/sites-available/$INST.conf"
|
||||||
|
# The *-plack.conf files add the proxy rules on top of the plain ones (which
|
||||||
|
# still provide static files and rewrites), so enable them in addition.
|
||||||
|
sed -i \
|
||||||
|
-e 's|^#\s*Include /etc/koha/apache-shared-opac-plack.conf| Include /etc/koha/apache-shared-opac-plack.conf|' \
|
||||||
|
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||||
|
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||||
|
|
||||||
|
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
|
||||||
|
# (The staff and public catalogue sites cannot share one address: Koha gives both the
|
||||||
|
# same URL shapes. The staff site is the natural home for a home library.)
|
||||||
|
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
||||||
|
# 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
|
||||||
|
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
||||||
|
# page). Now that the instance exists, swap to plain prefork; Apache runs as the
|
||||||
|
# instance user (Plack does the real work as that user already).
|
||||||
|
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||||
|
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||||
|
sed -i '/AssignUserID/d' "$SITE"
|
||||||
|
sed -i -e "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=$INST-koha/" \
|
||||||
|
-e "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=$INST-koha/" /etc/apache2/envvars
|
||||||
|
grep -q "^Listen $STAFF_PORT\b" /etc/apache2/ports.conf || echo "Listen $STAFF_PORT" >> /etc/apache2/ports.conf
|
||||||
|
grep -q '^ServerName' /etc/apache2/apache2.conf || echo 'ServerName localhost' >> /etc/apache2/apache2.conf
|
||||||
|
fi
|
||||||
|
chown -R "$INST-koha:$INST-koha" "/var/lib/koha/$INST" "/var/log/koha/$INST" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- 4. install or upgrade the database -------------------------------------
|
||||||
|
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||||
|
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||||
|
if [ "$TABLES" = "0" ]; then
|
||||||
|
log "first start: installing Koha (about a minute)..."
|
||||||
|
# (not koha-shell -c: it wraps the command in single quotes of its own)
|
||||||
|
runuser -u "$INST-koha" -- env \
|
||||||
|
KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||||
|
KOHA_ADMIN_USER="${KOHA_ADMIN_USER:-admin}" KOHA_ADMIN_PASS="$KOHA_ADMIN_PASS" \
|
||||||
|
KOHA_LIBRARY_NAME="${KOHA_LIBRARY_NAME:-My Library}" KOHA_LIBRARY_CODE="${KOHA_LIBRARY_CODE:-HOME}" \
|
||||||
|
KOHA_SHELVES="${KOHA_SHELVES:-}" KOHA_ITEM_TYPES="${KOHA_ITEM_TYPES:-}" TZ="$TZ" \
|
||||||
|
perl /opt/koha-init/bootstrap.pl
|
||||||
|
TABLES=$(mariadb -N -h "$DB_HOST" -u "$DB_USER" -p"$KOHA_DB_PASS" "$DB_NAME" \
|
||||||
|
-e "select count(*) from information_schema.tables where table_schema='$DB_NAME'")
|
||||||
|
[ "$TABLES" -gt 0 ] || { log "ERROR: installation did not create the database"; exit 1; }
|
||||||
|
FIRST_RUN=1
|
||||||
|
else
|
||||||
|
log "existing database found - applying any pending upgrades..."
|
||||||
|
koha-upgrade-schema "$INST" || log "WARNING: koha-upgrade-schema reported a problem (see output above)"
|
||||||
|
FIRST_RUN=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
|
||||||
|
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||||
|
perl /opt/koha-init/ensure.pl \
|
||||||
|
|| log "WARNING: ensure.pl reported a problem (see above)"
|
||||||
|
|
||||||
|
# --- 5. background services (their `daemon` processes die with the container,
|
||||||
|
# so they are started explicitly on every start) ------------------------
|
||||||
|
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||||
|
log "building search index..."
|
||||||
|
koha-rebuild-zebra -f -v "$INST" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
for svc in koha-zebra koha-indexer koha-worker koha-plack; do
|
||||||
|
"$svc" --restart "$INST" >/dev/null 2>&1 || "$svc" --start "$INST" >/dev/null 2>&1 \
|
||||||
|
|| log "WARNING: could not start $svc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# koha-create / koha-plack may have started Apache already; run it in the foreground instead.
|
||||||
|
apache2ctl -k stop >/dev/null 2>&1 || true
|
||||||
|
for i in 1 2 3 4 5; do pgrep -x apache2 >/dev/null || break; sleep 1; done
|
||||||
|
rm -f /var/run/apache2/apache2.pid
|
||||||
|
|
||||||
|
touch /run/koha-ready
|
||||||
|
log "ready - library catalogue on :80, staff interface on :$STAFF_PORT"
|
||||||
|
exec apache2ctl -D FOREGROUND
|
||||||
@@ -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 || {} } ],
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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' } );
|
||||||
@@ -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 => "$@" } );
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Item types for a home library: books, movies, music, games"
|
||||||
|
tables:
|
||||||
|
- itemtypes:
|
||||||
|
translatable: [ description ]
|
||||||
|
multiline: []
|
||||||
|
rows:
|
||||||
|
- { itemtype: BOOK, description: "Book", rentalcharge: 0, notforloan: 0, imageurl: "bridge/book.png", summary: "" }
|
||||||
|
- { itemtype: DVD, description: "Movie (DVD/Blu-ray)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/dvd.png", summary: "" }
|
||||||
|
- { itemtype: CD, description: "Music (CD/vinyl)", rentalcharge: 0, notforloan: 0, imageurl: "bridge/sound.png", summary: "" }
|
||||||
|
- { itemtype: GAME, description: "Game", rentalcharge: 0, notforloan: 0, imageurl: "bridge/computer_file.png", summary: "" }
|
||||||
|
- { itemtype: OTHER, description: "Other", rentalcharge: 0, notforloan: 0, imageurl: "bridge/kit.png", summary: "" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Public Z39.50 servers used by Cataloging > Z39.50/SRU search"
|
||||||
|
tables:
|
||||||
|
- z3950servers:
|
||||||
|
translatable: [ servername ]
|
||||||
|
multiline: []
|
||||||
|
rows:
|
||||||
|
- { host: "lx2.loc.gov", port: 210, db: LCDB, userid: "", password: "", servername: "LIBRARY OF CONGRESS", checked: 1, rank: 1, syntax: USMARC, encoding: utf8, recordtype: biblio }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Label sheets and layouts for Tools > Labels (spine labels and barcodes)"
|
||||||
|
tables:
|
||||||
|
- creator_templates:
|
||||||
|
translatable: [ template_desc ]
|
||||||
|
multiline: [ ]
|
||||||
|
rows:
|
||||||
|
# Avery 5160 / 8160 - the common 30-up address sheet; fits a spine label
|
||||||
|
# plus barcode on one label.
|
||||||
|
- template_code: "Avery 5160 | 1 x 2-5/8 (30 per sheet)"
|
||||||
|
template_desc: "3 columns, 10 rows. Spine label with barcode."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 2.63
|
||||||
|
label_height: 1
|
||||||
|
top_text_margin: 0.139
|
||||||
|
left_text_margin: 0.1
|
||||||
|
top_margin: 0.35
|
||||||
|
left_margin: 0.23
|
||||||
|
cols: 3
|
||||||
|
rows: 10
|
||||||
|
col_gap: 0.13
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
# Avery 5167 / 8167 - 80-up small labels, good for barcode-only stickers.
|
||||||
|
- template_code: "Avery 5167 | 1/2 x 1-3/4 (80 per sheet)"
|
||||||
|
template_desc: "4 columns, 20 rows. Barcode-only stickers."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 1.75
|
||||||
|
label_height: 0.5
|
||||||
|
top_text_margin: 0.1
|
||||||
|
left_text_margin: 0.05
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.3
|
||||||
|
cols: 4
|
||||||
|
rows: 20
|
||||||
|
col_gap: 0.3
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
# Demco 1" x 1.5" spine labels (Koha's own sample measurements).
|
||||||
|
- template_code: "Demco spine | 1 x 1.5 (50 per sheet)"
|
||||||
|
template_desc: "5 columns, 10 rows. Dewey / call number on up to four lines."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 1.5
|
||||||
|
label_height: 1
|
||||||
|
top_text_margin: 0.236
|
||||||
|
left_text_margin: 0
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.25
|
||||||
|
cols: 5
|
||||||
|
rows: 10
|
||||||
|
col_gap: 0.0625
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- creator_layouts:
|
||||||
|
translatable: [ layout_name, layout_xml ]
|
||||||
|
multiline: [ layout_xml ]
|
||||||
|
rows:
|
||||||
|
# callnum_split splits "613.2 SMI" onto separate lines the way spine
|
||||||
|
# labels are normally read (Dewey/LC aware).
|
||||||
|
- layout_name: "Spine - call number"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BIB
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TB
|
||||||
|
font_size: 11
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 1
|
||||||
|
text_justify: C
|
||||||
|
format_string: "itemcallnumber"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- layout_name: "Spine + barcode"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BIBBAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TB
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 1
|
||||||
|
text_justify: C
|
||||||
|
format_string: "itemcallnumber"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
|
|
||||||
|
- layout_name: "Barcode only"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TR
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 0
|
||||||
|
text_justify: L
|
||||||
|
format_string: "barcode"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt></opt>"
|
||||||
|
creator: "Labels"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
- "Library card sheet and layout for Tools > Patron card creator"
|
||||||
|
tables:
|
||||||
|
- creator_templates:
|
||||||
|
translatable: [ template_desc ]
|
||||||
|
multiline: [ ]
|
||||||
|
rows:
|
||||||
|
# Avery 5371 / 8871 business-card sheet: 10 cards, 2 across x 5 down.
|
||||||
|
- template_code: "Avery 5371 | 2 x 3-1/2 (10 per sheet)"
|
||||||
|
template_desc: "Library cards: 2 columns, 5 rows."
|
||||||
|
page_width: 8.5
|
||||||
|
page_height: 11
|
||||||
|
label_width: 3.5
|
||||||
|
label_height: 2
|
||||||
|
top_text_margin: 0
|
||||||
|
left_text_margin: 0
|
||||||
|
top_margin: 0.5
|
||||||
|
left_margin: 0.75
|
||||||
|
cols: 2
|
||||||
|
rows: 5
|
||||||
|
col_gap: 0
|
||||||
|
row_gap: 0
|
||||||
|
units: "INCH"
|
||||||
|
creator: "Patroncards"
|
||||||
|
|
||||||
|
- creator_layouts:
|
||||||
|
translatable: [ layout_name, layout_xml ]
|
||||||
|
multiline: [ layout_xml ]
|
||||||
|
rows:
|
||||||
|
# Name and library at the top, card number as a scannable barcode.
|
||||||
|
- layout_name: "Library card"
|
||||||
|
barcode_type: CODE39
|
||||||
|
start_label: 1
|
||||||
|
printing_type: BAR
|
||||||
|
guidebox: 0
|
||||||
|
oblique_title: 0
|
||||||
|
font: TR
|
||||||
|
font_size: 10
|
||||||
|
units: POINT
|
||||||
|
callnum_split: 0
|
||||||
|
text_justify: L
|
||||||
|
format_string: "barcode"
|
||||||
|
layout_xml:
|
||||||
|
- "<opt page_side=\"F\" units=\"POINT\">"
|
||||||
|
- " <text><firstname> <surname></text>"
|
||||||
|
- " <text enable=\"1\" font=\"TB\" font_size=\"14\" llx=\"14\" lly=\"118\" text_alignment=\"L\" />"
|
||||||
|
- " <text>Library card</text>"
|
||||||
|
- " <text enable=\"1\" font=\"TR\" font_size=\"9\" llx=\"14\" lly=\"100\" text_alignment=\"L\" />"
|
||||||
|
- " <barcode print=\"1\" type=\"CODE39\" llx=\"14\" lly=\"30\" height_scale=\"0.012\" width_scale=\"0.6\" text_print=\"1\" />"
|
||||||
|
- "</opt>"
|
||||||
|
creator: "Patroncards"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Quick Add: scan an ISBN, the book is in the catalogue. Standard-library Python
|
||||||
|
# plus yaz (the Z39.50 client). Same Debian base as the Koha image, so it adds
|
||||||
|
# one small layer instead of another base-image pull.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||||
|
# you are behind a TLS-inspecting proxy. Empty by default.
|
||||||
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
|
||||||
|
|
||||||
|
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||||
|
&& mkdir -p /app /data && chown quickadd /data
|
||||||
|
COPY app.py index.html /app/
|
||||||
|
USER quickadd
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 8082
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8082/healthz', timeout=3).status==200 else 1)"
|
||||||
|
CMD ["python3", "/app/app.py"]
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
"""Regression check for photo scanning: generate phone-like photos of real barcodes and make
|
||||||
|
sure the running Quick Add reads them (at the size the page sends, 2400 px).
|
||||||
|
|
||||||
|
python3 -m venv /tmp/qa-venv && /tmp/qa-venv/bin/pip install zxing-cpp pillow
|
||||||
|
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/gen_photos.py
|
||||||
|
PHOTO_DIR=/tmp/qa-photos /tmp/qa-venv/bin/python tests/check_scan.py http://127.0.0.1:8082 admin PASSWORD
|
||||||
|
"""
|
||||||
|
import base64, io, json, os, sys, urllib.request
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
url, user, pw = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
||||||
|
D = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||||
|
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def scan(data, kind):
|
||||||
|
req = urllib.request.Request(f"{url}/api/scan?kind={kind}", data=data, method="POST",
|
||||||
|
headers={"Content-Type": "image/jpeg", "Authorization": auth})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)["codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def shrunk(path, m=2400): # what the page does before uploading
|
||||||
|
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
||||||
|
k = min(1, m / max(im.size))
|
||||||
|
im = im.resize((round(im.width * k), round(im.height * k)), Image.LANCZOS)
|
||||||
|
b = io.BytesIO(); im.save(b, "JPEG", quality=90); return b.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for n in sorted(f for f in os.listdir(D) if f.endswith(".jpg")):
|
||||||
|
base = n[:-4]
|
||||||
|
exp_file = os.path.join(D, base + ".expect")
|
||||||
|
exp = open(exp_file).read() if os.path.exists(exp_file) else None
|
||||||
|
codes = scan(shrunk(os.path.join(D, n)), "sticker" if base.startswith("sticker") else "isbn")
|
||||||
|
ok = (codes[:1] == [exp]) if exp else (codes == [])
|
||||||
|
bad += not ok
|
||||||
|
print(("ok " if ok else "FAIL ") + f"{base:20s} expected {exp!s:14s} got {codes[:2]}")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Generate phone-photo-like test images containing real barcodes."""
|
||||||
|
import random, io, os
|
||||||
|
import zxingcpp
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
|
||||||
|
|
||||||
|
OUT = os.environ.get("PHOTO_DIR", "/tmp/quickadd-photos")
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
random.seed(7)
|
||||||
|
|
||||||
|
|
||||||
|
def barcode(fmt, text, scale=4, height=90):
|
||||||
|
bc = zxingcpp.write_barcode(fmt, text, width=0, height=height, quiet_zone=10)
|
||||||
|
img = Image.fromarray(bc.__array__() if hasattr(bc, "__array__") else bc).convert("RGB")
|
||||||
|
return img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
|
def scene(code_img, w=3024, h=4032, tilt=0, blur=0.0, contrast=1.0, bright=1.0, noise=0, quality=72, exif=None, size_frac=0.45):
|
||||||
|
"""Paste the barcode (on a white label) onto a coloured, busy 'book cover'."""
|
||||||
|
bg = Image.new("RGB", (w, h), (random.randint(20, 90), random.randint(30, 80), random.randint(60, 140)))
|
||||||
|
d = ImageDraw.Draw(bg)
|
||||||
|
for _ in range(60): # clutter: stripes, blobs, text-ish bars
|
||||||
|
x, y = random.randint(0, w), random.randint(0, h)
|
||||||
|
d.rectangle([x, y, x + random.randint(40, 600), y + random.randint(8, 60)],
|
||||||
|
fill=tuple(random.randint(0, 255) for _ in range(3)))
|
||||||
|
label = code_img.copy()
|
||||||
|
k = (w * size_frac) / label.width
|
||||||
|
label = label.resize((int(label.width * k), int(label.height * k)), Image.LANCZOS)
|
||||||
|
pad = Image.new("RGB", (label.width + 60, label.height + 60), "white")
|
||||||
|
pad.paste(label, (30, 30))
|
||||||
|
if tilt:
|
||||||
|
pad = pad.rotate(tilt, expand=True, resample=Image.BICUBIC, fillcolor="white")
|
||||||
|
bg.paste(pad, ((w - pad.width) // 2 + random.randint(-80, 80), (h - pad.height) // 2 + random.randint(-300, 300)))
|
||||||
|
if blur:
|
||||||
|
bg = bg.filter(ImageFilter.GaussianBlur(blur))
|
||||||
|
bg = ImageEnhance.Contrast(bg).enhance(contrast)
|
||||||
|
bg = ImageEnhance.Brightness(bg).enhance(bright)
|
||||||
|
if noise:
|
||||||
|
px = bg.load()
|
||||||
|
for _ in range(noise):
|
||||||
|
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
|
||||||
|
v = random.randint(0, 255); px[x, y] = (v, v, v)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
kw = {"quality": quality}
|
||||||
|
if exif is not None:
|
||||||
|
e = Image.Exif(); e[0x0112] = exif; kw["exif"] = e
|
||||||
|
bg.save(buf, "JPEG", **kw)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
E13 = zxingcpp.BarcodeFormat.EAN13
|
||||||
|
UPCA = zxingcpp.BarcodeFormat.UPCA
|
||||||
|
C39 = zxingcpp.BarcodeFormat.Code39
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
# name: (expected, bytes)
|
||||||
|
"isbn_clean": ("9780345339683", scene(barcode(E13, "9780345339683"))),
|
||||||
|
"isbn_tilt8": ("9780345339683", scene(barcode(E13, "9780345339683"), tilt=8, blur=1.2)),
|
||||||
|
"isbn_tilt_neg12": ("9780441172719", scene(barcode(E13, "9780441172719"), tilt=-12, blur=1.5, contrast=0.8)),
|
||||||
|
"isbn_dim_noisy": ("9780345339683", scene(barcode(E13, "9780345339683"), blur=1.4, contrast=0.7, bright=0.7, noise=60000, quality=55)),
|
||||||
|
"isbn_small": ("9780441172719", scene(barcode(E13, "9780441172719"), size_frac=0.22, blur=1.0)),
|
||||||
|
"isbn_exif_rot90": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(90, expand=True), exif=6)),
|
||||||
|
"isbn_upside_down": ("9780345339683", scene(barcode(E13, "9780345339683").rotate(180, expand=True))),
|
||||||
|
"upc_a": ("036000291452", scene(barcode(UPCA, "036000291452"), tilt=5, blur=1.0)),
|
||||||
|
}
|
||||||
|
for name, (exp, data) in cases.items():
|
||||||
|
open(f"{OUT}/{name}.jpg", "wb").write(data)
|
||||||
|
open(f"{OUT}/{name}.expect", "w").write(exp)
|
||||||
|
|
||||||
|
# sticker (Code 39, 7 digits) and a photo with no barcode at all
|
||||||
|
open(f"{OUT}/sticker_c39.jpg", "wb").write(scene(barcode(C39, "0000042", scale=3), tilt=4, blur=1.0, size_frac=0.5))
|
||||||
|
open(f"{OUT}/sticker_c39.expect", "w").write("0000042")
|
||||||
|
blank = Image.new("RGB", (3024, 4032), (90, 60, 40)); ImageDraw.Draw(blank).rectangle([400, 900, 2600, 1300], fill=(200, 180, 60))
|
||||||
|
b = io.BytesIO(); blank.save(b, "JPEG", quality=80); open(f"{OUT}/no_barcode.jpg", "wb").write(b.getvalue())
|
||||||
|
print("wrote", len(os.listdir(OUT)) // 2, "photos to", OUT)
|
||||||
|
for n in sorted(os.listdir(OUT)):
|
||||||
|
if n.endswith(".jpg"):
|
||||||
|
print(f" {n:22s} {os.path.getsize(os.path.join(OUT, n)) / 1e6:5.1f} MB")
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||||
|
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
|
||||||
|
#
|
||||||
|
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||||
|
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||||
|
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
|
||||||
|
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
|
||||||
|
# - MariaDB.
|
||||||
|
# Docker itself is installed on demand (lib/docker.sh).
|
||||||
|
#
|
||||||
|
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
|
||||||
|
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
|
||||||
|
# all, because Koha's own search permission also allows editing label layouts); the page
|
||||||
|
# list stores that guest login like any other Basic Auth site. The admin login never
|
||||||
|
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
|
||||||
|
#
|
||||||
|
# Whether other devices on the LAN can reach the library site is the "Network access"
|
||||||
|
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
|
||||||
|
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
|
||||||
|
# (BIND_ADDR in library/.env), not by firewall rules.
|
||||||
|
#
|
||||||
|
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Detection / state
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
library_env_get() {
|
||||||
|
sudo grep -m1 "^$1=" "$LIBRARY_DIR/.env" 2>/dev/null | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
library_is_installed() {
|
||||||
|
[[ -f "$LIBRARY_DIR/docker-compose.yml" ]] || return 1
|
||||||
|
[[ "$(docker_container_state library-koha)" != "missing" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
library_is_running() {
|
||||||
|
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The page the kiosk shows: Quick Add's Find a book view on the library site.
|
||||||
|
library_catalogue_url() {
|
||||||
|
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages from earlier versions of this add-on pointed at the public catalogue.
|
||||||
|
library_old_catalogue_url() {
|
||||||
|
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||||
|
}
|
||||||
|
|
||||||
|
# True when the kiosk's page list already shows the catalogue.
|
||||||
|
library_tab_exists() {
|
||||||
|
local url idx
|
||||||
|
url="$(library_catalogue_url)"
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
[[ "${URLS[$idx]}" == "$url" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_status() {
|
||||||
|
if ! library_is_installed; then
|
||||||
|
echo "Library Catalog: not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local state health ip
|
||||||
|
state="$(docker_container_state library-koha)"
|
||||||
|
health="$(docker_container_health library-koha)"
|
||||||
|
ip="$(get_ip_address)"
|
||||||
|
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||||
|
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
|
||||||
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
|
echo " Network: this kiosk only"
|
||||||
|
else
|
||||||
|
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_menu_builder() {
|
||||||
|
if library_is_installed; then
|
||||||
|
load_existing_config
|
||||||
|
local tab_label="Show Find a book on the kiosk"
|
||||||
|
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
|
||||||
|
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||||
|
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||||
|
else
|
||||||
|
MENU_LABELS=("Install Library Catalog")
|
||||||
|
MENU_HANDLERS=(action_library_install)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_library_menu() {
|
||||||
|
run_menu "LIBRARY CATALOG" addon_library_menu_builder addon_library_status
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Helpers
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Copy library/ from this checkout to $LIBRARY_DIR. Never overwrites the
|
||||||
|
# deployed .env (passwords) even if a stray one exists in the checkout.
|
||||||
|
library_sync_files() {
|
||||||
|
local src="${SCRIPT_DIR}/library"
|
||||||
|
if [[ ! -f "$src/docker-compose.yml" ]]; then
|
||||||
|
log_error "library/ not found in this checkout ($src) - re-clone or git pull"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "$LIBRARY_DIR" || return 1
|
||||||
|
tar -C "$src" --exclude=./.env --exclude='*.crt' -cf - . | sudo tar -C "$LIBRARY_DIR" -xf - || return 1
|
||||||
|
sudo chmod 755 "$LIBRARY_DIR/install.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
library_add_tab() {
|
||||||
|
load_existing_config
|
||||||
|
if library_tab_exists; then
|
||||||
|
log_info "Find a book is already one of the kiosk's pages."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
URLS+=("$(library_catalogue_url)")
|
||||||
|
DURS+=("0")
|
||||||
|
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
|
||||||
|
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
|
||||||
|
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drop every page that points at the catalogue's local address.
|
||||||
|
library_remove_tab() {
|
||||||
|
load_existing_config
|
||||||
|
local url old idx
|
||||||
|
url="$(library_catalogue_url)"
|
||||||
|
old="$(library_old_catalogue_url)"
|
||||||
|
local -a nu=() nd=() nus=() np=() nn=()
|
||||||
|
for idx in "${!URLS[@]}"; do
|
||||||
|
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
|
||||||
|
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||||
|
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||||
|
done
|
||||||
|
if [[ "${#nu[@]}" -eq "${#URLS[@]}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
URLS=("${nu[@]}"); DURS=("${nd[@]}"); USERS=("${nus[@]}"); PASSES=("${np[@]}"); NAMES=("${nn[@]}")
|
||||||
|
HOME_TAB_INDEX=-1
|
||||||
|
save_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# library_set_bind ADDR - persist BIND_ADDR in .env and recreate the containers.
|
||||||
|
library_set_bind() {
|
||||||
|
local addr="$1"
|
||||||
|
if sudo grep -q '^BIND_ADDR=' "$LIBRARY_DIR/.env" 2>/dev/null; then
|
||||||
|
sudo sed -i "s|^BIND_ADDR=.*|BIND_ADDR=${addr}|" "$LIBRARY_DIR/.env"
|
||||||
|
else
|
||||||
|
echo "BIND_ADDR=${addr}" | sudo tee -a "$LIBRARY_DIR/.env" >/dev/null
|
||||||
|
fi
|
||||||
|
docker_compose "$LIBRARY_DIR" up -d
|
||||||
|
}
|
||||||
|
|
||||||
|
library_warn_if_low_memory() {
|
||||||
|
local total_mb
|
||||||
|
total_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo 2>/dev/null || echo 0)
|
||||||
|
if [[ "$total_mb" -gt 0 && "$total_mb" -lt 3000 ]]; then
|
||||||
|
log_warning "This machine has about ${total_mb} MB of RAM. The catalogue needs roughly 2 GB on top of the kiosk itself - it may be slow."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Actions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
action_library_install() {
|
||||||
|
echo
|
||||||
|
echo "This installs a library catalogue (Koha) with Z39.50 book lookup, label"
|
||||||
|
echo "printing and a scan-to-add page. It needs Docker (installed for you if"
|
||||||
|
echo "missing), about 3 GB of disk space and ~2 GB of RAM. The first build takes"
|
||||||
|
echo "5-10 minutes."
|
||||||
|
echo
|
||||||
|
library_warn_if_low_memory
|
||||||
|
|
||||||
|
local library_name
|
||||||
|
library_name=$(ask_text "Library name (shown on the catalogue)" "My Library")
|
||||||
|
[[ -z "$library_name" ]] && library_name="My Library"
|
||||||
|
|
||||||
|
local bind="0.0.0.0"
|
||||||
|
echo
|
||||||
|
echo "Who should be able to open the catalogue, staff interface and Quick Add"
|
||||||
|
echo "page besides this kiosk?"
|
||||||
|
echo " - Yes: any device on your network (use it from your own laptop/phone;"
|
||||||
|
echo " the staff pages are protected by a generated password)."
|
||||||
|
echo " - No: only this kiosk. Safest, but you can only manage the library here."
|
||||||
|
if ! ask_yes_no "Allow other devices on the network?" "y"; then
|
||||||
|
bind="127.0.0.1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_ensure_installed || { pause; return 1; }
|
||||||
|
library_sync_files || { pause; return 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Building and starting the stack - this takes a while on the first run..."
|
||||||
|
if ! sudo env LIBRARY_NAME="$library_name" BIND_ADDR="$bind" "$LIBRARY_DIR/install.sh"; then
|
||||||
|
log_error "The library stack failed to start - see: sudo docker logs library-koha"
|
||||||
|
pause
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
|
||||||
|
library_add_tab
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
log_success "Library Catalog installed"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_show_access() {
|
||||||
|
local ip user pass
|
||||||
|
ip="$(get_ip_address)"
|
||||||
|
user="$(library_env_get ADMIN_USER)"
|
||||||
|
pass="$(library_env_get ADMIN_PASSWORD)"
|
||||||
|
echo
|
||||||
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
|
ip="localhost"
|
||||||
|
echo "(Network access is limited to this kiosk.)"
|
||||||
|
fi
|
||||||
|
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
|
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
||||||
|
echo " Admin login: ${user} / ${pass}"
|
||||||
|
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
|
||||||
|
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
|
||||||
|
echo " it cannot add or change anything (and it has no login on the staff site itself)."
|
||||||
|
echo
|
||||||
|
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_toggle_tab() {
|
||||||
|
load_existing_config
|
||||||
|
if library_tab_exists; then
|
||||||
|
library_remove_tab
|
||||||
|
else
|
||||||
|
library_add_tab
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_network() {
|
||||||
|
local current
|
||||||
|
current="$(library_env_get BIND_ADDR)"
|
||||||
|
echo
|
||||||
|
echo "Currently: $([[ "$current" == "127.0.0.1" ]] && echo "this kiosk only" || echo "other devices on the network can connect")"
|
||||||
|
echo
|
||||||
|
if ask_yes_no "Allow other devices on the network?" "$([[ "$current" == "127.0.0.1" ]] && echo n || echo y)"; then
|
||||||
|
library_set_bind "0.0.0.0" && log_success "Other devices on the network can now connect"
|
||||||
|
else
|
||||||
|
library_set_bind "127.0.0.1" && log_success "Only this kiosk can connect now"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_start() {
|
||||||
|
docker_compose "$LIBRARY_DIR" start && log_success "Started"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_stop() {
|
||||||
|
docker_compose "$LIBRARY_DIR" stop && log_success "Stopped (the kiosk page will show an error until you start it again)"
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_update() {
|
||||||
|
echo
|
||||||
|
echo "This rebuilds the images with the latest Koha release. Your catalogue data is kept."
|
||||||
|
if ! ask_yes_no "Update now?" "y"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker_ensure_installed || { pause; return 1; }
|
||||||
|
library_sync_files || { pause; return 1; }
|
||||||
|
if sudo "$LIBRARY_DIR/install.sh" --update; then
|
||||||
|
log_success "Updated"
|
||||||
|
else
|
||||||
|
log_error "Update failed - see: sudo docker logs library-koha"
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
|
|
||||||
|
# library_do_uninstall [purge]
|
||||||
|
# no argument: stop and remove the containers but KEEP the data volumes and
|
||||||
|
# $LIBRARY_DIR/.env - the database password is baked into the
|
||||||
|
# database volume, so a later reinstall needs the same .env.
|
||||||
|
# purge: also delete the data (all books!), the images and $LIBRARY_DIR.
|
||||||
|
library_do_uninstall() {
|
||||||
|
local mode="${1:-}"
|
||||||
|
library_remove_tab
|
||||||
|
if [[ -f "$LIBRARY_DIR/docker-compose.yml" ]]; then
|
||||||
|
if [[ "$mode" == "purge" ]]; then
|
||||||
|
docker_compose "$LIBRARY_DIR" down -v --rmi local 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker_compose "$LIBRARY_DIR" down 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$mode" == "purge" ]]; then
|
||||||
|
sudo rm -rf "$LIBRARY_DIR"
|
||||||
|
log_success "Library Catalog and all its data removed"
|
||||||
|
else
|
||||||
|
log_success "Library Catalog stopped and removed (your books are kept)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_library_uninstall() {
|
||||||
|
echo
|
||||||
|
echo "This stops and removes the catalogue containers."
|
||||||
|
if ask_yes_no "Also DELETE all catalogue data (every book you have added)?" "n"; then
|
||||||
|
local confirm
|
||||||
|
confirm=$(ask_text "Type DELETE to confirm" "")
|
||||||
|
if [[ "$confirm" != "DELETE" ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
pause
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
library_do_uninstall purge
|
||||||
|
else
|
||||||
|
library_do_uninstall
|
||||||
|
echo "Your books are kept; install again from this menu to bring them back."
|
||||||
|
fi
|
||||||
|
pause
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
################################################################################
|
################################################################################
|
||||||
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
|
# menus/addon_remote_access.sh - "Remote Access" addon (VNC, WireGuard,
|
||||||
# Tailscale, Netbird).
|
# Tailscale, Netbird) plus SSH Access (GitHub/Launchpad key import).
|
||||||
#
|
#
|
||||||
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
|
# Third Addon migrated, and the biggest so far in scope (4 sub-areas).
|
||||||
# All four genuinely mutate real system state at fixed paths this project
|
# All four genuinely mutate real system state at fixed paths this project
|
||||||
@@ -31,6 +31,7 @@ VNC_APT_PACKAGES=(x11vnc)
|
|||||||
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
|
WIREGUARD_APT_PACKAGES=(wireguard wireguard-tools)
|
||||||
|
|
||||||
remote_access_status() {
|
remote_access_status() {
|
||||||
|
echo "SSH: $(ssh_status_line)"
|
||||||
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
|
echo "VNC: $(is_service_active x11vnc && echo "running" || echo "not installed")"
|
||||||
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
|
echo "WireGuard: $(wireguard_connected && echo "connected" || (command -v wg &>/dev/null && echo "installed, not connected" || echo "not installed"))"
|
||||||
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
|
echo "Tailscale: $(command -v tailscale &>/dev/null && echo "installed" || echo "not installed")"
|
||||||
@@ -38,8 +39,8 @@ remote_access_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remote_access_menu_builder() {
|
remote_access_menu_builder() {
|
||||||
MENU_LABELS=("VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
MENU_LABELS=("SSH Access (GitHub/Launchpad keys)" "VNC Remote Desktop" "WireGuard VPN" "Tailscale VPN" "Netbird VPN")
|
||||||
MENU_HANDLERS=(vnc_menu wireguard_menu tailscale_menu netbird_menu)
|
MENU_HANDLERS=(ssh_menu vnc_menu wireguard_menu tailscale_menu netbird_menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
remote_access_menu() {
|
remote_access_menu() {
|
||||||
@@ -414,7 +415,7 @@ action_netbird_connect() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo netbird up --setup-key "$setup_key"; then
|
if sudo netbird up --setup-key "$setup_key" --allow-server-ssh; then
|
||||||
log_success "Netbird connected"
|
log_success "Netbird connected"
|
||||||
else
|
else
|
||||||
log_error "Netbird connection failed"
|
log_error "Netbird connection failed"
|
||||||
@@ -438,3 +439,143 @@ netbird_do_uninstall() {
|
|||||||
sudo apt remove -y netbird 2>/dev/null || true
|
sudo apt remove -y netbird 2>/dev/null || true
|
||||||
log_success "Netbird removed"
|
log_success "Netbird removed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# SSH Access - openssh-server plus public keys imported from GitHub or
|
||||||
|
# Launchpad with ssh-import-id (the same "Import SSH identity" choice
|
||||||
|
# Ubuntu's own installer offers). Keys go to the admin user running this
|
||||||
|
# menu (~/.ssh/authorized_keys), never to the restricted `kiosk` user.
|
||||||
|
# The key sets published by those services are fetched over HTTPS and
|
||||||
|
# are public data, so no secret is handled here.
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
SSH_KEYS_ONLY_CONF="${SSHD_CONFIG_DIR:-/etc/ssh/sshd_config.d}/10-kiosk-keys-only.conf"
|
||||||
|
SSH_AUTH_KEYS="${SSH_AUTH_KEYS:-$HOME/.ssh/authorized_keys}"
|
||||||
|
|
||||||
|
ssh_key_count() {
|
||||||
|
[[ -f "$SSH_AUTH_KEYS" ]] && grep -cE '^(ssh-|ecdsa-|sk-)' "$SSH_AUTH_KEYS" || echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_status_line() {
|
||||||
|
local state="not installed"
|
||||||
|
if is_service_active ssh || is_service_active sshd; then
|
||||||
|
state="running"
|
||||||
|
elif dpkg -s openssh-server &>/dev/null; then
|
||||||
|
state="installed, not running"
|
||||||
|
fi
|
||||||
|
local pw="password login on"
|
||||||
|
[[ -f "$SSH_KEYS_ONLY_CONF" ]] && pw="keys only"
|
||||||
|
echo "$state, $(ssh_key_count) key(s), $pw"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_status() {
|
||||||
|
echo "SSH: $(ssh_status_line)"
|
||||||
|
echo " Keys go to: $SSH_AUTH_KEYS"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_menu_builder() {
|
||||||
|
MENU_LABELS=("Enable SSH server" "Import keys from GitHub" "Import keys from Launchpad" "List authorized keys" "Require keys (disable password login)" "Allow password login again")
|
||||||
|
MENU_HANDLERS=(action_ssh_enable action_ssh_import_github action_ssh_import_launchpad action_ssh_list_keys action_ssh_keys_only action_ssh_allow_passwords)
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_menu() {
|
||||||
|
run_menu "SSH ACCESS" ssh_menu_builder ssh_status
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_enable() {
|
||||||
|
echo
|
||||||
|
sudo apt install -y openssh-server ssh-import-id || { log_error "Could not install openssh-server"; return 1; }
|
||||||
|
sudo systemctl enable --now ssh || { log_error "Could not start the SSH server"; return 1; }
|
||||||
|
# provision_configure_firewall already allows ssh; harmless if repeated.
|
||||||
|
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||||
|
sudo ufw allow ssh >/dev/null
|
||||||
|
fi
|
||||||
|
log_success "SSH server enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
# $1 = "gh" or "lp", $2 = label for messages.
|
||||||
|
ssh_import_from() {
|
||||||
|
local prefix="$1" label="$2" user
|
||||||
|
echo
|
||||||
|
if ! command -v ssh-import-id &>/dev/null; then
|
||||||
|
sudo apt install -y ssh-import-id || { log_error "Could not install ssh-import-id"; return 1; }
|
||||||
|
fi
|
||||||
|
read -r -p "$label username (blank to cancel): " user
|
||||||
|
[[ -z "$user" ]] && { echo "Cancelled"; return; }
|
||||||
|
if [[ ! "$user" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||||
|
log_error "That doesn't look like a valid $label username"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ssh-import-id "${prefix}:${user}"; then
|
||||||
|
log_success "Imported $label keys for $user ($(ssh_key_count) key(s) now authorized)"
|
||||||
|
else
|
||||||
|
log_error "Import failed - check the username and that this machine can reach $label"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_import_github() { ssh_import_from gh "GitHub"; }
|
||||||
|
action_ssh_import_launchpad() { ssh_import_from lp "Launchpad"; }
|
||||||
|
|
||||||
|
action_ssh_list_keys() {
|
||||||
|
echo
|
||||||
|
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||||
|
echo "No authorized keys for $USER yet."
|
||||||
|
else
|
||||||
|
ssh-keygen -lf "$SSH_AUTH_KEYS"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_keys_only() {
|
||||||
|
echo
|
||||||
|
if [[ "$(ssh_key_count)" == "0" ]]; then
|
||||||
|
log_error "No authorized keys yet - import some first, or you'd lock yourself out"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Log in from another machine with a key BEFORE doing this; password"
|
||||||
|
echo "logins over SSH will stop working."
|
||||||
|
ask_yes_no "Disable SSH password login?" "n" || { echo "Cancelled"; return; }
|
||||||
|
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' |
|
||||||
|
sudo install -D -m 644 /dev/stdin "$SSH_KEYS_ONLY_CONF"
|
||||||
|
if sudo sshd -t && sudo systemctl reload ssh; then
|
||||||
|
log_success "SSH now requires a key"
|
||||||
|
else
|
||||||
|
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||||
|
log_error "sshd rejected the config - reverted"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
action_ssh_allow_passwords() {
|
||||||
|
echo
|
||||||
|
sudo rm -f "$SSH_KEYS_ONLY_CONF"
|
||||||
|
sudo systemctl reload ssh 2>/dev/null || true
|
||||||
|
log_success "SSH password login allowed again"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First-time install hook (called from lib/provision.sh).
|
||||||
|
ssh_first_time_setup() {
|
||||||
|
echo
|
||||||
|
echo "SSH lets you manage the kiosk remotely. Ubuntu can import your"
|
||||||
|
echo "public keys from GitHub or Launchpad so you can log in with no password."
|
||||||
|
ask_yes_no "Enable SSH and import keys now?" "y" || { log_info "Configure later: Addons -> Remote Access -> SSH Access"; return; }
|
||||||
|
action_ssh_enable || return 0
|
||||||
|
local choice
|
||||||
|
while true; do
|
||||||
|
echo
|
||||||
|
echo "Import keys from:"
|
||||||
|
echo " 1. GitHub"
|
||||||
|
echo " 2. Launchpad"
|
||||||
|
echo " 3. Done"
|
||||||
|
choice=$(ask_integer "Choose" "3" 1 3)
|
||||||
|
case "$choice" in
|
||||||
|
1) action_ssh_import_github || true ;;
|
||||||
|
2) action_ssh_import_launchpad || true ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ "$(ssh_key_count)" != "0" ]]; then
|
||||||
|
echo "Tip: Remote Access -> SSH Access can switch SSH to key-only login"
|
||||||
|
echo "once you've confirmed a key works."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|||||||
+18
-12
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ clone_detect_addons() {
|
|||||||
command -v netbird &>/dev/null && addons+=("netbird")
|
command -v netbird &>/dev/null && addons+=("netbird")
|
||||||
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom")
|
||||||
webui_is_installed 2>/dev/null && addons+=("webui")
|
webui_is_installed 2>/dev/null && addons+=("webui")
|
||||||
|
library_is_installed 2>/dev/null && addons+=("library")
|
||||||
|
|
||||||
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then
|
||||||
local authelia_url
|
local authelia_url
|
||||||
@@ -243,6 +244,13 @@ action_apply_clone_settings() {
|
|||||||
fi ;;
|
fi ;;
|
||||||
asterisk_intercom)
|
asterisk_intercom)
|
||||||
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;;
|
||||||
|
library)
|
||||||
|
if library_is_installed; then
|
||||||
|
echo " [x] Library Catalog - already installed (the books themselves are not cloned)"
|
||||||
|
else
|
||||||
|
echo " [ ] Library Catalog - not installed, install via Addons (the catalogue page is cloned, the books are not)"
|
||||||
|
missing_any=true
|
||||||
|
fi ;;
|
||||||
authelia)
|
authelia)
|
||||||
echo " [!] Authelia - needs a fresh login on this device" ;;
|
echo " [!] Authelia - needs a fresh login on this device" ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
# (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall,
|
||||||
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
# tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall,
|
||||||
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
# squeezelite_do_uninstall, asterisk_intercom_do_uninstall,
|
||||||
# webui_do_uninstall, power_schedule_do_remove_all,
|
# webui_do_uninstall, library_do_uninstall, power_schedule_do_remove_all,
|
||||||
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
# emergency_hotspot_do_disable, time_sync_do_reset) instead of
|
||||||
# re-implementing removal logic for each addon a second time here - if an
|
# re-implementing removal logic for each addon a second time here - if an
|
||||||
# addon's uninstall logic changes, this picks it up automatically. Only
|
# addon's uninstall logic changes, this picks it up automatically. Only
|
||||||
@@ -59,6 +59,7 @@ action_complete_uninstall() {
|
|||||||
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)"
|
||||||
echo " • Asterisk Intercom (Baresip)"
|
echo " • Asterisk Intercom (Baresip)"
|
||||||
echo " • Web UI"
|
echo " • Web UI"
|
||||||
|
echo " • Library Catalog (Koha) and ALL the books in it"
|
||||||
echo " • LightDM and Openbox"
|
echo " • LightDM and Openbox"
|
||||||
echo " • All kiosk schedules and services"
|
echo " • All kiosk schedules and services"
|
||||||
echo " • Emergency hotspot configuration"
|
echo " • Emergency hotspot configuration"
|
||||||
@@ -90,6 +91,7 @@ action_complete_uninstall() {
|
|||||||
squeezelite_do_uninstall
|
squeezelite_do_uninstall
|
||||||
asterisk_intercom_do_uninstall purge
|
asterisk_intercom_do_uninstall purge
|
||||||
webui_do_uninstall
|
webui_do_uninstall
|
||||||
|
library_do_uninstall purge
|
||||||
|
|
||||||
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
echo "[3/12] Removing schedules, emergency hotspot and time server setting..."
|
||||||
power_schedule_do_remove_all
|
power_schedule_do_remove_all
|
||||||
@@ -110,6 +112,8 @@ action_complete_uninstall() {
|
|||||||
sudo rm -rf "$KIOSK_HOME"
|
sudo rm -rf "$KIOSK_HOME"
|
||||||
|
|
||||||
echo "[6/12] Removing remaining systemd units..."
|
echo "[6/12] Removing remaining systemd units..."
|
||||||
|
sudo systemctl disable --now kiosk-wifi-helper.socket 2>/dev/null || true
|
||||||
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.socket
|
||||||
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.service
|
||||||
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
|
sudo rm -f "$SYSTEMD_DIR"/kiosk-*.timer
|
||||||
sudo systemctl daemon-reload 2>/dev/null || true
|
sudo systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+57
-7
@@ -142,6 +142,42 @@ wifi_choose_iface() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# What the adapter is actually doing, printed after a failed connect so the
|
||||||
|
# cause (regulatory domain, weak signal, DHCP, driver) is visible, not guessed.
|
||||||
|
wifi_debug_dump() {
|
||||||
|
local i="$1"
|
||||||
|
echo "--- link ---"
|
||||||
|
iw dev "$i" link 2>&1 | head -8
|
||||||
|
echo "--- regulatory domain ---"
|
||||||
|
iw reg get 2>&1 | grep -m2 "country\|global" || true
|
||||||
|
echo "--- wpa_supplicant log (last 15) ---"
|
||||||
|
journalctl -b -u "netplan-wpa-$i" -n 15 --no-pager -o cat 2>&1 | cut -c1-160
|
||||||
|
echo "--- networkd ---"
|
||||||
|
networkctl status "$i" --no-pager 2>&1 | head -12
|
||||||
|
echo "(full log: /var/lib/kiosk-firstboot/install.log, or journalctl -b -u 'netplan-wpa-*')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||||
|
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||||
|
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||||
|
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||||
|
# the adapter can't join it.
|
||||||
|
wifi_default_country() {
|
||||||
|
local c=""
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" && -r /etc/modprobe.d/kiosk-regdom.conf ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' /etc/modprobe.d/kiosk-regdom.conf | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" ]]; then
|
||||||
|
local loc="${LANG:-}"
|
||||||
|
[[ -r /etc/default/locale ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' /etc/default/locale | head -1)
|
||||||
|
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
echo "${c^^}"
|
||||||
|
}
|
||||||
|
|
||||||
wifi_menu() {
|
wifi_menu() {
|
||||||
local rc=0
|
local rc=0
|
||||||
wifi_configure || rc=$?
|
wifi_configure || rc=$?
|
||||||
@@ -336,7 +372,17 @@ wifi_configure() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
apply_wifi_config "$wifi_iface" "$ssid" "$password"
|
local country c_in
|
||||||
|
country=$(wifi_default_country)
|
||||||
|
read -r -p "Country code for WiFi regulations (2 letters, e.g. US) [${country:-none}]: " c_in
|
||||||
|
c_in=${c_in:-$country}
|
||||||
|
c_in=${c_in^^}
|
||||||
|
if [[ -n "$c_in" && ! "$c_in" =~ ^[A-Z]{2}$ ]]; then
|
||||||
|
log_warning "'$c_in' isn't a 2-letter country code - skipping it"
|
||||||
|
c_in=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
apply_wifi_config "$wifi_iface" "$ssid" "$password" "$c_in"
|
||||||
}
|
}
|
||||||
|
|
||||||
# apply_wifi_config IFACE SSID PASSWORD
|
# apply_wifi_config IFACE SSID PASSWORD
|
||||||
@@ -346,6 +392,7 @@ apply_wifi_config() {
|
|||||||
local wifi_iface="$1"
|
local wifi_iface="$1"
|
||||||
local ssid="$2"
|
local ssid="$2"
|
||||||
local password="$3"
|
local password="$3"
|
||||||
|
local country="${4:-}"
|
||||||
|
|
||||||
# Our own file, merged by netplan with the others (wired config etc).
|
# Our own file, merged by netplan with the others (wired config etc).
|
||||||
local netplan_file="$WIFI_NETPLAN_FILE"
|
local netplan_file="$WIFI_NETPLAN_FILE"
|
||||||
@@ -369,7 +416,8 @@ network:
|
|||||||
dhcp4: true
|
dhcp4: true
|
||||||
dhcp6: false
|
dhcp6: false
|
||||||
optional: true
|
optional: true
|
||||||
access-points:
|
${country:+ regulatory-domain: \"$country\"
|
||||||
|
} access-points:
|
||||||
$(wifi_yaml_quote "$ssid"):
|
$(wifi_yaml_quote "$ssid"):
|
||||||
password: $(wifi_yaml_quote "$password")
|
password: $(wifi_yaml_quote "$password")
|
||||||
EOF
|
EOF
|
||||||
@@ -402,14 +450,15 @@ WATCHEOF
|
|||||||
rm -f "$temp_plan"
|
rm -f "$temp_plan"
|
||||||
|
|
||||||
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
|
sudo bash -c "$(declare -f wifi_drop_stale_entries); NETPLAN_DIR='$NETPLAN_DIR' WIFI_NETPLAN_FILE='$netplan_file' wifi_drop_stale_entries"
|
||||||
|
[[ -n "$country" ]] && sudo iw reg set "$country" 2>/dev/null
|
||||||
echo "Applying configuration..."
|
echo "Applying configuration..."
|
||||||
local netplan_log
|
local netplan_log
|
||||||
netplan_log=$(mktemp)
|
netplan_log=$(mktemp)
|
||||||
if sudo netplan apply > "$netplan_log" 2>&1; then
|
if sudo netplan apply > "$netplan_log" 2>&1; then
|
||||||
cat "$netplan_log"
|
cat "$netplan_log"
|
||||||
echo "Waiting for $wifi_iface to connect (up to 30 seconds)..."
|
echo "Waiting for $wifi_iface to connect (up to 45 seconds)..."
|
||||||
local _
|
local _
|
||||||
for _ in {1..30}; do
|
for _ in {1..45}; do
|
||||||
wifi_iface_has_ip "$wifi_iface" && break
|
wifi_iface_has_ip "$wifi_iface" && break
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
@@ -422,11 +471,12 @@ WATCHEOF
|
|||||||
why=$(wifi_diagnose "$wifi_iface")
|
why=$(wifi_diagnose "$wifi_iface")
|
||||||
if [[ -n "$why" ]]; then
|
if [[ -n "$why" ]]; then
|
||||||
log_error "Couldn't connect to $ssid: $why"
|
log_error "Couldn't connect to $ssid: $why"
|
||||||
|
elif iw dev "$wifi_iface" link 2>/dev/null | grep -q "^Connected"; then
|
||||||
|
log_error "Joined the network but got no address - the router's DHCP isn't answering (or is slow)"
|
||||||
else
|
else
|
||||||
echo "Wrong password or network name is the usual cause."
|
echo "Not joined, and the log doesn't say why - not necessarily the password."
|
||||||
fi
|
fi
|
||||||
echo "Details:"
|
wifi_debug_dump "$wifi_iface"
|
||||||
echo " sudo journalctl -b -u 'netplan-wpa-*' -u systemd-networkd -n 30 --no-pager"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_error "netplan apply failed"
|
log_error "netplan apply failed"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ Section "ServerFlags"
|
|||||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||||
Option "DontZap" "true"
|
Option "DontZap" "true"
|
||||||
|
|
||||||
# Disable VT switching (Ctrl+Alt+F1-F12)
|
# Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
|
||||||
Option "DontVTSwitch" "true"
|
# default, as the installer says. Advanced -> Virtual Consoles (or the
|
||||||
|
# install prompt) turns this to "true" to disable them.
|
||||||
|
Option "DontVTSwitch" "false"
|
||||||
|
|
||||||
# Don't allow clients to disconnect on exit
|
# Don't allow clients to disconnect on exit
|
||||||
Option "AllowClosedownGrabs" "false"
|
Option "AllowClosedownGrabs" "false"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Kiosk WiFi helper socket (used by the kiosk app's WiFi screen)
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/kiosk-wifi-helper.sock
|
||||||
|
SocketUser=root
|
||||||
|
# Replaced with the kiosk user's group when installed (lib/provision.sh).
|
||||||
|
SocketGroup=@KIOSK_GROUP@
|
||||||
|
SocketMode=0660
|
||||||
|
Accept=yes
|
||||||
|
MaxConnections=4
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Kiosk WiFi helper (one request)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/bin/kiosk-wifi-helper-socket
|
||||||
|
StandardInput=socket
|
||||||
|
StandardOutput=socket
|
||||||
|
StandardError=journal
|
||||||
|
# A connect waits up to ~30s for an address, plus netplan; never run forever.
|
||||||
|
RuntimeMaxSec=180
|
||||||
@@ -4,16 +4,25 @@
|
|||||||
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
|
# (Ctrl+Alt+Super+W), for joining a WiFi network from the kiosk
|
||||||
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
|
# itself when the console (Ctrl+Alt+F1..F6) is switched off.
|
||||||
#
|
#
|
||||||
# The kiosk user may run this one script via passwordless sudo
|
# The kiosk app reaches it through a root-owned socket
|
||||||
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
|
# (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
|
||||||
|
# or, as a fallback, passwordless sudo (/etc/sudoers.d/kiosk-wifi) - it does a
|
||||||
# fixed set of things and nothing else:
|
# fixed set of things and nothing else:
|
||||||
#
|
#
|
||||||
|
# kiosk-wifi-helper ifaces {"adapters":[{"name","kind","connected","ssid","ip"}],"selected"}
|
||||||
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
# kiosk-wifi-helper status {"iface","connected","ssid","ip","configured"}
|
||||||
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
# kiosk-wifi-helper scan {"iface","networks":[{"ssid","signal","secure"}]}
|
||||||
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
# kiosk-wifi-helper connect stdin {"ssid","password"} -> {"ok","ip"|"error"}
|
||||||
|
# kiosk-wifi-helper region-status
|
||||||
|
# {"country","locale","keyboard","locales":[],"keyboards":[]}
|
||||||
|
# kiosk-wifi-helper region-set stdin {"country"?,"locale"?,"keyboard"?} -> {"ok",...}
|
||||||
|
# WiFi country (regulatory domain), system
|
||||||
|
# locale and keyboard layout; every field optional
|
||||||
#
|
#
|
||||||
# Every reply is one JSON line on stdout, exit 0 on success. connect
|
# Every reply is one JSON line on stdout, exit 0 on success. status, scan
|
||||||
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
# and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
|
||||||
|
# to act on that adapter; without it the connected adapter is used (see
|
||||||
|
# pick_iface). connect writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
||||||
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
|
# 60-kiosk-wifi.yaml) and puts the previous one back if the interface
|
||||||
# gets no address - a typo in the password doesn't lose a WiFi network
|
# gets no address - a typo in the password doesn't lose a WiFi network
|
||||||
# that was working. The password comes in on stdin, never argv (argv is
|
# that was working. The password comes in on stdin, never argv (argv is
|
||||||
@@ -24,6 +33,8 @@ set -uo pipefail
|
|||||||
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
NETPLAN_DIR="${NETPLAN_DIR:-/etc/netplan}"
|
||||||
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
SYS_CLASS="${KIOSK_WIFI_SYS_CLASS:-/sys/class}" # overridable for tests
|
||||||
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
WIFI_NETPLAN_FILE="$NETPLAN_DIR/60-kiosk-wifi.yaml"
|
||||||
|
REGDOM_CONF="${KIOSK_REGDOM_CONF:-/etc/modprobe.d/kiosk-regdom.conf}"
|
||||||
|
LOCALE_FILE="${KIOSK_LOCALE_FILE:-/etc/default/locale}"
|
||||||
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
LOCK_FILE="${KIOSK_WIFI_LOCK:-/run/kiosk-wifi-helper.lock}"
|
||||||
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
CONNECT_WAIT="${KIOSK_WIFI_CONNECT_WAIT:-30}"
|
||||||
|
|
||||||
@@ -139,15 +150,200 @@ except Exception:
|
|||||||
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
|
sys.exit(1)' "$WIFI_NETPLAN_FILE" "$SYS_CLASS" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Two-letter country code for the WiFi regulatory domain: the one already
|
||||||
|
# in our netplan file, else from the system locale (en_US.UTF-8 -> US).
|
||||||
|
# Without a country set, Linux uses the restrictive "world" domain: many
|
||||||
|
# 5 GHz channels become listen-only, so a network shows up in scans but
|
||||||
|
# the adapter can't join it.
|
||||||
|
default_country() {
|
||||||
|
local c=""
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
c=$(sed -n 's/^[[:space:]]*regulatory-domain:[[:space:]]*["'"'"']\{0,1\}\([A-Za-z][A-Za-z]\).*/\1/p' "$WIFI_NETPLAN_FILE" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" && -f "$REGDOM_CONF" ]]; then
|
||||||
|
c=$(sed -n 's/.*ieee80211_regdom=\([A-Za-z][A-Za-z]\).*/\1/p' "$REGDOM_CONF" | head -1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$c" ]]; then
|
||||||
|
local loc="${LANG:-}"
|
||||||
|
[[ -r "$LOCALE_FILE" ]] && loc=$(sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1)
|
||||||
|
[[ "$loc" =~ _([A-Z][A-Z])[.@]? ]] && c="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
echo "${c^^}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every wireless interface, by what it is (see detect_iface).
|
||||||
|
list_wifi_ifaces() {
|
||||||
|
local d
|
||||||
|
for d in $SYS_CLASS/net/*; do
|
||||||
|
if [[ -d "$d/wireless" || -e "$d/phy80211" ]]; then
|
||||||
|
basename "$d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# The wireless adapter that is connected right now (associated and has an
|
||||||
|
# address), if any. With a built-in card plus a USB dongle, the connected
|
||||||
|
# one is often not the first in name order - status/scan/connect must act
|
||||||
|
# on the adapter that is actually carrying the connection.
|
||||||
|
connected_iface() {
|
||||||
|
local i
|
||||||
|
for i in $(list_wifi_ifaces); do
|
||||||
|
if [[ -n "$(current_ssid "$i")" && -n "$(iface_ip "$i")" ]]; then
|
||||||
|
echo "$i"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which adapter to use: the connected one, else the one our netplan file
|
||||||
|
# configures, else the first found.
|
||||||
|
pick_iface() {
|
||||||
|
connected_iface || prefer_configured_iface || detect_iface
|
||||||
|
}
|
||||||
|
|
||||||
|
# The request body (JSON on stdin), read once in main. REQ_IFACE is the
|
||||||
|
# adapter the caller asked for, or empty.
|
||||||
|
REQ_INPUT=""
|
||||||
|
REQ_IFACE=""
|
||||||
|
|
||||||
|
# Only a real wireless interface name is accepted - never pass caller text
|
||||||
|
# to a command or a YAML file unchecked.
|
||||||
|
valid_iface() {
|
||||||
|
local i
|
||||||
|
[[ "$1" =~ ^[A-Za-z0-9._-]{1,15}$ ]] || return 1
|
||||||
|
for i in $(list_wifi_ifaces); do
|
||||||
|
[[ "$i" == "$1" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# "usb" or "built-in", from where the device sits in sysfs.
|
||||||
|
iface_kind() {
|
||||||
|
if readlink -f "$SYS_CLASS/net/$1/device" 2>/dev/null | grep -q '/usb'; then
|
||||||
|
echo usb
|
||||||
|
else
|
||||||
|
echo built-in
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pick_requested_or_default_iface() {
|
||||||
|
if [[ -n "$REQ_IFACE" ]]; then
|
||||||
|
valid_iface "$REQ_IFACE" || return 1
|
||||||
|
echo "$REQ_IFACE"
|
||||||
|
else
|
||||||
|
pick_iface
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
need_iface() {
|
need_iface() {
|
||||||
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
|
if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
|
||||||
|
reply_error "WiFi adapter $REQ_IFACE not found"
|
||||||
|
fi
|
||||||
|
IFACE=$(pick_requested_or_default_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
|
||||||
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
|
command -v iw >/dev/null || reply_error "WiFi support (iw, wpasupplicant) isn't installed - run Core Settings -> WiFi once from the admin menu"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---- Region: WiFi country, system locale, keyboard layout -----------------
|
||||||
|
|
||||||
|
current_locale() {
|
||||||
|
[[ -r "$LOCALE_FILE" ]] || return 0
|
||||||
|
sed -n 's/^LANG=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' "$LOCALE_FILE" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
current_keyboard() {
|
||||||
|
localectl status 2>/dev/null | sed -n 's/^[[:space:]]*X11 Layout:[[:space:]]*//p' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locales that are generated on this system (setting any other one would
|
||||||
|
# leave the whole system on a locale that doesn't exist).
|
||||||
|
list_locales() {
|
||||||
|
locale -a 2>/dev/null | grep -i 'utf-\?8' | sed 's/\.[Uu][Tt][Ff]-\?8$/.UTF-8/' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
list_keyboards() {
|
||||||
|
localectl list-x11-keymap-layouts 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_status() {
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson ls "$(list_locales | jq -R . | jq -cs .)" \
|
||||||
|
--argjson ks "$(list_keyboards | jq -R . | jq -cs .)" \
|
||||||
|
'{ok:true,country:$c,locale:$l,keyboard:$k,locales:$ls,keyboards:$ks}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist the WiFi country: kernel option for the regulatory domain (so it
|
||||||
|
# applies from boot, before any WiFi config exists), the netplan file if
|
||||||
|
# there is one (so wpa_supplicant gets it too), and now via iw.
|
||||||
|
set_country() {
|
||||||
|
local c="$1"
|
||||||
|
printf '# Written by kiosk-wifi-helper (Region setting)\noptions cfg80211 ieee80211_regdom=%s\n' "$c" > "$REGDOM_CONF"
|
||||||
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
|
sed -i '/^[[:space:]]*regulatory-domain:/d' "$WIFI_NETPLAN_FILE"
|
||||||
|
sed -i "s/^\([[:space:]]*\)optional: true\$/&\n\1regulatory-domain: \"$c\"/" "$WIFI_NETPLAN_FILE"
|
||||||
|
fi
|
||||||
|
iw reg set "$c" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
do_region_set() {
|
||||||
|
local input country locale keyboard
|
||||||
|
input="$REQ_INPUT"
|
||||||
|
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
locale=$(jq -r '.locale // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
country=${country^^}
|
||||||
|
|
||||||
|
if [[ -n "$country" ]]; then
|
||||||
|
[[ "$country" =~ ^[A-Z]{2}$ ]] || reply_error "A country code is 2 letters, e.g. US"
|
||||||
|
# Reject codes that aren't real countries when the tz database is here.
|
||||||
|
if [[ -r /usr/share/zoneinfo/iso3166.tab ]] && ! grep -q "^$country[[:space:]]" /usr/share/zoneinfo/iso3166.tab; then
|
||||||
|
reply_error "$country isn't a known country code"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
list_locales | grep -qxF -- "$locale" || reply_error "Locale $locale isn't installed on this system"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
list_keyboards | grep -qxF -- "$keyboard" || reply_error "Unknown keyboard layout: $keyboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local err
|
||||||
|
[[ -n "$country" ]] && set_country "$country"
|
||||||
|
if [[ -n "$locale" ]]; then
|
||||||
|
err=$(localectl set-locale "LANG=$locale" 2>&1) || reply_error "Couldn't set the locale: ${err:0:200}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$keyboard" ]]; then
|
||||||
|
err=$(localectl set-x11-keymap "$keyboard" 2>&1) || reply_error "Couldn't set the keyboard: ${err:0:200}"
|
||||||
|
# Console layout too, when there's a console map of the same name.
|
||||||
|
localectl list-keymaps 2>/dev/null | grep -qxF -- "$keyboard" && localectl set-keymap "$keyboard" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
jq -cn --arg c "$(default_country)" --arg l "$(current_locale)" --arg k "$(current_keyboard)" \
|
||||||
|
--argjson restart "$([[ -n "$locale$keyboard" ]] && echo true || echo false)" \
|
||||||
|
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
|
||||||
|
}
|
||||||
|
|
||||||
|
do_ifaces() {
|
||||||
|
local i rows="" sel
|
||||||
|
sel=$(pick_iface) || sel=""
|
||||||
|
for i in $(list_wifi_ifaces); do
|
||||||
|
local ssid ip
|
||||||
|
ssid=$(current_ssid "$i")
|
||||||
|
ip=$(iface_ip "$i")
|
||||||
|
rows+=$(jq -cn --arg n "$i" --arg k "$(iface_kind "$i")" --arg s "$ssid" --arg a "$ip" \
|
||||||
|
'{name:$n,kind:$k,connected:($s != "" and $a != ""),ssid:$s,ip:$a}')$'\n'
|
||||||
|
done
|
||||||
|
jq -cn --arg sel "$sel" --argjson a "$(printf '%s' "$rows" | jq -cs .)" \
|
||||||
|
'{ok:true,selected:$sel,adapters:$a}'
|
||||||
|
}
|
||||||
|
|
||||||
do_status() {
|
do_status() {
|
||||||
local iface ssid="" ip="" configured
|
local iface ssid="" ip="" configured
|
||||||
configured=$(configured_ssid)
|
configured=$(configured_ssid)
|
||||||
if ! iface=$(detect_iface); then
|
if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
|
||||||
|
reply_error "WiFi adapter $REQ_IFACE not found"
|
||||||
|
fi
|
||||||
|
if ! iface=$(pick_requested_or_default_iface); then
|
||||||
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
|
jq -cn --arg c "$configured" '{ok:true,iface:"",connected:false,ssid:"",ip:"",configured:$c}'
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -244,7 +440,7 @@ PYEOF
|
|||||||
do_connect() {
|
do_connect() {
|
||||||
need_iface
|
need_iface
|
||||||
local input ssid password
|
local input ssid password
|
||||||
input=$(head -c 4096)
|
input="$REQ_INPUT"
|
||||||
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
|
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
|
||||||
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
password=$(jq -r '.password // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
|
|
||||||
@@ -264,7 +460,9 @@ do_connect() {
|
|||||||
unblock
|
unblock
|
||||||
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
hard_blocked && reply_error "WiFi is switched off by a hardware switch or in the BIOS/UEFI"
|
||||||
|
|
||||||
local backup="" new
|
local country backup="" new
|
||||||
|
country=$(default_country)
|
||||||
|
[[ -n "$country" ]] && iw reg set "$country" 2>/dev/null
|
||||||
mkdir -p "$NETPLAN_DIR"
|
mkdir -p "$NETPLAN_DIR"
|
||||||
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
if [[ -f "$WIFI_NETPLAN_FILE" ]]; then
|
||||||
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
|
backup="${WIFI_NETPLAN_FILE}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
@@ -281,6 +479,7 @@ do_connect() {
|
|||||||
echo " dhcp4: true"
|
echo " dhcp4: true"
|
||||||
echo " dhcp6: false"
|
echo " dhcp6: false"
|
||||||
echo " optional: true"
|
echo " optional: true"
|
||||||
|
[[ -n "$country" ]] && echo " regulatory-domain: \"$country\""
|
||||||
echo " access-points:"
|
echo " access-points:"
|
||||||
if [[ -n "$password" ]]; then
|
if [[ -n "$password" ]]; then
|
||||||
echo " $(yaml_quote "$ssid"):"
|
echo " $(yaml_quote "$ssid"):"
|
||||||
@@ -344,9 +543,18 @@ trap 'rm -f "$TMP_SCAN"' EXIT
|
|||||||
exec 9> "$LOCK_FILE"
|
exec 9> "$LOCK_FILE"
|
||||||
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
|
flock -w 60 9 || reply_error "Another WiFi change is still running - try again in a moment"
|
||||||
|
|
||||||
|
# One JSON request body on stdin (not when run by hand from a terminal).
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
REQ_INPUT=$(head -c 4096)
|
||||||
|
REQ_IFACE=$(jq -r '.iface // "" | strings' <<< "$REQ_INPUT" 2>/dev/null) || REQ_IFACE=""
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
|
ifaces) do_ifaces ;;
|
||||||
status) do_status ;;
|
status) do_status ;;
|
||||||
scan) do_scan ;;
|
scan) do_scan ;;
|
||||||
connect) do_connect ;;
|
connect) do_connect ;;
|
||||||
|
region-status) do_region_status ;;
|
||||||
|
region-set) do_region_set ;;
|
||||||
*) reply_error "unknown action: ${1:-}" ;;
|
*) reply_error "unknown action: ${1:-}" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
|
||||||
|
#
|
||||||
|
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
|
||||||
|
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
|
||||||
|
# stdout. The kiosk app connects, sends
|
||||||
|
#
|
||||||
|
# <action>\n<JSON request body>
|
||||||
|
#
|
||||||
|
# and half-closes; the reply is the helper's one JSON line.
|
||||||
|
#
|
||||||
|
# Why a socket and not `sudo`: the kiosk app can end up running with the
|
||||||
|
# Linux "no new privileges" flag, and sudo then refuses to run as root
|
||||||
|
# ("The 'no new privileges' flag is set"). A root service reached through a
|
||||||
|
# socket isn't affected by that flag. Who may connect is decided by the
|
||||||
|
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
|
||||||
|
# run the helper through the old sudoers rule, no more.
|
||||||
|
#
|
||||||
|
# Only the fixed actions below are passed on; anything else is refused.
|
||||||
|
################################################################################
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
|
||||||
|
|
||||||
|
# First line only; bash's read doesn't consume past the newline on a
|
||||||
|
# socket, so the JSON body stays on stdin for the helper.
|
||||||
|
IFS= read -r -t 10 -n 32 action || action=""
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
ifaces|status|scan|connect|region-status|region-set)
|
||||||
|
exec "$HELPER" "$action"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo '{"ok":false,"error":"unknown action"}'
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+217
@@ -0,0 +1,217 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
################################################################################
|
||||||
|
# tests/addon_library.test.sh - tests for menus/addon_library.sh + lib/docker.sh
|
||||||
|
#
|
||||||
|
# Runs entirely in a scratch directory with a stub `sudo`, a stub `docker` and
|
||||||
|
# a fixture stack standing in for library/, so it needs no root, no Docker and
|
||||||
|
# no network - and cannot touch a real kiosk. (The real stack is exercised by
|
||||||
|
# library/install.sh itself.)
|
||||||
|
#
|
||||||
|
# bash tests/addon_library.test.sh
|
||||||
|
################################################################################
|
||||||
|
set -u
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
T="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$T"' EXIT
|
||||||
|
|
||||||
|
PASS=0 FAIL=0
|
||||||
|
ok() { PASS=$((PASS + 1)); echo " ok - $1"; }
|
||||||
|
bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; }
|
||||||
|
check() { # check "description" command...
|
||||||
|
local d="$1"; shift
|
||||||
|
if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- stubs --------------------------------------------------------------------
|
||||||
|
mkdir -p "$T/bin"
|
||||||
|
cat > "$T/bin/sudo" << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Stub sudo: drop "-u USER" and run the command as-is.
|
||||||
|
if [[ "$1" == "-u" ]]; then shift 2; fi
|
||||||
|
exec "$@"
|
||||||
|
EOF
|
||||||
|
cat > "$T/bin/docker" << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Stub docker: logs every call; container state comes from $STUB_STATE.
|
||||||
|
echo "$*" >> "$STUB_LOG"
|
||||||
|
case "$1" in
|
||||||
|
--version) echo "Docker version 99.0.0, build stub"; exit 0 ;;
|
||||||
|
info) exit 0 ;;
|
||||||
|
compose)
|
||||||
|
[[ "$2" == "version" ]] && { echo "Docker Compose version v9"; exit 0; }
|
||||||
|
exit 0 ;;
|
||||||
|
inspect)
|
||||||
|
st="$(cat "$STUB_STATE" 2>/dev/null || echo missing)"
|
||||||
|
[[ "$st" == "missing" ]] && exit 1
|
||||||
|
case "$*" in *Health*) echo healthy ;; *) echo "$st" ;; esac
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$T/bin/sudo" "$T/bin/docker"
|
||||||
|
export PATH="$T/bin:$PATH"
|
||||||
|
export STUB_LOG="$T/docker.log" STUB_STATE="$T/state"
|
||||||
|
echo missing > "$STUB_STATE"; : > "$STUB_LOG"
|
||||||
|
|
||||||
|
# ---- fixture "checkout" with a stub library stack ----------------------------
|
||||||
|
FIX="$T/checkout"
|
||||||
|
mkdir -p "$FIX/library/koha/certs"
|
||||||
|
echo "name: library" > "$FIX/library/docker-compose.yml"
|
||||||
|
echo "SHOULD-NOT-BE-COPIED" > "$FIX/library/.env"
|
||||||
|
echo "cert" > "$FIX/library/koha/certs/proxy.crt"
|
||||||
|
echo "koha-file" > "$FIX/library/koha/Dockerfile"
|
||||||
|
cat > "$FIX/library/install.sh" << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Stub of library/install.sh: records how it was called and writes a .env.
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
echo "install.sh $* LIBRARY_NAME=${LIBRARY_NAME:-} BIND_ADDR=${BIND_ADDR:-}" >> "$STUB_LOG"
|
||||||
|
[[ "${1:-}" == "--update" ]] && exit 0
|
||||||
|
cat > "$HERE/.env" << ENV
|
||||||
|
LIBRARY_NAME="${LIBRARY_NAME:-}"
|
||||||
|
OPAC_PORT=8080
|
||||||
|
STAFF_PORT=8081
|
||||||
|
QUICKADD_PORT=8082
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=s3cretPASS
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=guestPASS
|
||||||
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
|
ENV
|
||||||
|
echo running > "$STUB_STATE"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ---- environment the addon expects -------------------------------------------
|
||||||
|
export KIOSK_USER="$(id -un)"
|
||||||
|
export KIOSK_HOME="$T/home" KIOSK_DIR="$T/home/kiosk-app" CONFIG_PATH="$T/home/kiosk-app/config.json"
|
||||||
|
export LIBRARY_DIR="$T/opt-library"
|
||||||
|
SCRIPT_DIR="$FIX"
|
||||||
|
mkdir -p "$KIOSK_DIR"
|
||||||
|
cat > "$CONFIG_PATH" << 'EOF'
|
||||||
|
{"tabs":[{"url":"https://example.com","duration":60,"username":"","password":"","name":"Example"}],
|
||||||
|
"homeTabIndex":-1,"inactivityTimeout":120,"swipeMode":"dual","allowNavigation":"same-origin","somethingUntracked":"keep-me"}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$REPO/lib/menu.sh"
|
||||||
|
source "$REPO/lib/config.sh"
|
||||||
|
source "$REPO/lib/docker.sh"
|
||||||
|
source "$REPO/menus/addon_library.sh"
|
||||||
|
export KIOSK_UNATTENDED=0
|
||||||
|
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
||||||
|
|
||||||
|
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== lib/docker.sh"
|
||||||
|
check "docker_is_installed sees the stub" docker_is_installed
|
||||||
|
check "container state: missing" test "$(docker_container_state library-koha)" = missing
|
||||||
|
echo running > "$STUB_STATE"
|
||||||
|
check "container state: running" test "$(docker_container_state library-koha)" = running
|
||||||
|
check "container health: healthy" test "$(docker_container_health library-koha)" = healthy
|
||||||
|
echo missing > "$STUB_STATE"
|
||||||
|
check "docker_free_port returns a number" test -n "$(docker_free_port 20000)"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== sync files"
|
||||||
|
mkdir -p "$LIBRARY_DIR"; echo "DEPLOYED-ENV" > "$LIBRARY_DIR/.env"
|
||||||
|
library_sync_files
|
||||||
|
check "copies the stack files" test -f "$LIBRARY_DIR/koha/Dockerfile"
|
||||||
|
check "never overwrites a deployed .env" test "$(cat "$LIBRARY_DIR/.env")" = DEPLOYED-ENV
|
||||||
|
check "does not copy certificates" test ! -e "$LIBRARY_DIR/koha/certs/proxy.crt"
|
||||||
|
rm -rf "$LIBRARY_DIR"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== not installed"
|
||||||
|
if library_is_installed; then bad "library_is_installed false before install"; else ok "library_is_installed false before install"; fi
|
||||||
|
addon_library_menu_builder
|
||||||
|
check "offers only Install" test "${MENU_LABELS[*]}" = "Install Library Catalog"
|
||||||
|
check "status says not installed" test "$(addon_library_status)" = "Library Catalog: not installed"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== install action (kiosk-only network, show on kiosk)"
|
||||||
|
# answers: library name, allow-network=n, show-on-kiosk=y, <Enter> for pause
|
||||||
|
printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.out" 2>&1
|
||||||
|
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
||||||
|
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
||||||
|
check "is_installed is now true" library_is_installed
|
||||||
|
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
|
||||||
|
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
|
||||||
|
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
|
||||||
|
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
||||||
|
addon_library_status > "$T/status.out"
|
||||||
|
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== menu while installed"
|
||||||
|
addon_library_menu_builder
|
||||||
|
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
|
||||||
|
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
||||||
|
|
||||||
|
echo "== tab toggling is idempotent"
|
||||||
|
library_add_tab >/dev/null 2>&1
|
||||||
|
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
||||||
|
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
|
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
addon_library_menu_builder
|
||||||
|
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||||
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
||||||
|
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== an earlier version's tab (public catalogue address) is cleaned up"
|
||||||
|
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
|
||||||
|
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
|
||||||
|
library_remove_tab >/dev/null 2>&1
|
||||||
|
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
|
||||||
|
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
library_add_tab >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "== network access"
|
||||||
|
: > "$STUB_LOG"
|
||||||
|
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
||||||
|
check ".env BIND_ADDR is now 0.0.0.0" test "$(library_env_get BIND_ADDR)" = 0.0.0.0
|
||||||
|
check "containers were recreated" grep -q 'compose --project-directory .* up -d' "$STUB_LOG"
|
||||||
|
printf 'n\n\n' | action_library_network >/dev/null 2>&1 # back to kiosk-only
|
||||||
|
check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR)" = 127.0.0.1
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== access screen"
|
||||||
|
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
||||||
|
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||||
|
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
|
||||||
|
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
||||||
|
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== update"
|
||||||
|
: > "$STUB_LOG"
|
||||||
|
printf 'y\n\n' | action_library_update >/dev/null 2>&1
|
||||||
|
check "runs install.sh --update" grep -q 'install.sh --update' "$STUB_LOG"
|
||||||
|
check "update keeps the .env" test "$(library_env_get ADMIN_PASSWORD)" = s3cretPASS
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
echo "== uninstall keeps the books by default"
|
||||||
|
: > "$STUB_LOG"
|
||||||
|
printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
|
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
||||||
|
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
||||||
|
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
||||||
|
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
|
|
||||||
|
echo "== purge needs the typed confirmation"
|
||||||
|
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
|
check "wrong confirmation cancels" test -f "$LIBRARY_DIR/.env"
|
||||||
|
: > "$STUB_LOG"
|
||||||
|
library_add_tab >/dev/null 2>&1
|
||||||
|
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
|
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
||||||
|
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
||||||
|
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
|
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "passed: $PASS failed: $FAIL"
|
||||||
|
[[ "$FAIL" -eq 0 ]]
|
||||||
@@ -9323,7 +9323,7 @@ addon_netbird() {
|
|||||||
echo "Get setup key from Netbird dashboard"
|
echo "Get setup key from Netbird dashboard"
|
||||||
read -r -p "Enter setup key: " setup_key
|
read -r -p "Enter setup key: " setup_key
|
||||||
if [[ -n "$setup_key" ]]; then
|
if [[ -n "$setup_key" ]]; then
|
||||||
sudo netbird up --setup-key "$setup_key"
|
sudo netbird up --setup-key "$setup_key" --allow-server-ssh
|
||||||
log_success "Netbird connected"
|
log_success "Netbird connected"
|
||||||
fi
|
fi
|
||||||
pause
|
pause
|
||||||
@@ -9354,7 +9354,7 @@ addon_netbird() {
|
|||||||
if [[ "$do_connect" =~ ^[Yy]$ ]]; then
|
if [[ "$do_connect" =~ ^[Yy]$ ]]; then
|
||||||
echo "Get setup key from Netbird dashboard"
|
echo "Get setup key from Netbird dashboard"
|
||||||
read -r -p "Enter setup key: " setup_key
|
read -r -p "Enter setup key: " setup_key
|
||||||
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key"
|
[[ -n "$setup_key" ]] && sudo netbird up --setup-key "$setup_key" --allow-server-ssh
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -643,6 +673,63 @@ document.getElementById('run-upgrade').addEventListener('click', async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Region */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function fillSelect(sel, items, current) {
|
||||||
|
sel.textContent = '';
|
||||||
|
const list = items.slice();
|
||||||
|
if (current && !list.includes(current)) list.unshift(current);
|
||||||
|
for (const v of list) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = v;
|
||||||
|
o.textContent = v;
|
||||||
|
o.selected = v === current;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegion() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/region');
|
||||||
|
const r = await res.json();
|
||||||
|
if (!res.ok) throw new Error(r.error || 'Could not read the region');
|
||||||
|
document.getElementById('region-country').value = r.country || '';
|
||||||
|
fillSelect(document.getElementById('region-locale'), r.locales || [], r.locale);
|
||||||
|
fillSelect(document.getElementById('region-keyboard'), r.keyboards || [], r.keyboard);
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('region-form').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/region', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
country: document.getElementById('region-country').value.trim(),
|
||||||
|
locale: document.getElementById('region-locale').value,
|
||||||
|
keyboard: document.getElementById('region-keyboard').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const r = await res.json();
|
||||||
|
if (!res.ok) throw new Error(r.error || 'Could not save');
|
||||||
|
showMessage(r.restart ? 'Region saved - restart for the locale and keyboard to apply' : 'Region saved');
|
||||||
|
loadRegion();
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('region-from-locale').addEventListener('click', () => {
|
||||||
|
const m = /_([A-Z]{2})/.exec(document.getElementById('region-locale').value);
|
||||||
|
if (m) document.getElementById('region-country').value = m[1];
|
||||||
|
else showMessage("That locale doesn't name a country", true);
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
/* Init */
|
/* Init */
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
@@ -657,6 +744,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
await loadAddonStatus();
|
await loadAddonStatus();
|
||||||
renderAddons();
|
renderAddons();
|
||||||
|
loadRegion();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
+51
-1
@@ -36,13 +36,17 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
||||||
Addons
|
Addons
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-page="region">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
|
||||||
|
Region
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-page="update">
|
<button class="nav-item" data-page="update">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
|
||||||
Update
|
Update
|
||||||
</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">
|
||||||
@@ -61,6 +65,18 @@
|
|||||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
<button type="button" id="save-sites">Save Sites & 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 → "On-screen back / home / forward buttons". A mouse's side buttons also go back and forward.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Display & Interaction -->
|
<!-- Display & Interaction -->
|
||||||
@@ -91,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>
|
||||||
@@ -100,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">
|
||||||
@@ -192,6 +213,35 @@
|
|||||||
<div id="addons-list"></div>
|
<div id="addons-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Region -->
|
||||||
|
<section class="page" id="page-region">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Region</h1>
|
||||||
|
<p>WiFi country, system language and keyboard layout. The WiFi country decides which channels the adapter may join - without it many 5 GHz networks show up in scans but won't connect.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<form id="region-form">
|
||||||
|
<label>
|
||||||
|
WiFi country (2 letters, e.g. US)
|
||||||
|
<input type="text" id="region-country" maxlength="2" autocomplete="off" spellcheck="false" style="text-transform: uppercase">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Language / locale
|
||||||
|
<select id="region-locale"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Keyboard layout
|
||||||
|
<select id="region-keyboard"></select>
|
||||||
|
</label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit">Save region</button>
|
||||||
|
<button type="button" id="region-from-locale" class="secondary">Country from locale</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Locale and keyboard apply after a restart; the WiFi country applies immediately.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Update -->
|
<!-- Update -->
|
||||||
<section class="page" id="page-update">
|
<section class="page" id="page-update">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+62
-7
@@ -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,12 +23,22 @@ 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 SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
const SUDO_CMD = process.env.SUDO_CMD !== undefined ? process.env.SUDO_CMD : 'sudo';
|
||||||
|
|
||||||
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'];
|
||||||
@@ -69,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) {
|
||||||
@@ -172,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' });
|
||||||
@@ -253,7 +274,41 @@ app.get('/api/actions/jobs/:jobId/stream', (req, res) => {
|
|||||||
req.on('close', () => job.listeners.delete(listener));
|
req.on('close', () => job.listeners.delete(listener));
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8090;
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Region: WiFi country, locale, keyboard (root work in kiosk-wifi-helper) */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function runRegionHelper(action, input, res) {
|
||||||
|
const cmd = SUDO_CMD || WIFI_HELPER_PATH;
|
||||||
|
const args = SUDO_CMD ? ['-n', WIFI_HELPER_PATH, action] : [action];
|
||||||
|
const child = execFile(cmd, args, { timeout: 30_000 }, (err, stdout, stderr) => {
|
||||||
|
let body = null;
|
||||||
|
try { body = JSON.parse(stdout.trim().split('\n').pop()); } catch (e) { /* not our JSON */ }
|
||||||
|
if (!body) {
|
||||||
|
console.error(`${action} failed:`, stderr || (err && err.message));
|
||||||
|
return res.status(500).json({ error: 'The region helper is not available - run Advanced -> Upgrade from the admin menu' });
|
||||||
|
}
|
||||||
|
res.status(body.ok ? 200 : 400).json(body);
|
||||||
|
});
|
||||||
|
child.stdin.on('error', () => {});
|
||||||
|
child.stdin.end(input ? JSON.stringify(input) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/region', (req, res) => runRegionHelper('region-status', null, res));
|
||||||
|
|
||||||
|
app.put('/api/region', (req, res) => {
|
||||||
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const input = {};
|
||||||
|
for (const k of ['country', 'locale', 'keyboard']) {
|
||||||
|
if (body[k] === undefined || body[k] === '') continue;
|
||||||
|
if (typeof body[k] !== 'string' || body[k].length > 64) return badRequest(res, `${k} must be a short string`);
|
||||||
|
input[k] = body[k];
|
||||||
|
}
|
||||||
|
if (!Object.keys(input).length) return badRequest(res, 'Nothing to change');
|
||||||
|
runRegionHelper('region-set', input, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -13,8 +13,25 @@ const assert = require('assert');
|
|||||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
|
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-api-test-'));
|
||||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||||
|
|
||||||
|
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
|
||||||
|
process.env.WIFI_HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-wifi-helper.sh');
|
||||||
|
|
||||||
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 {
|
||||||
@@ -240,6 +257,31 @@ async function main() {
|
|||||||
assert.strictEqual(body.lockoutTimeout, 20);
|
assert.strictEqual(body.lockoutTimeout, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await check('GET /api/region returns the helper\'s status', async () => {
|
||||||
|
const res = await fetch(`${base}/api/region`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual((await res.json()).country, 'US');
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('PUT /api/region passes only the given fields to the helper', async () => {
|
||||||
|
const res = await fetch(`${base}/api/region`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ country: 'DE', locale: '', bogus: 'x' }),
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.deepStrictEqual(JSON.parse((await res.json()).echo), { country: 'DE' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('PUT /api/region with nothing to change is a 400', async () => {
|
||||||
|
const res = await fetch(`${base}/api/region`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
server.close();
|
server.close();
|
||||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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' }],
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stand-in for kiosk-wifi-helper in api.test.js: echoes the action and stdin.
|
||||||
|
input=$(cat)
|
||||||
|
case "$1" in
|
||||||
|
region-status) echo '{"ok":true,"country":"US","locale":"en_US.UTF-8","keyboard":"us","locales":["en_US.UTF-8"],"keyboards":["us"]}' ;;
|
||||||
|
region-set) jq -cn --arg i "$input" '{ok:true,echo:$i,restart:true}' ;;
|
||||||
|
*) echo '{"ok":false,"error":"unknown"}' ;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user