Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef968fd10 | ||
|
|
9fb512cdab | ||
|
|
f0a9728016 | ||
|
|
6fdc5f4dd1 | ||
|
|
0af833a39d | ||
|
|
442b45c2e0 | ||
|
|
6f877d1107 | ||
|
|
3d02d28a8c | ||
|
|
7f230c8e73 | ||
|
|
b784d98c9d | ||
|
|
d01919e2ac | ||
|
|
10e991e158 | ||
|
|
770a6a1dfb | ||
|
|
522a33b127 |
@@ -1,22 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
## No user data in the repo
|
|
||||||
|
|
||||||
Never write anything specific to a person's kiosk, network or accounts into
|
|
||||||
this repository - not in code, tests, fixtures, docs, comments, commit
|
|
||||||
messages, branch names or pull request text.
|
|
||||||
|
|
||||||
That includes:
|
|
||||||
|
|
||||||
- WiFi network names (SSIDs), passwords, keys and tokens
|
|
||||||
- MAC addresses, IP addresses, hostnames and interface names taken from a
|
|
||||||
real machine
|
|
||||||
- usernames, email addresses, PINs and passwords
|
|
||||||
- the URLs of someone's own sites or services
|
|
||||||
- log output, screenshots or terminal output pasted in by a user
|
|
||||||
|
|
||||||
When an example, test or fixture needs a value like these, use an obvious
|
|
||||||
placeholder (`ExampleNet`, `192.0.2.10`, `aa:bb:cc:dd:ee:ff`, `wlan0`,
|
|
||||||
`example.com`) instead of a real one. When debugging from output a user
|
|
||||||
shares, keep it in the conversation or the scratchpad, not in a file in this
|
|
||||||
repo, and leave it out of commit messages.
|
|
||||||
@@ -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>:8888`) or `./install.sh`. |
|
| `--unattended` | First-boot install takes every default with no prompts (skips the Core Settings menu) and reboots into the kiosk. Set sites afterwards in the Web UI (`http://<ip>:8090`) or `./install.sh`. |
|
||||||
| `--username NAME [--password PASS]` | Pre-set the admin account (password is prompted for if omitted; only a hash goes on the ISO). Not `kiosk`. |
|
| `--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,7 +249,6 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
|
|||||||
- **1-finger swipe** (dual mode) - Navigate within page (arrow keys)
|
- **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
|
||||||
@@ -538,9 +537,7 @@ It writes the same netplan file as Core Settings → WiFi (`/etc/netplan/60-kios
|
|||||||
|
|
||||||
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
|
**About Fn:** the Fn key is handled inside most keyboards and never reaches the computer, so it isn't part of the combo - holding it too does no harm where Fn+W is still W. On compact keyboards where Fn+W is something else (often Up arrow), leave Fn out.
|
||||||
|
|
||||||
**More than one WiFi adapter** (e.g. a built-in card and a USB dongle): a *WiFi adapter* dropdown appears at the top, and the status box lists every adapter with what it's connected to. Status, scan and connect act on the one you pick; by default it's the adapter that's connected right now.
|
The root part is `/usr/local/bin/kiosk-wifi-helper` (status/scan/connect only), which the kiosk user may run via sudo through `/etc/sudoers.d/kiosk-wifi` - nothing else. Existing kiosks get it with Advanced → Upgrade.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -776,10 +773,7 @@ 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
|
||||||
- `Ctrl+PageDown` / `Ctrl+PageUp` - Next/Previous tab (the standard browser keys)
|
- `Alt+Right/Left` - Next/Previous tab
|
||||||
- `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
|
||||||
@@ -1585,7 +1579,7 @@ full migration pass.
|
|||||||
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
- **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 (port 8888 for new installs, 8090 on older ones; no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
- **Web UI now installs by default** during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in — the Addons menu entry still works standalone for reconfiguring the port or reinstalling it on a kiosk provisioned before this change.
|
||||||
- **The web UI can now install/reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates** — the same four addons plus Update named directly. Every one of these is the exact same interactive `action_*` function the terminal menu already uses (no prompt/mutation refactor of any addon file), driven by piping the right answers on stdin — the same technique this project's own bash tests already use to drive these functions.
|
- **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.
|
||||||
|
|||||||
+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>:8888) or ./install.sh.
|
# the Web UI (http://<ip>:8090) or ./install.sh.
|
||||||
#
|
#
|
||||||
# Nothing in the original ISO is changed except /boot/grub/grub.cfg
|
# 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);
|
||||||
|
|||||||
+20
-181
@@ -1,6 +1,5 @@
|
|||||||
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');
|
||||||
@@ -60,8 +59,6 @@ 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;
|
||||||
@@ -104,11 +101,6 @@ 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
|
||||||
@@ -129,7 +121,6 @@ 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);
|
||||||
@@ -772,23 +763,6 @@ function rotateToNextSite(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address a site is loaded from: its configured URL, with the site's
|
|
||||||
// own login filled in (if it has one) and local files/folders turned into
|
|
||||||
// the kiosk's viewer addresses. Used when a site's view is first created
|
|
||||||
// and by the home button, so both always agree.
|
|
||||||
function buildTabUrl(t){
|
|
||||||
let url=t.url;
|
|
||||||
if(t.username&&t.password){
|
|
||||||
try{
|
|
||||||
const u=new URL(t.url);
|
|
||||||
u.username=t.username;
|
|
||||||
u.password=t.password;
|
|
||||||
url=u.toString();
|
|
||||||
}catch(e){}
|
|
||||||
}
|
|
||||||
return resolveLocalSite(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachView(i){
|
function attachView(i){
|
||||||
closeHTMLKeyboard();
|
closeHTMLKeyboard();
|
||||||
|
|
||||||
@@ -1170,75 +1144,6 @@ 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();
|
||||||
@@ -1310,56 +1215,7 @@ 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;
|
||||||
|
|
||||||
const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
|
function runWifiHelper(action,input){
|
||||||
|
|
||||||
// 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);}};
|
||||||
@@ -1376,11 +1232,12 @@ function runWifiHelperSudo(action,input){
|
|||||||
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
|
child.on('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(parseWifiReply(out));
|
finish(JSON.parse(line));
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Not our JSON - typically sudo refusing ("a password is required",
|
// Not our JSON - typically sudo refusing ("a password is required")
|
||||||
// or the "no new privileges" flag) - Upgrade installs the socket.
|
// on a kiosk set up before this feature: Upgrade installs the rule.
|
||||||
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'});
|
||||||
}
|
}
|
||||||
@@ -1390,12 +1247,6 @@ function runWifiHelperSudo(action,input){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runWifiHelper(action,input){
|
|
||||||
const viaSocket=await runWifiHelperSocket(action,input);
|
|
||||||
if(viaSocket!==null)return viaSocket;
|
|
||||||
return runWifiHelperSudo(action,input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetWifiIdleTimer(){
|
function resetWifiIdleTimer(){
|
||||||
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
|
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
|
||||||
wifiIdleTimer=setTimeout(()=>{
|
wifiIdleTimer=setTimeout(()=>{
|
||||||
@@ -1476,10 +1327,7 @@ 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==='ifaces')return runWifiHelper('ifaces');
|
if(action==='status'||action==='scan')return runWifiHelper(action);
|
||||||
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-status')return runWifiHelper('region-status');
|
||||||
if(action==='region-set'){
|
if(action==='region-set'){
|
||||||
const input={};
|
const input={};
|
||||||
@@ -1489,7 +1337,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
|
|||||||
}
|
}
|
||||||
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||''),iface:req.iface?String(req.iface):''});
|
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
|
||||||
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
|
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1558,7 +1406,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(),canLock:!!(enablePasswordProtection&&lockoutPassword)};
|
const powerInfo={version:VERSION,localIP:localIP,vpnIP:vpnIP.trim()};
|
||||||
// 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()){
|
||||||
@@ -1620,11 +1468,6 @@ 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>');
|
||||||
@@ -1644,7 +1487,16 @@ async function createWindow(){
|
|||||||
|
|
||||||
mainWindow.addBrowserView(view);
|
mainWindow.addBrowserView(view);
|
||||||
|
|
||||||
const url=buildTabUrl(t);
|
let url=t.url;
|
||||||
|
if(t.username&&t.password){
|
||||||
|
try{
|
||||||
|
const u=new URL(t.url);
|
||||||
|
u.username=t.username;
|
||||||
|
u.password=t.password;
|
||||||
|
url=u.toString();
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
url=resolveLocalSite(url);
|
||||||
|
|
||||||
const initialOrigin=new URL(t.url).origin;
|
const initialOrigin=new URL(t.url).origin;
|
||||||
|
|
||||||
@@ -1701,7 +1553,6 @@ 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);
|
||||||
@@ -1743,13 +1594,8 @@ 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();});
|
||||||
@@ -1939,17 +1785,10 @@ 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();});
|
||||||
// The standard browser keys for switching tabs. (Alt+Left/Right used to
|
globalShortcut.register('Alt+Right',()=>{nextTab();});
|
||||||
// switch sites; they are now page back/forward, below.)
|
globalShortcut.register('Alt+Left',()=>{prevTab();});
|
||||||
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,24 +115,6 @@ ipcRenderer.on('keyboard-button-enabled',(event,enabled)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// On-screen back / home / forward buttons (top middle, half transparent) - an
|
|
||||||
// option for screens with no keyboard or mouse side buttons.
|
|
||||||
let historyButtonsEnabled=false;
|
|
||||||
let historyBar=null;
|
|
||||||
let historyBarHideTimer=null;
|
|
||||||
let historyOpacity=0.5; // 0.1 - 1.0, from the "opacity" setting (percent)
|
|
||||||
const historyHoverOpacity=()=>Math.min(1,historyOpacity+0.4);
|
|
||||||
ipcRenderer.on('history-buttons-config',(event,cfg)=>{
|
|
||||||
historyButtonsEnabled=!!(cfg&&cfg.enabled===true);
|
|
||||||
const pct=Number(cfg&&cfg.opacity);
|
|
||||||
historyOpacity=(pct>=10&&pct<=100)?pct/100:0.5;
|
|
||||||
console.log('[HISTORY-BTN] Back/forward buttons enabled: '+historyButtonsEnabled+', opacity '+historyOpacity);
|
|
||||||
if(historyBar){
|
|
||||||
historyBar.style.opacity=String(historyOpacity);
|
|
||||||
if(!historyButtonsEnabled)historyBar.style.display='none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcRenderer.on('nav-button-enabled',(event,enabled)=>{
|
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);
|
||||||
@@ -271,57 +253,6 @@ 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;
|
||||||
@@ -666,16 +597,6 @@ 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;';
|
||||||
@@ -683,7 +604,6 @@ 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);
|
||||||
@@ -719,8 +639,6 @@ 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;
|
||||||
@@ -1000,23 +918,11 @@ 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=>{
|
||||||
|
|||||||
+8
-49
@@ -95,11 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="wifi-step" class="hidden">
|
<div id="wifi-step" class="hidden">
|
||||||
<div id="adapter-row" class="hidden">
|
<div id="status">Checking…</div>
|
||||||
<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>
|
||||||
@@ -143,7 +139,6 @@
|
|||||||
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 || {}))
|
||||||
@@ -168,47 +163,16 @@
|
|||||||
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 ad = fillAdapters(await request('ifaces'));
|
const s = await request('status');
|
||||||
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); };
|
||||||
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
|
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +181,7 @@
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
message('Scanning…', 'busy');
|
message('Scanning…', 'busy');
|
||||||
const list = $('networks');
|
const list = $('networks');
|
||||||
const r = await request('scan', adapter ? {iface: adapter} : {});
|
const r = await request('scan');
|
||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
message(r.error, 'error');
|
message(r.error, 'error');
|
||||||
@@ -253,7 +217,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, iface: adapter});
|
const r = await request('connect', {ssid, password});
|
||||||
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 = '';
|
||||||
@@ -323,19 +287,14 @@
|
|||||||
message('');
|
message('');
|
||||||
$('pin-step').classList.add('hidden');
|
$('pin-step').classList.add('hidden');
|
||||||
$('wifi-step').classList.remove('hidden');
|
$('wifi-step').classList.remove('hidden');
|
||||||
refreshStatus().then(() => scan());
|
refreshStatus();
|
||||||
loadRegion();
|
loadRegion();
|
||||||
|
scan();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('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-save').addEventListener('click', saveRegion);
|
||||||
$('region-from-locale').addEventListener('click', countryFromLocale);
|
$('region-from-locale').addEventListener('click', countryFromLocale);
|
||||||
$('connect').addEventListener('click', connect);
|
$('connect').addEventListener('click', connect);
|
||||||
|
|||||||
+2
-15
@@ -79,8 +79,6 @@ 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
|
||||||
@@ -205,7 +203,7 @@ load_existing_config() {
|
|||||||
ALLOW_NAVIGATION=$(sudo -u "$KIOSK_USER" jq -r '.allowNavigation // "same-origin"' "$CONFIG_PATH" 2>/dev/null || echo "same-origin")
|
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 history_btn password_enabled boot_password
|
local pause_btn keyboard_btn nav_btn password_enabled boot_password
|
||||||
pause_btn=$(sudo -u "$KIOSK_USER" jq -r '.enablePauseButton // true' "$CONFIG_PATH" 2>/dev/null)
|
pause_btn=$(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"
|
||||||
|
|
||||||
@@ -215,12 +213,6 @@ 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"
|
||||||
|
|
||||||
@@ -271,9 +263,6 @@ 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"
|
||||||
|
|
||||||
@@ -312,8 +301,6 @@ 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}" \
|
||||||
@@ -327,7 +314,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,enableHistoryButtons:$enableHistoryButtons,historyButtonsOpacity:$historyButtonsOpacity,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
|
'. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp"
|
||||||
|
|
||||||
if [[ ${#URLS[@]} -gt 0 ]]; then
|
if [[ ${#URLS[@]} -gt 0 ]]; then
|
||||||
for idx in "${!URLS[@]}"; do
|
for idx in "${!URLS[@]}"; do
|
||||||
|
|||||||
+2
-24
@@ -198,12 +198,7 @@ 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
|
||||||
# Only on first install: this file is also where Advanced -> Virtual
|
provision_install_file "etc/X11/xorg.conf.d/10-serverflags.conf" /etc/X11/xorg.conf.d/10-serverflags.conf
|
||||||
# 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
|
||||||
@@ -247,23 +242,6 @@ provision_install_wifi_helper() {
|
|||||||
log_warning "Couldn't set up the kiosk WiFi screen's permission - Ctrl+Alt+Super+W won't be able to connect"
|
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
|
||||||
@@ -328,7 +306,7 @@ provision_configure_webui() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local port="8888"
|
local port="8090"
|
||||||
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
|
||||||
|
|||||||
@@ -3,3 +3,6 @@
|
|||||||
# Extra CA certificates dropped in for building behind a proxy
|
# Extra CA certificates dropped in for building behind a proxy
|
||||||
koha/certs/*.crt
|
koha/certs/*.crt
|
||||||
quickadd/certs/*.crt
|
quickadd/certs/*.crt
|
||||||
|
# Generated by install.sh --domain
|
||||||
|
Caddyfile
|
||||||
|
caddy-root.crt
|
||||||
|
|||||||
+123
-5
@@ -1,4 +1,4 @@
|
|||||||
# Home library: Koha + Quick Add (Docker)
|
# Room for Books: a home library (Koha + the Room for Books site, in Docker)
|
||||||
|
|
||||||
A self-hosted library catalogue you can set up in one command:
|
A self-hosted library catalogue you can set up in one command:
|
||||||
|
|
||||||
@@ -12,12 +12,19 @@ it never overwrites your `.env` (passwords) or your data.
|
|||||||
|
|
||||||
| What | Address | Login |
|
| What | Address | Login |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Catalogue** (public search, no login) | `http://<this machine>:8080` | none |
|
| **Your library**: add books, find a book, people, lending, labels, settings | `http://<this machine>:8098` | `admin` and the generated password |
|
||||||
| **Staff interface** (cataloguing, labels, Z39.50) | `http://<this machine>:8081` | `admin` and the generated password |
|
| **Add books** (scan an ISBN, or use the phone camera) | `http://<this machine>:8098/add/` | same login (asked once more) |
|
||||||
| **Add books** (scan an ISBN) | `http://<this machine>:8082` | same login |
|
| **Public catalogue** (search only, for a kiosk or visitors) | `http://<this machine>:8097` | none |
|
||||||
|
|
||||||
|
For home use you only need the first address: sign in to **Your library**, and use the **Add books**
|
||||||
|
button on its home page. The Add books page has a **Find a book** tab (title, author or ISBN, with the
|
||||||
|
shelf and whether it is on the shelf or checked out) and a **Manage** link back to the staff site. The
|
||||||
|
public catalogue exists for a kiosk screen or visitors and does not need a login. (Koha gives its public and
|
||||||
|
staff sites the same URL shapes, so they cannot share one address; Add books can, and does.)
|
||||||
|
|
||||||
Ports move up to the next free number if one is taken; the final addresses are printed
|
Ports move up to the next free number if one is taken; the final addresses are printed
|
||||||
at the end and by `sudo ./install.sh --status`.
|
at the end and by `sudo ./install.sh --status`. Add books is also reachable directly on its own port
|
||||||
|
(`8099`), which is handy behind a proxy but not needed otherwise.
|
||||||
|
|
||||||
## What it is made of
|
## What it is made of
|
||||||
|
|
||||||
@@ -196,3 +203,114 @@ koha/ Dockerfile (Koha from the project's Debian repo), entrypoi
|
|||||||
servers, label templates/layouts)
|
servers, label templates/layouts)
|
||||||
quickadd/ the scan-to-add service (Python standard library + yaz)
|
quickadd/ the scan-to-add service (Python standard library + yaz)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Signing in once
|
||||||
|
|
||||||
|
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
|
||||||
|
|
||||||
|
## The home page (the Room for Books site at `/add/`)
|
||||||
|
|
||||||
|
**One address.** `http://<ip>:<port>/` (and later `https://your-domain/` behind Caddy) opens this page; there is no separate
|
||||||
|
port to remember. Koha's own staff screens live on the same address at `/cgi-bin/koha/mainpage.pl`, reached from the menu
|
||||||
|
(*Koha (full staff screens)*). Everyone signs in at the same prompt: staff with the admin login, borrowers with their card number
|
||||||
|
and password, the guest with the guest login.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Same ports on every install
|
||||||
|
|
||||||
|
New installs of this stack use the same three ports whether you install it from the kiosk add-on or from `ubuntu-post-install`:
|
||||||
|
**8098** is the one to use (the Room for Books home page, with Koha behind the menu), 8099 is Add books direct, and 8097 is the
|
||||||
|
public catalogue, which stays closed. If one is taken, the next free port is used and the installer prints it. Existing installs
|
||||||
|
keep the ports they already have.
|
||||||
|
|
||||||
|
## HTTPS with Caddy (and the live camera scanner)
|
||||||
|
|
||||||
|
Phones only allow a live camera on an HTTPS page. One command adds Caddy in front of the library:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# a real domain that points at this machine (ports 80 and 443 reachable): the certificate is automatic
|
||||||
|
sudo ./install.sh --domain library.example.com
|
||||||
|
|
||||||
|
# a name only your own network knows (library.home.lan): Caddy makes its own certificate authority
|
||||||
|
sudo ./install.sh --domain library.home.lan --lan-tls # then install caddy-root.crt on your phones
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes `Caddyfile`, starts a `caddy` container (the compose `caddy` profile), sets `STAFF_URL=https://<name>` and closes the plain-HTTP
|
||||||
|
ports to the network (`BIND_ADDR=127.0.0.1`; add `--keep-direct` to leave them open). Then use `https://<name>/`: everything works
|
||||||
|
as before, and the **Live scan** buttons appear on Add books and Lend / return. Behind your own Caddy instead, point it at the library
|
||||||
|
port: `library.example.com { reverse_proxy <this machine>:8098 }` and set `STAFF_URL=https://library.example.com` in `.env`.
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
quickadd:
|
||||||
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
|
KOHA_QUICKADD_URL: http://quickadd:8082
|
||||||
|
KOHA_SSO_SECRET: ${SSO_SECRET:-}
|
||||||
KOHA_DB_HOST: db
|
KOHA_DB_HOST: db
|
||||||
KOHA_DB_PASS: ${DB_PASSWORD}
|
KOHA_DB_PASS: ${DB_PASSWORD}
|
||||||
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
KOHA_ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
@@ -41,8 +45,9 @@ services:
|
|||||||
KOHA_SHELVES: ${SHELVES:-}
|
KOHA_SHELVES: ${SHELVES:-}
|
||||||
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
|
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
|
||||||
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8097}:80"
|
||||||
|
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8098}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- koha-data:/var/lib/koha
|
- koha-data:/var/lib/koha
|
||||||
|
|
||||||
@@ -53,26 +58,45 @@ services:
|
|||||||
image: library-quickadd:local
|
image: library-quickadd:local
|
||||||
container_name: library-quickadd
|
container_name: library-quickadd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
koha:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
KOHA_API: http://koha:8081/api/v1
|
KOHA_API: http://koha:8081/api/v1
|
||||||
|
SSO_SECRET: ${SSO_SECRET:-}
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||||
|
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
|
||||||
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||||
OPAC_PORT: ${OPAC_PORT:-8080}
|
OPAC_PORT: ${OPAC_PORT:-8097}
|
||||||
STAFF_PORT: ${STAFF_PORT:-8081}
|
STAFF_PORT: ${STAFF_PORT:-8098}
|
||||||
OPAC_URL: ${OPAC_URL:-}
|
OPAC_URL: ${OPAC_URL:-}
|
||||||
STAFF_URL: ${STAFF_URL:-}
|
STAFF_URL: ${STAFF_URL:-}
|
||||||
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
|
QUICKADD_SOURCES: ${QUICKADD_SOURCES:-z3950:lx2.loc.gov:210/LCDB,openlibrary}
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8082}:8082"
|
- "${BIND_ADDR:-0.0.0.0}:${QUICKADD_PORT:-8099}:8082"
|
||||||
volumes:
|
volumes:
|
||||||
- quickadd-data:/data
|
- quickadd-data:/data
|
||||||
|
|
||||||
|
# HTTPS with an automatic certificate (and so the live camera on phones): only started when
|
||||||
|
# `./install.sh --domain <name>` has set COMPOSE_PROFILES=caddy in .env.
|
||||||
|
caddy:
|
||||||
|
profiles: ["caddy"]
|
||||||
|
image: caddy:2
|
||||||
|
container_name: library-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- koha
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
koha-data:
|
koha-data:
|
||||||
quickadd-data:
|
quickadd-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
|||||||
+88
-10
@@ -5,13 +5,16 @@
|
|||||||
# sudo ./install.sh --status # show URLs and container health
|
# sudo ./install.sh --status # show URLs and container health
|
||||||
# sudo ./install.sh --update # rebuild with the latest Koha and restart
|
# sudo ./install.sh --update # rebuild with the latest Koha and restart
|
||||||
# sudo ./install.sh --stop | --start
|
# sudo ./install.sh --stop | --start
|
||||||
|
# sudo ./install.sh --domain library.example.com # HTTPS via Caddy (certificate is automatic)
|
||||||
|
# sudo ./install.sh --domain library.home.lan --lan-tls # HTTPS for a name only your network knows (own CA)
|
||||||
|
# add --keep-direct to also keep the plain-HTTP ports open on the network
|
||||||
#
|
#
|
||||||
# Optional environment variables:
|
# Optional environment variables:
|
||||||
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
|
# LIBRARY_NAME="Smith Family Library" name shown on the catalogue
|
||||||
# LIBRARY_CODE=HOME short code for the library (letters/digits)
|
# LIBRARY_CODE=HOME short code for the library (letters/digits)
|
||||||
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
|
# SHELVES="LR=Living room;BR=Bedroom" shelves/rooms to offer (first install only)
|
||||||
# ITEM_TYPES="CD=Compact disc" extra item types (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)
|
# OPAC_PORT=8097 STAFF_PORT=8098 preferred host ports (next free is used)
|
||||||
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
|
# EXTRA_CA=/path/to/proxy-ca.crt trust an extra CA while building
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -79,13 +82,67 @@ show_status() {
|
|||||||
fi
|
fi
|
||||||
dc ps
|
dc ps
|
||||||
echo
|
echo
|
||||||
echo " Catalogue (public): http://$ip:$OPAC_PORT"
|
if [ -n "${DOMAIN:-}" ]; then
|
||||||
echo " Staff interface: http://$ip:$STAFF_PORT"
|
echo " Your library (HTTPS): https://$DOMAIN <- use this one: the live camera scanner needs it"
|
||||||
echo " Add books (scan): http://$ip:${QUICKADD_PORT:-8082} (same login)"
|
echo " (Koha's own screens: https://$DOMAIN/cgi-bin/koha/mainpage.pl, or the menu inside)"
|
||||||
echo " Staff login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
fi
|
||||||
|
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
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- HTTPS (Caddy) options --------------------------------------------------------
|
||||||
|
DOMAIN_ARG="" LAN_TLS=0 KEEP_DIRECT=0 ACTION=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--domain) DOMAIN_ARG="${2:-}"; [ -n "$DOMAIN_ARG" ] || { c_err "--domain needs a name"; exit 1; }; shift 2 ;;
|
||||||
|
--lan-tls) LAN_TLS=1; shift ;;
|
||||||
|
--keep-direct) KEEP_DIRECT=1; shift ;;
|
||||||
|
*) ACTION="${ACTION:+$ACTION }$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- $ACTION
|
||||||
|
if [ -n "$DOMAIN_ARG" ] && ! printf '%s' "$DOMAIN_ARG" | grep -Eq '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$'; then
|
||||||
|
c_err "That does not look like a host name: $DOMAIN_ARG"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write (or replace) KEY=VALUE in .env
|
||||||
|
env_set() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; }
|
||||||
|
|
||||||
|
configure_caddy() {
|
||||||
|
local name="$DOMAIN_ARG"
|
||||||
|
# Caddy needs 80 and 443 (it is not moved to other ports: browsers expect these).
|
||||||
|
if ! dc ps --status running --services 2>/dev/null | grep -qx caddy; then
|
||||||
|
for p in 80 443; do
|
||||||
|
port_in_use "$p" && { c_err "Port $p is already in use on this machine - Caddy needs it. Stop what is using it, or use your own reverse proxy (see README)."; exit 1; }
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "# Generated by install.sh --domain - Caddy fetches and renews the certificate by itself."
|
||||||
|
echo "$name {"
|
||||||
|
[ "$LAN_TLS" = 1 ] && echo " tls internal"
|
||||||
|
echo " encode zstd gzip"
|
||||||
|
echo " reverse_proxy koha:8081"
|
||||||
|
echo "}"
|
||||||
|
} > Caddyfile
|
||||||
|
env_set DOMAIN "$name"
|
||||||
|
env_set COMPOSE_PROFILES caddy
|
||||||
|
env_set STAFF_URL "https://$name"
|
||||||
|
[ "$KEEP_DIRECT" = 1 ] || env_set BIND_ADDR 127.0.0.1
|
||||||
|
c_ok "Caddy will serve https://$name"
|
||||||
|
if [ "$LAN_TLS" = 1 ]; then
|
||||||
|
c_info "Own-CA certificate: phones must trust Caddy's root, which is copied to $HERE/caddy-root.crt after start."
|
||||||
|
else
|
||||||
|
c_info "The name must point at this machine (DNS) and ports 80/443 must reach it, or the certificate cannot be issued."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
--status) show_status; exit 0 ;;
|
--status) show_status; exit 0 ;;
|
||||||
--stop) dc stop; exit 0 ;;
|
--stop) dc stop; exit 0 ;;
|
||||||
@@ -113,12 +170,18 @@ LIBRARY_CODE=${LIBRARY_CODE:-HOME}
|
|||||||
SHELVES="${SHELVES:-}"
|
SHELVES="${SHELVES:-}"
|
||||||
ITEM_TYPES="${ITEM_TYPES:-}"
|
ITEM_TYPES="${ITEM_TYPES:-}"
|
||||||
TZ=${TZ_NOW}
|
TZ=${TZ_NOW}
|
||||||
OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
|
OPAC_PORT=$(free_port "${OPAC_PORT:-8097}")
|
||||||
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
STAFF_PORT=$(free_port "${STAFF_PORT:-8098}")
|
||||||
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8099}")
|
||||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
|
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
|
||||||
|
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=$(rand 16)
|
ADMIN_PASSWORD=$(rand 16)
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=$(rand 14)
|
||||||
|
# Lets the library site vouch for a signed-in staff user to the Add books page (no second login).
|
||||||
|
SSO_SECRET=$(rand 40)
|
||||||
DB_ROOT_PASSWORD=$(rand 32)
|
DB_ROOT_PASSWORD=$(rand 32)
|
||||||
DB_PASSWORD=$(rand 32)
|
DB_PASSWORD=$(rand 32)
|
||||||
KOHA_SUITE=stable
|
KOHA_SUITE=stable
|
||||||
@@ -129,13 +192,21 @@ else
|
|||||||
c_info "Re-using existing .env"
|
c_info "Re-using existing .env"
|
||||||
# Settings added in later versions: append them to an older .env.
|
# Settings added in later versions: append them to an older .env.
|
||||||
if ! grep -q '^QUICKADD_PORT=' .env; then
|
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||||
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
echo "QUICKADD_PORT=$(free_port 8099)" >> .env
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||||
|
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||||
|
fi
|
||||||
|
grep -q '^SSO_SECRET=' .env || echo "SSO_SECRET=$(rand 40)" >> .env
|
||||||
|
# Existing installs keep their current public-catalogue behaviour.
|
||||||
|
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
||||||
if ! grep -q '^BIND_ADDR=' .env; then
|
if ! grep -q '^BIND_ADDR=' .env; then
|
||||||
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[ -n "$DOMAIN_ARG" ] && configure_caddy
|
||||||
|
|
||||||
if [ -n "${EXTRA_CA:-}" ]; then
|
if [ -n "${EXTRA_CA:-}" ]; then
|
||||||
cp "$EXTRA_CA" koha/certs/extra-ca.crt
|
cp "$EXTRA_CA" koha/certs/extra-ca.crt
|
||||||
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
|
cp "$EXTRA_CA" quickadd/certs/extra-ca.crt
|
||||||
@@ -154,7 +225,7 @@ with_retry dc up -d
|
|||||||
c_info "First start installs the database - waiting until Koha reports healthy..."
|
c_info "First start installs the database - waiting until Koha reports healthy..."
|
||||||
for i in $(seq 1 180); do
|
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)"
|
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
|
if [ "$health" = healthy ] && curl -fsS -o /dev/null -m 5 "http://127.0.0.1:${QUICKADD_PORT:-8099}/healthz" 2>/dev/null; then READY=1; break; fi
|
||||||
# One restart is tolerated (Docker's restart policy); a crash loop is not.
|
# 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
|
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
|
c_err "library-koha keeps crashing - see: docker logs library-koha"; exit 1
|
||||||
@@ -164,5 +235,12 @@ done
|
|||||||
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
|
[ "${READY:-0}" = 1 ] || { c_err "Timed out - see: docker logs -f library-koha"; exit 1; }
|
||||||
|
|
||||||
c_ok "Your library is running."
|
c_ok "Your library is running."
|
||||||
|
if [ -n "${DOMAIN:-}" ] && grep -q '^ tls internal' Caddyfile 2>/dev/null; then
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
docker cp library-caddy:/data/caddy/pki/authorities/local/root.crt "$HERE/caddy-root.crt" >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -f "$HERE/caddy-root.crt" ] && c_info "Install this certificate on phones/computers that should trust https://$DOMAIN: $HERE/caddy-root.crt"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
show_status
|
show_status
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
|
|||||||
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||||
done
|
done
|
||||||
|
|
||||||
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
|
# The endpoint Quick Add uses to recognise a signed-in staff user (see the file header).
|
||||||
|
COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session
|
||||||
|
# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it)
|
||||||
|
COPY quickadd-return.pl quickadd-value.pl /usr/share/koha/intranet/cgi-bin/
|
||||||
|
RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-value.pl
|
||||||
|
|
||||||
|
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||||
COPY seed/ /opt/koha-init/seed/
|
COPY seed/ /opt/koha-init/seed/
|
||||||
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||||
|
|
||||||
|
|||||||
@@ -160,11 +160,11 @@ my %prefs = (
|
|||||||
finesMode => 'off', # a home library does not charge overdue fines
|
finesMode => 'off', # a home library does not charge overdue fines
|
||||||
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
||||||
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
||||||
|
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
|
||||||
OpenLibraryCovers => 1, # cover images in the catalogue
|
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||||
OPACSearchForTitleIn => '',
|
OPACSearchForTitleIn => '',
|
||||||
BiblioDefaultView => 'normal',
|
BiblioDefaultView => 'normal',
|
||||||
OpacPublic => 1,
|
opacuserlogin => 1,
|
||||||
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
|
|
||||||
OpacMaintenance => 0,
|
OpacMaintenance => 0,
|
||||||
ExpireReservesMaxPickUpDelay => 0,
|
ExpireReservesMaxPickUpDelay => 0,
|
||||||
AllowOfflineCirculation => 0,
|
AllowOfflineCirculation => 0,
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
}
|
||||||
@@ -56,12 +56,43 @@ if [ ! -d "/etc/koha/sites/$INST" ]; then
|
|||||||
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
-e 's|^#\s*Include /etc/koha/apache-shared-intranet-plack.conf| Include /etc/koha/apache-shared-intranet-plack.conf|' "$SITE"
|
||||||
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
koha-plack --enable "$INST" >/dev/null 2>&1 || true # koha-create may already have enabled it
|
||||||
|
|
||||||
# Run Apache itself as the instance user instead of mpm_itk's per-vhost
|
# One address: serve the Add books page (Quick Add) inside the staff site at /add/.
|
||||||
# uid switching (which needs extra capabilities and is pointless here:
|
# (The staff and public catalogue sites cannot share one address: Koha gives both the
|
||||||
# Plack does the real work as that user already).
|
# same URL shapes. The staff site is the natural home for a home library.)
|
||||||
|
if [ -n "${KOHA_QUICKADD_URL:-}" ]; then
|
||||||
|
# Single sign-on for staff: when KOHA_SSO_SECRET is set, this web server tells Quick Add (via a
|
||||||
|
# secret header nobody outside can forge: it is overwritten here on every request) that the
|
||||||
|
# request came through the staff site, and Quick Add then asks Koha whether the browser's login
|
||||||
|
# cookie belongs to a staff user who may edit the catalogue. Without the secret, or if anything
|
||||||
|
# about that fails, Quick Add just asks for its own login as before.
|
||||||
|
SSO_BLOCK=""
|
||||||
|
if [ -n "${KOHA_SSO_SECRET:-}" ]; then
|
||||||
|
install -m 0640 -o root -g "$INST-koha" /dev/null /etc/koha/quickadd-sso.secret
|
||||||
|
printf '%s' "$KOHA_SSO_SECRET" > /etc/koha/quickadd-sso.secret
|
||||||
|
SSO_BLOCK=" <IfModule mod_headers.c>
|
||||||
|
<Location /add/>
|
||||||
|
RequestHeader set X-Quickadd-Proxy \"$KOHA_SSO_SECRET\"
|
||||||
|
</Location>
|
||||||
|
</IfModule>"
|
||||||
|
fi
|
||||||
|
cat > /etc/koha/apache-quickadd.conf << APACHE
|
||||||
|
<IfModule mod_proxy_http.c>
|
||||||
|
RedirectMatch ^/add\$ /add/
|
||||||
|
# The bare address (http://ip:port/ or https://your-domain/) opens the Room for Books home page;
|
||||||
|
# Koha's own staff screens stay at /cgi-bin/koha/mainpage.pl.
|
||||||
|
RedirectMatch ^/\$ /add/
|
||||||
|
ProxyPass /add/ ${KOHA_QUICKADD_URL%/}/
|
||||||
|
ProxyPassReverse /add/ ${KOHA_QUICKADD_URL%/}/
|
||||||
|
${SSO_BLOCK}
|
||||||
|
</IfModule>
|
||||||
|
APACHE
|
||||||
|
sed -i 's|^\(\s*\)Include /etc/koha/apache-shared-intranet.conf|&\n\1Include /etc/koha/apache-quickadd.conf|' "/etc/apache2/sites-available/$INST.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
# koha-create insists on mpm_itk being enabled, but inside a container it can
|
# koha-create insists on mpm_itk being enabled, but inside a container it can
|
||||||
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
# not switch users (initgroups: Operation not permitted -> HTTP 500 on every
|
||||||
# page). Now that the instance exists, swap to plain prefork.
|
# page). Now that the instance exists, swap to plain prefork; Apache runs as the
|
||||||
|
# instance user (Plack does the real work as that user already).
|
||||||
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
rm -f /etc/apache2/mods-enabled/mpm_itk.*
|
||||||
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
a2enmod mpm_prefork >/dev/null 2>&1 || true
|
||||||
sed -i '/AssignUserID/d' "$SITE"
|
sed -i '/AssignUserID/d' "$SITE"
|
||||||
@@ -94,6 +125,11 @@ else
|
|||||||
FIRST_RUN=0
|
FIRST_RUN=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
|
||||||
|
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||||
|
perl /opt/koha-init/ensure.pl \
|
||||||
|
|| log "WARNING: ensure.pl reported a problem (see above)"
|
||||||
|
|
||||||
# --- 5. background services (their `daemon` processes die with the container,
|
# --- 5. background services (their `daemon` processes die with the container,
|
||||||
# so they are started explicitly on every start) ------------------------
|
# so they are started explicitly on every start) ------------------------
|
||||||
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||||
|
|||||||
@@ -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 => "$@" } );
|
||||||
@@ -5,7 +5,7 @@ FROM debian:bookworm-slim
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools tzdata \
|
&& apt-get install -y --no-install-recommends ca-certificates python3 yaz zbar-tools qrencode tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
# Optional: drop extra CA certificates (*.crt) into ./certs/ before building if
|
||||||
@@ -13,7 +13,7 @@ RUN apt-get update \
|
|||||||
COPY certs/ /usr/local/share/ca-certificates/extra/
|
COPY certs/ /usr/local/share/ca-certificates/extra/
|
||||||
RUN update-ca-certificates
|
RUN update-ca-certificates
|
||||||
|
|
||||||
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null
|
RUN command -v yaz-client >/dev/null && command -v yaz-marcdump >/dev/null && command -v python3 >/dev/null && command -v zbarimg >/dev/null && command -v qrencode >/dev/null
|
||||||
|
|
||||||
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
RUN useradd --system --uid 10001 --home-dir /app quickadd \
|
||||||
&& mkdir -p /app /data && chown quickadd /data
|
&& mkdir -p /app /data && chown quickadd /data
|
||||||
|
|||||||
Binary file not shown.
+1118
-17
File diff suppressed because it is too large
Load Diff
+956
-19
File diff suppressed because it is too large
Load Diff
+37
-27
@@ -1,29 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
################################################################################
|
################################################################################
|
||||||
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||||
# catalogue (Koha) running in Docker, shown on the kiosk as a public
|
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
|
||||||
# search terminal.
|
|
||||||
#
|
#
|
||||||
# What gets installed (all from this checkout's library/ directory, deployed to
|
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||||
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||||
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
|
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
|
||||||
# interface (cataloguing, Z39.50 lookup, label printing).
|
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
|
||||||
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
|
|
||||||
# - MariaDB.
|
# - MariaDB.
|
||||||
# Docker itself is installed on demand (lib/docker.sh).
|
# Docker itself is installed on demand (lib/docker.sh).
|
||||||
#
|
#
|
||||||
# On the kiosk the catalogue is added as one of the Sites (tabs) with
|
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
|
||||||
# duration 0 (stay put - it's a search terminal, not a rotating display).
|
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
|
||||||
# Staff and Quick Add are meant for the librarian's own device; whether they
|
# all, because Koha's own search permission also allows editing label layouts); the page
|
||||||
# are reachable from the LAN or only from this kiosk is the "Network access"
|
# list stores that guest login like any other Basic Auth site. The admin login never
|
||||||
# choice below. NOTE: Docker publishes ports straight through iptables and
|
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
|
||||||
# bypasses ufw, so "this kiosk only" is enforced by binding the published
|
#
|
||||||
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
|
# Whether other devices on the LAN can reach the library site is the "Network access"
|
||||||
|
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
|
||||||
|
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
|
||||||
|
# (BIND_ADDR in library/.env), not by firewall rules.
|
||||||
#
|
#
|
||||||
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
|
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Detection / state
|
# Detection / state
|
||||||
@@ -42,7 +43,13 @@ library_is_running() {
|
|||||||
[[ "$(docker_container_state library-koha)" == "running" ]]
|
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The page the kiosk shows: Quick Add's Find a book view on the library site.
|
||||||
library_catalogue_url() {
|
library_catalogue_url() {
|
||||||
|
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages from earlier versions of this add-on pointed at the public catalogue.
|
||||||
|
library_old_catalogue_url() {
|
||||||
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,19 +73,19 @@ addon_library_status() {
|
|||||||
health="$(docker_container_health library-koha)"
|
health="$(docker_container_health library-koha)"
|
||||||
ip="$(get_ip_address)"
|
ip="$(get_ip_address)"
|
||||||
echo "Library Catalog: ${state}${health:+ ($health)}"
|
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||||
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
|
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
|
||||||
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
echo " Network: this kiosk only"
|
echo " Network: this kiosk only"
|
||||||
else
|
else
|
||||||
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
|
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
addon_library_menu_builder() {
|
addon_library_menu_builder() {
|
||||||
if library_is_installed; then
|
if library_is_installed; then
|
||||||
load_existing_config
|
load_existing_config
|
||||||
local tab_label="Show the catalogue on the kiosk"
|
local tab_label="Show Find a book on the kiosk"
|
||||||
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
|
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
|
||||||
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||||
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||||
else
|
else
|
||||||
@@ -111,13 +118,13 @@ library_sync_files() {
|
|||||||
library_add_tab() {
|
library_add_tab() {
|
||||||
load_existing_config
|
load_existing_config
|
||||||
if library_tab_exists; then
|
if library_tab_exists; then
|
||||||
log_info "The catalogue is already one of the kiosk's pages."
|
log_info "Find a book is already one of the kiosk's pages."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
URLS+=("$(library_catalogue_url)")
|
URLS+=("$(library_catalogue_url)")
|
||||||
DURS+=("0")
|
DURS+=("0")
|
||||||
USERS+=("")
|
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
|
||||||
PASSES+=("")
|
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
|
||||||
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||||
save_config
|
save_config
|
||||||
}
|
}
|
||||||
@@ -125,11 +132,12 @@ library_add_tab() {
|
|||||||
# Drop every page that points at the catalogue's local address.
|
# Drop every page that points at the catalogue's local address.
|
||||||
library_remove_tab() {
|
library_remove_tab() {
|
||||||
load_existing_config
|
load_existing_config
|
||||||
local url idx
|
local url old idx
|
||||||
url="$(library_catalogue_url)"
|
url="$(library_catalogue_url)"
|
||||||
|
old="$(library_old_catalogue_url)"
|
||||||
local -a nu=() nd=() nus=() np=() nn=()
|
local -a nu=() nd=() nus=() np=() nn=()
|
||||||
for idx in "${!URLS[@]}"; do
|
for idx in "${!URLS[@]}"; do
|
||||||
[[ "${URLS[$idx]}" == "$url" ]] && continue
|
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
|
||||||
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||||
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||||
done
|
done
|
||||||
@@ -199,7 +207,7 @@ action_library_install() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
|
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
|
||||||
library_add_tab
|
library_add_tab
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
@@ -217,10 +225,12 @@ action_library_show_access() {
|
|||||||
ip="localhost"
|
ip="localhost"
|
||||||
echo "(Network access is limited to this kiosk.)"
|
echo "(Network access is limited to this kiosk.)"
|
||||||
fi
|
fi
|
||||||
echo " Catalogue (public search): http://${ip}:$(library_env_get OPAC_PORT)/"
|
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
echo " Staff interface: http://${ip}:$(library_env_get STAFF_PORT)/"
|
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
||||||
echo " Add books (scan ISBNs): http://${ip}:$(library_env_get QUICKADD_PORT)/"
|
echo " Admin login: ${user} / ${pass}"
|
||||||
echo " Login (staff and add books): ${user} / ${pass}"
|
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
|
||||||
|
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
|
||||||
|
echo " it cannot add or change anything (and it has no login on the staff site itself)."
|
||||||
echo
|
echo
|
||||||
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -59,9 +59,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 "8888"
|
sudo grep -oP '^PORT=\K.*' "$WEBUI_ENV_DIR/webui.env" 2>/dev/null || echo "8090"
|
||||||
else
|
else
|
||||||
echo "8888"
|
echo "8090"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ action_webui_install() {
|
|||||||
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" "8888" 1024 65535)
|
port=$(ask_integer "Port to listen on" "8090" 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,8 +321,6 @@ 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,18 +121,6 @@ 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
|
||||||
|
|||||||
@@ -112,8 +112,6 @@ 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,7 +20,6 @@ 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() {
|
||||||
@@ -30,8 +29,6 @@ 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
|
||||||
@@ -39,8 +36,6 @@ 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,31 +131,3 @@ action_toggle_nav_button() {
|
|||||||
fi
|
fi
|
||||||
save_config
|
save_config
|
||||||
}
|
}
|
||||||
|
|
||||||
# Page back/forward: Alt+Left / Alt+Right and the mouse's side buttons
|
|
||||||
# always work; this adds clickable buttons (top middle, half transparent,
|
|
||||||
# shown on mouse movement or a tap) for screens with no keyboard or mouse
|
|
||||||
# buttons. Off by default.
|
|
||||||
action_toggle_history_buttons() {
|
|
||||||
if [[ "$ENABLE_HISTORY_BUTTONS" == "true" ]]; then
|
|
||||||
ENABLE_HISTORY_BUTTONS="false"
|
|
||||||
log_warning "On-screen back/home/forward buttons disabled"
|
|
||||||
else
|
|
||||||
ENABLE_HISTORY_BUTTONS="true"
|
|
||||||
log_success "On-screen back/home/forward buttons enabled"
|
|
||||||
fi
|
|
||||||
save_config
|
|
||||||
}
|
|
||||||
|
|
||||||
# How see-through the back/forward buttons are: 100 = solid, 10 = faint.
|
|
||||||
# They get a little more solid while the mouse is over them.
|
|
||||||
action_set_history_opacity() {
|
|
||||||
echo
|
|
||||||
echo "How solid the on-screen back/home/forward buttons look (10 = faint, 100 = solid)."
|
|
||||||
HISTORY_BUTTONS_OPACITY=$(ask_integer "Opacity percent" "$HISTORY_BUTTONS_OPACITY" 10 100)
|
|
||||||
save_config
|
|
||||||
log_success "Back/home/forward button opacity: ${HISTORY_BUTTONS_OPACITY}%"
|
|
||||||
if [[ "$ENABLE_HISTORY_BUTTONS" != "true" ]]; then
|
|
||||||
log_info "The buttons are off - turn them on with the entry above."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ Section "ServerFlags"
|
|||||||
# Disable Ctrl+Alt+Backspace (X server kill)
|
# Disable Ctrl+Alt+Backspace (X server kill)
|
||||||
Option "DontZap" "true"
|
Option "DontZap" "true"
|
||||||
|
|
||||||
# Allow VT switching (Ctrl+Alt+F1-F8): virtual consoles are ENABLED by
|
# Disable VT switching (Ctrl+Alt+F1-F12)
|
||||||
# default, as the installer says. Advanced -> Virtual Consoles (or the
|
Option "DontVTSwitch" "true"
|
||||||
# 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"
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Kiosk WiFi helper socket (used by the kiosk app's WiFi screen)
|
|
||||||
|
|
||||||
[Socket]
|
|
||||||
ListenStream=/run/kiosk-wifi-helper.sock
|
|
||||||
SocketUser=root
|
|
||||||
# Replaced with the kiosk user's group when installed (lib/provision.sh).
|
|
||||||
SocketGroup=@KIOSK_GROUP@
|
|
||||||
SocketMode=0660
|
|
||||||
Accept=yes
|
|
||||||
MaxConnections=4
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Kiosk WiFi helper (one request)
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/local/bin/kiosk-wifi-helper-socket
|
|
||||||
StandardInput=socket
|
|
||||||
StandardOutput=socket
|
|
||||||
StandardError=journal
|
|
||||||
# A connect waits up to ~30s for an address, plus netplan; never run forever.
|
|
||||||
RuntimeMaxSec=180
|
|
||||||
@@ -4,12 +4,10 @@
|
|||||||
# (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 app reaches it through a root-owned socket
|
# The kiosk user may run this one script via passwordless sudo
|
||||||
# (kiosk-wifi-helper.socket, group kiosk; see kiosk-wifi-helper-socket) -
|
# (/etc/sudoers.d/kiosk-wifi, written by lib/provision.sh) - it does a
|
||||||
# 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"}
|
||||||
@@ -19,10 +17,8 @@
|
|||||||
# WiFi country (regulatory domain), system
|
# WiFi country (regulatory domain), system
|
||||||
# locale and keyboard layout; every field optional
|
# locale and keyboard layout; every field optional
|
||||||
#
|
#
|
||||||
# Every reply is one JSON line on stdout, exit 0 on success. status, scan
|
# Every reply is one JSON line on stdout, exit 0 on success. connect
|
||||||
# and connect accept an optional {"iface": "<adapter>"} in the stdin JSON
|
# writes the same netplan file as Core Settings -> WiFi (menus/wifi.sh,
|
||||||
# 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
|
||||||
@@ -172,76 +168,8 @@ default_country() {
|
|||||||
echo "${c^^}"
|
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() {
|
||||||
if [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
|
IFACE=$(prefer_configured_iface || detect_iface) || reply_error "No WiFi adapter found. If it's a USB adapter, check it's plugged in."
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +216,7 @@ set_country() {
|
|||||||
|
|
||||||
do_region_set() {
|
do_region_set() {
|
||||||
local input country locale keyboard
|
local input country locale keyboard
|
||||||
input="$REQ_INPUT"
|
input=$(head -c 4096)
|
||||||
country=$(jq -r '.country // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
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"
|
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"
|
keyboard=$(jq -r '.keyboard // "" | strings' <<< "$input" 2>/dev/null) || reply_error "Bad request"
|
||||||
@@ -323,27 +251,10 @@ do_region_set() {
|
|||||||
'{ok:true,country:$c,locale:$l,keyboard:$k,restart:$restart}'
|
'{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 [[ -n "$REQ_IFACE" ]] && ! valid_iface "$REQ_IFACE"; then
|
if ! iface=$(detect_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
|
||||||
@@ -440,7 +351,7 @@ PYEOF
|
|||||||
do_connect() {
|
do_connect() {
|
||||||
need_iface
|
need_iface
|
||||||
local input ssid password
|
local input ssid password
|
||||||
input="$REQ_INPUT"
|
input=$(head -c 4096)
|
||||||
ssid=$(jq -er '.ssid | strings' <<< "$input" 2>/dev/null) || reply_error "No network name given"
|
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"
|
||||||
|
|
||||||
@@ -543,14 +454,7 @@ 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 ;;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
################################################################################
|
|
||||||
# kiosk-wifi-helper-socket - socket front end for kiosk-wifi-helper.
|
|
||||||
#
|
|
||||||
# Run by systemd (kiosk-wifi-helper@.service, one instance per connection to
|
|
||||||
# /run/kiosk-wifi-helper.sock) as root, with the connection as stdin AND
|
|
||||||
# stdout. The kiosk app connects, sends
|
|
||||||
#
|
|
||||||
# <action>\n<JSON request body>
|
|
||||||
#
|
|
||||||
# and half-closes; the reply is the helper's one JSON line.
|
|
||||||
#
|
|
||||||
# Why a socket and not `sudo`: the kiosk app can end up running with the
|
|
||||||
# Linux "no new privileges" flag, and sudo then refuses to run as root
|
|
||||||
# ("The 'no new privileges' flag is set"). A root service reached through a
|
|
||||||
# socket isn't affected by that flag. Who may connect is decided by the
|
|
||||||
# socket's owner/group/mode (root:kiosk 0660) - the same people who could
|
|
||||||
# run the helper through the old sudoers rule, no more.
|
|
||||||
#
|
|
||||||
# Only the fixed actions below are passed on; anything else is refused.
|
|
||||||
################################################################################
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HELPER="${KIOSK_WIFI_HELPER:-/usr/local/bin/kiosk-wifi-helper}"
|
|
||||||
|
|
||||||
# First line only; bash's read doesn't consume past the newline on a
|
|
||||||
# socket, so the JSON body stays on stdin for the helper.
|
|
||||||
IFS= read -r -t 10 -n 32 action || action=""
|
|
||||||
|
|
||||||
case "$action" in
|
|
||||||
ifaces|status|scan|connect|region-status|region-set)
|
|
||||||
exec "$HELPER" "$action"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo '{"ok":false,"error":"unknown action"}'
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
+28
-14
@@ -74,6 +74,8 @@ STAFF_PORT=8081
|
|||||||
QUICKADD_PORT=8082
|
QUICKADD_PORT=8082
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=s3cretPASS
|
ADMIN_PASSWORD=s3cretPASS
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=guestPASS
|
||||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
ENV
|
ENV
|
||||||
echo running > "$STUB_STATE"
|
echo running > "$STUB_STATE"
|
||||||
@@ -98,7 +100,7 @@ source "$REPO/menus/addon_library.sh"
|
|||||||
export KIOSK_UNATTENDED=0
|
export KIOSK_UNATTENDED=0
|
||||||
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
||||||
|
|
||||||
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
|
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== lib/docker.sh"
|
echo "== lib/docker.sh"
|
||||||
@@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
|
|||||||
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
||||||
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
||||||
check "is_installed is now true" library_is_installed
|
check "is_installed is now true" library_is_installed
|
||||||
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
|
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
|
||||||
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
|
||||||
|
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
|
||||||
|
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
||||||
addon_library_status > "$T/status.out"
|
addon_library_status > "$T/status.out"
|
||||||
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
||||||
@@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== menu while installed"
|
echo "== menu while installed"
|
||||||
addon_library_menu_builder
|
addon_library_menu_builder
|
||||||
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
|
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
|
||||||
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
||||||
|
|
||||||
echo "== tab toggling is idempotent"
|
echo "== tab toggling is idempotent"
|
||||||
library_add_tab >/dev/null 2>&1
|
library_add_tab >/dev/null 2>&1
|
||||||
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
|
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
||||||
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
addon_library_menu_builder
|
addon_library_menu_builder
|
||||||
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
|
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
||||||
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
|
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
echo "== an earlier version's tab (public catalogue address) is cleaned up"
|
||||||
|
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
|
||||||
|
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
|
||||||
|
library_remove_tab >/dev/null 2>&1
|
||||||
|
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
|
||||||
|
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
library_add_tab >/dev/null 2>&1
|
||||||
|
|
||||||
echo "== network access"
|
echo "== network access"
|
||||||
: > "$STUB_LOG"
|
: > "$STUB_LOG"
|
||||||
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
||||||
@@ -168,8 +180,10 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== access screen"
|
echo "== access screen"
|
||||||
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
||||||
check "shows the staff login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||||
check "shows quick-add address" grep -q 'Add books.*:8082' "$T/access.out"
|
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
|
||||||
|
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
||||||
|
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== update"
|
echo "== update"
|
||||||
@@ -185,7 +199,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
|
|||||||
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
||||||
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
||||||
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
||||||
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
|
|
||||||
echo "== purge needs the typed confirmation"
|
echo "== purge needs the typed confirmation"
|
||||||
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
@@ -195,8 +209,8 @@ library_add_tab >/dev/null 2>&1
|
|||||||
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
||||||
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
||||||
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "passed: $PASS failed: $FAIL"
|
echo "passed: $PASS failed: $FAIL"
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ 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: '',
|
||||||
|
|||||||
@@ -173,8 +173,6 @@ 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),
|
||||||
};
|
};
|
||||||
@@ -284,8 +282,6 @@ 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);
|
||||||
|
|||||||
@@ -95,7 +95,6 @@
|
|||||||
<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>
|
||||||
@@ -105,10 +104,6 @@
|
|||||||
<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">
|
||||||
|
|||||||
+1
-7
@@ -70,12 +70,6 @@ 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) {
|
||||||
@@ -294,7 +288,7 @@ app.put('/api/region', (req, res) => {
|
|||||||
runRegionHelper('region-set', input, res);
|
runRegionHelper('region-set', input, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8888;
|
const PORT = process.env.PORT || 8090;
|
||||||
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
const BIND_ADDR = process.env.BIND_ADDR || '0.0.0.0';
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -75,17 +75,6 @@ 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' }],
|
||||||
|
|||||||
Reference in New Issue
Block a user