Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec3d4735b | ||
|
|
13a451a756 | ||
|
|
a349043abb | ||
|
|
251cac9807 | ||
|
|
d82a254cdb | ||
|
|
fe264f0a23 | ||
|
|
a1e415ac05 | ||
|
|
41250b7fc7 | ||
|
|
43f21dbf6c | ||
|
|
18f13f12dd | ||
|
|
7811bf5dcb | ||
|
|
2b9f841bbd | ||
|
|
8924a16747 | ||
|
|
ab82598859 | ||
|
|
cae5d75005 | ||
|
|
ee74f84c9f | ||
|
|
676a725807 | ||
|
|
76aac203d6 | ||
|
|
034106918d | ||
|
|
0f3237f125 | ||
|
|
a486e3ff63 | ||
|
|
0b0f5c60c3 | ||
|
|
5230606144 | ||
|
|
2d47530069 | ||
|
|
9fcb4df30e | ||
|
|
18012c2c95 | ||
|
|
678b54dac7 | ||
|
|
0f9946e6ca | ||
|
|
1c8e245603 |
@@ -1,14 +1,20 @@
|
|||||||
# Registry
|
# Registry
|
||||||
REGISTRY=registry.bonamin.gr
|
REGISTRY=registry.bonamin.net
|
||||||
VERSION=0.1.0
|
VERSION=0.1.0
|
||||||
|
|
||||||
# Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
|
# Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
|
||||||
SITE_ID=your-site-id
|
SITE_ID=your-site-id
|
||||||
SITE_KEY=your-site-key
|
SITE_KEY=your-site-key
|
||||||
CLOUD_URL=https://xenia-admin.bonamin.gr
|
CLOUD_URL=https://xenia-api.bonamin.net
|
||||||
SECRET_KEY=generate-with-openssl-rand-hex-32
|
SECRET_KEY=generate-with-openssl-rand-hex-32
|
||||||
LICENSE_GRACE_HOURS=24
|
LICENSE_GRACE_HOURS=24
|
||||||
|
|
||||||
|
# This machine's LAN IP on the PHYSICAL network (Ethernet/WiFi) — the address
|
||||||
|
# phones open (http://<HOST_IP>) and the pairing QR code encodes. Never a VPN
|
||||||
|
# address (WireGuard/Tailscale/ZeroTier). install.sh detects it; reserve it in
|
||||||
|
# the router's DHCP.
|
||||||
|
HOST_IP=
|
||||||
|
|
||||||
# Break-glass support account (leave blank to disable)
|
# Break-glass support account (leave blank to disable)
|
||||||
MASTER_USERNAME=
|
MASTER_USERNAME=
|
||||||
MASTER_PASSWORD=
|
MASTER_PASSWORD=
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Files that run or are read on Linux client machines must keep LF endings,
|
||||||
|
# even when checked out on Windows with core.autocrlf=true (install.sh is scp'd as-is).
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
.env.example text eol=lf
|
||||||
|
gradlew text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# client-services
|
||||||
|
|
||||||
|
Read [docs/README.md](docs/README.md) (pack rules) and the global [../docs/README.md](../docs/README.md) (Working Rules) before making changes.
|
||||||
|
This is its own git repo. Commit here first, then bump the pointer in the parent `xenia-pos` repo.
|
||||||
@@ -10,6 +10,8 @@ services:
|
|||||||
waiter_pwa:
|
waiter_pwa:
|
||||||
build:
|
build:
|
||||||
context: ./waiter_pwa
|
context: ./waiter_pwa
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${VERSION:-dev}
|
||||||
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:80"
|
||||||
|
|||||||
@@ -11,12 +11,35 @@ services:
|
|||||||
- DATABASE_URL=sqlite:////app/data/pos.db
|
- DATABASE_URL=sqlite:////app/data/pos.db
|
||||||
- VERSION=${VERSION:-0.0.0}
|
- VERSION=${VERSION:-0.0.0}
|
||||||
- HOST_IP=${HOST_IP:-}
|
- HOST_IP=${HOST_IP:-}
|
||||||
|
- TLS_PORT=${TLS_PORT:-8443} # published port of the proxy's TLS entry (advertised to the app)
|
||||||
|
- HTTP_PORT=${HTTP_PORT:-80} # published plain-HTTP LAN port (app downloads UI bundles from it)
|
||||||
- MASTER_USERNAME=${MASTER_USERNAME:-}
|
- MASTER_USERNAME=${MASTER_USERNAME:-}
|
||||||
- MASTER_PASSWORD=${MASTER_PASSWORD:-}
|
- MASTER_PASSWORD=${MASTER_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_PATH}:/app/data
|
- ${DATA_PATH}:/app/data
|
||||||
- ${LOGO_PATH}:/app/logo.png:ro
|
- ${LOGO_PATH}:/app/logo.png:ro
|
||||||
- ${FISCAL_PATH}:/mnt/fiscal
|
- ${FISCAL_PATH}:/mnt/fiscal
|
||||||
|
- netinfo:/netinfo:ro
|
||||||
|
# "Healthy" = app started, which also means the TLS key/cert for the
|
||||||
|
# proxy's :8443 exist (created at startup by services/tls_identity.py)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/system/health', timeout=3)"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# Finds the server's LAN IP on the PHYSICAL network (never a VPN) every minute
|
||||||
|
# and shares it with the backend (pairing QR, manager, heartbeat). Needs the
|
||||||
|
# host's network namespace because the backend's bridge network can't see
|
||||||
|
# real NICs. Same image as the backend — see services/netinfo_helper.py.
|
||||||
|
netinfo:
|
||||||
|
image: ${REGISTRY}/pos-backend:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
command: ["python", "-m", "services.netinfo_helper"]
|
||||||
|
volumes:
|
||||||
|
- netinfo:/netinfo
|
||||||
|
|
||||||
waiter_pwa:
|
waiter_pwa:
|
||||||
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
||||||
@@ -36,10 +59,20 @@ services:
|
|||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
- "4443:4443"
|
- "4443:4443"
|
||||||
|
- "8081:8081" # manager over plain HTTP (LAN only)
|
||||||
|
- "8443:8443" # native app over TLS with a pinned key (LAN only)
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ./certs:/etc/nginx/certs:ro
|
- ./certs:/etc/nginx/certs:ro
|
||||||
|
- ${DATA_PATH}/tls:/etc/nginx/xenia-tls:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- waiter_pwa
|
backend:
|
||||||
- manager_dashboard
|
condition: service_healthy
|
||||||
|
waiter_pwa:
|
||||||
|
condition: service_started
|
||||||
|
manager_dashboard:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
netinfo:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# client-services — On-site Pack
|
||||||
|
|
||||||
|
Everything that runs **inside the restaurant**, on the local server box.
|
||||||
|
This folder is its **own git repo**, nested inside the `xenia-pos` parent repo.
|
||||||
|
|
||||||
|
> **Read the global rules first:** [../../docs/README.md](../../docs/README.md). This file adds only the rules specific to this pack.
|
||||||
|
> The feature catalog, the cloud contract, known issues and site records all live in the parent's `docs/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Stack | Dev port | Prod exposure | Image |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `local_backend` | FastAPI + SQLAlchemy + SQLite (`/app/data/pos.db`) | 8000 | only through proxy / inner nginx | `pos-backend` |
|
||||||
|
| `waiter_pwa` | React + Vite + vite-plugin-pwa, axios, zustand, react-query, Dexie (IndexedDB) | 5173 | **`http://<LAN IP>`** (80, LAN only) · `https://waiter.<domain>` / `https://<IP>` (443) | `pos-waiter` |
|
||||||
|
| `manager_dashboard` | React + Vite | 5174 | **`http://<LAN IP>:8081`** (LAN only; `http://<IP>/manager` redirects there) · `https://manager.<domain>` (443) · `https://<IP>:4443` | `pos-manager` |
|
||||||
|
| `proxy` | nginx:alpine | — | 80, 443, 4443, 8081, 8443. See the header of `nginx-proxy/nginx.conf` for the full routing map. Waits for the backend to be healthy | stock |
|
||||||
|
|
||||||
|
### Request path in production
|
||||||
|
Phones normally use **plain HTTP by LAN IP** (`http://<HOST_IP>` → proxy:80 default_server). Only private source IPs are allowed; everything else gets 403.
|
||||||
|
Legacy domain sites use `https://waiter.<domain>` (proxy:443), and `waiter.*`/`manager.*` on port 80 still redirect to https.
|
||||||
|
```
|
||||||
|
phone ──http(s)──▶ proxy:80/443 ──http──▶ waiter_pwa nginx:80 ──┬─ / → static SPA
|
||||||
|
├─ /api/ → backend:8000
|
||||||
|
├─ /api/ws/ → backend:8000 (WebSocket upgrade)
|
||||||
|
└─ /static/ → backend:8000/static/
|
||||||
|
```
|
||||||
|
Every hop must forward WebSocket upgrades: `proxy_http_version 1.1`, plus `Upgrade` and `Connection` headers, plus a long `proxy_read_timeout`.
|
||||||
|
The manager calls the API **same-origin, with relative paths**. The waiter app resolves every backend URL through `waiter_pwa/src/config/server.js`: same-origin when served by the venue's server, absolute when running in the native app (see CS-8). Don't hardcode hosts.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| `local_backend/main.py` | App setup, router registration, CORS, **`_run_migrations()`** |
|
||||||
|
| `local_backend/services/lan_ip.py`, `services/netinfo_helper.py` | Which LAN IP phones get (override → live detection → `HOST_IP`); the `netinfo` host-network helper |
|
||||||
|
| `local_backend/services/tls_identity.py`, `waiter_pwa/android/.../TrustStore.java`, `src/native/tls.js` | Encrypted LAN link: the server's self-managed key and cert, and the app's key pinning |
|
||||||
|
| `local_backend/services/cloud_sync.py` | Every call to the cloud (see the parent's `docs/reference/cloud-contract.md`) |
|
||||||
|
| `local_backend/roles.py`, `routers/deps.py` | Roles, permission checks, auth dependencies |
|
||||||
|
| `local_backend/routers/ws.py` | Real-time event stream (seq + cursor replay) |
|
||||||
|
| `waiter_pwa/src/config/server.js` | **Where the backend is**: active venue, `apiBase()`, `wsUrl()`, `assetUrl()`, `storageKey()`, `dbName()`, `deliveryMode()` |
|
||||||
|
| `waiter_pwa/android/`, `capacitor.config.json` | Native Android app (Capacitor 8). See "Native Android app" below |
|
||||||
|
| `waiter_pwa/src/native/pairing.js`, `src/pages/VenuesPage.jsx` | Pairing with a venue (QR / typed address) and the venue switcher |
|
||||||
|
| `waiter_pwa/src/api/client.js` | axios instance: auth header, 401 → logout, network error → offline |
|
||||||
|
| `waiter_pwa/src/context/SSEContext.jsx` | Real-time lifecycle, event → store/cache updates, visibility refresh |
|
||||||
|
| `waiter_pwa/src/db/posdb`, `src/services/offlineOrders` | Offline cache and queued orders |
|
||||||
|
| `nginx-proxy/nginx.conf` | Proxy config used in dev / by hand |
|
||||||
|
| `install.sh` | Site installer. **Writes its own copy of the proxy config.** |
|
||||||
|
| `docker-compose.yml` / `docker-compose.dev.yml` | Prod (pull images) / dev override (build + expose ports) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pack-specific rules
|
||||||
|
|
||||||
|
**CS-1. Schema changes go through `_run_migrations()`.**
|
||||||
|
A new table: `create_all` handles it. A new column on an existing table: append an `ALTER TABLE <t> ADD COLUMN ...` to the `migrations` list in `local_backend/main.py`, with a SQLite-safe default for `NOT NULL`.
|
||||||
|
Add the column to the SQLAlchemy model **and** the Pydantic schema in the same commit. Never drop or rename columns.
|
||||||
|
|
||||||
|
**CS-2. New real-time events need both ends.**
|
||||||
|
If the backend emits a new event type, handle it in `waiter_pwa/src/context/SSEContext.jsx` (and in the manager, if relevant), or state explicitly that it's ignored.
|
||||||
|
Events carry IDs; clients re-fetch the full object (`/api/orders/{id}`) instead of trusting partial payloads.
|
||||||
|
|
||||||
|
**CS-3. The waiter app must survive bad networks.**
|
||||||
|
Waiters walk in and out of WiFi range and phones sleep. Any new waiter flow must:
|
||||||
|
- work from the IndexedDB cache when offline, or clearly block with an offline state
|
||||||
|
- never lose an order that was entered (queue it)
|
||||||
|
- tolerate duplicate or replayed events
|
||||||
|
|
||||||
|
**CS-4. Money and prices are snapshotted.**
|
||||||
|
Prices, costs and discounts are copied onto order items and logs when the action happens (snapshot pattern, `PriceEventLog`). Reports read the snapshots, never the live catalogue.
|
||||||
|
|
||||||
|
**CS-5. The proxy config lives in two places and must stay byte-identical.**
|
||||||
|
`install.sh` writes the heredoc on client sites, and `nginx-proxy/nginx.conf` is the repo copy. Edit both together (global Rule 9) and check with:
|
||||||
|
`sed -n "/<< 'EOF'/,/^EOF/p" install.sh | sed '1d;$d' | diff - nginx-proxy/nginx.conf`
|
||||||
|
The plain-HTTP servers (80 default_server, 8081) must keep their LAN-only `allow`/`deny` block.
|
||||||
|
|
||||||
|
**CS-6. Permissions are checked on the backend.**
|
||||||
|
Hiding a button in the UI is not access control. Every new endpoint declares its role or `perm_*` requirement through `routers/deps.py`.
|
||||||
|
|
||||||
|
**CS-8. The waiter app never assumes it's served by the backend.**
|
||||||
|
The same waiter build runs same-origin (https domain, or plain `http://<LAN IP>`) **and** inside the native app talking to a remote venue server. So in `waiter_pwa`:
|
||||||
|
- REST goes through `api/client.js` (its `baseURL` is `apiBase()`). Any other fetch, `EventSource` or `WebSocket` must build its URL with `apiBase()` or `wsUrl()`.
|
||||||
|
- Backend-served files (`/static/...` images, avatars) are rendered through `assetUrl()`.
|
||||||
|
- Persistent **venue-specific** data (token, IDs of products/tables/zones) uses `storageKey()` for localStorage keys and zustand `persist` names. The IndexedDB name comes from `dbName()`. Device preferences (theme, payment safety…) stay global.
|
||||||
|
- Features that need a secure context (service worker, camera, notifications, clipboard) must degrade gracefully when `deliveryMode()` is `'plain-web'`.
|
||||||
|
|
||||||
|
**CS-7. Greek-market realities.**
|
||||||
|
Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Dev: backend with reload
|
||||||
|
cd local_backend; uvicorn main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Dev: frontends (Vite proxies /api and /api/ws to :8000)
|
||||||
|
cd waiter_pwa; npm run dev # :5173
|
||||||
|
cd manager_dashboard; npm run dev # :5174
|
||||||
|
cd waiter_pwa; npm run lint
|
||||||
|
|
||||||
|
# Build release images (VERSION comes from .env). See the parent's DEPLOYMENT_GUIDE.md.
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml build
|
||||||
|
docker push registry.bonamin.net/pos-backend:<ver> # + pos-waiter, pos-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Android app (waiter_pwa)
|
||||||
|
|
||||||
|
The same waiter code, bundled into an APK with Capacitor 8. It is sideloaded, not on Play Store yet.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd waiter_pwa
|
||||||
|
npm run apk:debug # releases/xenia-waiter-<ver>-debug.apk (WebView debuggable via chrome://inspect)
|
||||||
|
npm run apk:release # releases/xenia-waiter-<ver>.apk, signed, AND copied to public/downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Signing key:** `%USERPROFILE%\.xenia\xenia-release.jks` + `keystore.properties`. It lives **outside the repo; back it up.** A lost key means no phone can install updates (uninstall and reinstall on every phone). Cert SHA-256 `98:A2:60:25:…:61:7A`.
|
||||||
|
- **Versioning:** bump **both** `versionCode` and `versionName` in `android/app/build.gradle` for every APK handed out. Android refuses an update whose versionCode isn't higher.
|
||||||
|
- **Distribution:** `apk:release` copies the APK into `public/downloads/`, so the next web build (and Docker image) serves it at `http://<IP>/downloads/xenia-waiter.apk`. The browser-mode install banner links there. **Order: `apk:release` → `docker compose … build`.**
|
||||||
|
- **Native specifics:**
|
||||||
|
- `androidScheme: http` (origin `http://localhost`)
|
||||||
|
- cleartext allowed via `network_security_config.xml`
|
||||||
|
- no service worker (`vite build --mode native` → `dist-native/`)
|
||||||
|
- back button handled in `src/native/backButton.js`
|
||||||
|
- first run shows only the pairing screen
|
||||||
|
- `android/local.properties` (SDK path) is gitignored. Recreate it on a new machine: `sdk.dir=C:/Users/<you>/AppData/Local/Android/Sdk`.
|
||||||
|
|
||||||
|
- **UI bundles (plan step 8):** every waiter image also publishes the native UI bundle (`scripts/pack-bundle.mjs` → `/downloads/waiter-bundle.json` + zip). Phones run their venue's bundle via `@capgo/capacitor-updater` (manual mode, no Capgo cloud), driven by `src/native/bundles.js`.
|
||||||
|
|
||||||
|
**CS-10. `MIN_SHELL_BUILD` guards native compatibility.**
|
||||||
|
When web code starts relying on something new in `android/` (a plugin, a Java class, a manifest change), bump `MIN_SHELL_BUILD` in `src/native/shell.js` **and** `versionCode`, in the same commit. Phones with an older APK then keep their current UI and show "update the app", instead of loading a UI that calls missing native code.
|
||||||
|
Every bundle must call `markBundleReady()` at start (`BundleSync` does this); otherwise it is rolled back after 15 s.
|
||||||
|
|
||||||
|
**CS-9. Anything a native build needs must survive `npx cap sync`.**
|
||||||
|
`android/app/src/main/assets/public` and the generated configs are rebuilt on every sync, so never edit them. Native changes go in `android/app/src/main/**` (manifest, res, java) or `capacitor.config.json`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
There is no automated test suite yet. Before committing:
|
||||||
|
- the backend starts cleanly against an **existing** `pos.db` (this catches missing migrations)
|
||||||
|
- `npm run build` passes for any frontend you touched
|
||||||
|
- the flow was exercised by hand (the parent's `PLANS AND STRATEGIES/TESTING_CHECKLIST.md`)
|
||||||
|
- printing was checked against real hardware or `esc-pos-emulator`
|
||||||
|
|
||||||
|
Say in the commit or hand-off what was and wasn't verified.
|
||||||
@@ -7,6 +7,40 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# LAN IP of this machine on the PHYSICAL network (the address phones use:
|
||||||
|
# http://<HOST_IP>). Must never be a VPN address: with a full-tunnel WireGuard /
|
||||||
|
# Tailscale / ZeroTier client, "the interface that reaches the internet" is the
|
||||||
|
# tunnel, which phones on the restaurant WiFi can't reach.
|
||||||
|
# Real NICs (Ethernet, WiFi) have /sys/class/net/<if>/device; VPN tunnels,
|
||||||
|
# Docker bridges, veths and loopback don't.
|
||||||
|
# XENIA_SYS_NET overrides the sysfs path (tests only).
|
||||||
|
detect_host_ip() {
|
||||||
|
local sys_net="${XENIA_SYS_NET:-/sys/class/net}" ifc ip=""
|
||||||
|
command -v ip >/dev/null 2>&1 || { hostname -I 2>/dev/null | awk '{print $1}'; return; }
|
||||||
|
|
||||||
|
first_ipv4() { ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4, a, "/"); print a[1]; exit}'; }
|
||||||
|
|
||||||
|
# 1) Interface of the main-table default route, if it's real hardware.
|
||||||
|
# (wg-quick full-tunnel routing uses policy rules + its own table, so the
|
||||||
|
# main table's default route still points at the physical uplink.)
|
||||||
|
ifc=$(ip route show default 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')
|
||||||
|
if [ -n "$ifc" ] && [ -e "$sys_net/$ifc/device" ]; then
|
||||||
|
ip=$(first_ipv4 "$ifc")
|
||||||
|
fi
|
||||||
|
# 2) Otherwise the first real-hardware interface that has an IPv4 address.
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
for path in "$sys_net"/*; do
|
||||||
|
ifc=$(basename "$path")
|
||||||
|
[ -e "$path/device" ] || continue
|
||||||
|
ip=$(first_ipv4 "$ifc")
|
||||||
|
[ -n "$ip" ] && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
# 3) Last resort — the admin confirms it at the prompt anyway.
|
||||||
|
[ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
echo "$ip"
|
||||||
|
}
|
||||||
|
|
||||||
echo "=== Xenia POS Install ==="
|
echo "=== Xenia POS Install ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -27,13 +61,16 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
|||||||
echo " A .env file has been created from .env.example."
|
echo " A .env file has been created from .env.example."
|
||||||
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
|
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
|
||||||
echo ""
|
echo ""
|
||||||
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.gr"
|
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.net"
|
||||||
echo " Generate SECRET_KEY with: openssl rand -hex 32"
|
echo " Generate SECRET_KEY with: openssl rand -hex 32"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -rp " Enter SITE_ID: " INPUT_SITE_ID
|
read -rp " Enter SITE_ID: " INPUT_SITE_ID
|
||||||
read -rp " Enter SITE_KEY: " INPUT_SITE_KEY
|
read -rp " Enter SITE_KEY: " INPUT_SITE_KEY
|
||||||
read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY
|
read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY
|
||||||
|
DETECTED_IP=$(detect_host_ip)
|
||||||
|
read -rp " Enter this machine's LAN IP [${DETECTED_IP}]: " INPUT_HOST_IP
|
||||||
|
INPUT_HOST_IP=${INPUT_HOST_IP:-$DETECTED_IP}
|
||||||
|
|
||||||
if [ -z "$INPUT_SECRET_KEY" ]; then
|
if [ -z "$INPUT_SECRET_KEY" ]; then
|
||||||
INPUT_SECRET_KEY=$(openssl rand -hex 32)
|
INPUT_SECRET_KEY=$(openssl rand -hex 32)
|
||||||
@@ -43,22 +80,120 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
|||||||
sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env"
|
sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env"
|
||||||
sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env"
|
sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env"
|
||||||
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env"
|
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env"
|
||||||
|
sed -i "s/^HOST_IP=.*/HOST_IP=${INPUT_HOST_IP}/" "$SCRIPT_DIR/.env"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " .env written. Review it at: $SCRIPT_DIR/.env"
|
echo " .env written. Review it at: $SCRIPT_DIR/.env"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo " .env already exists — skipping."
|
echo " .env already exists — keeping it."
|
||||||
|
if ! grep -q '^HOST_IP=.\+' "$SCRIPT_DIR/.env"; then
|
||||||
|
DETECTED_IP=$(detect_host_ip)
|
||||||
|
sed -i '/^HOST_IP=/d' "$SCRIPT_DIR/.env"
|
||||||
|
echo "HOST_IP=${DETECTED_IP}" >> "$SCRIPT_DIR/.env"
|
||||||
|
echo " HOST_IP was not set — added HOST_IP=${DETECTED_IP} (edit .env if wrong)."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
|
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
|
||||||
echo "[ 3/5 ] Writing nginx proxy config..."
|
echo "[ 3/5 ] Writing nginx proxy config..."
|
||||||
cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF'
|
cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF'
|
||||||
|
# Xenia POS — on-site proxy config.
|
||||||
|
# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the
|
||||||
|
# repo is a byte-identical copy. Change both together (global Working Rule 9).
|
||||||
|
#
|
||||||
|
# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup)
|
||||||
|
# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only
|
||||||
|
# /manager → redirect to :8081
|
||||||
|
# :8081 → manager dashboard over plain HTTP, LAN only
|
||||||
|
# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem)
|
||||||
|
# :4443 → manager over https
|
||||||
|
# :8443 → native app over TLS (backend-managed key, pinned), LAN only
|
||||||
|
#
|
||||||
|
# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and
|
||||||
|
# disables buffering (SSE), otherwise live events never reach phones / KDS.
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Plain HTTP ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_name waiter.* manager.*;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location ~ ^/manager/?$ {
|
||||||
|
return 302 http://$host:8081/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8081 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── HTTPS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name waiter.*;
|
server_name waiter.*;
|
||||||
@@ -70,10 +205,16 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://waiter_pwa:80;
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,33 +229,116 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://manager_dashboard:80;
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl default_server;
|
listen 443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://waiter_pwa:80;
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Encrypted LAN entry for the native app (plan step 7). Key + self-signed cert
|
||||||
|
# are created and renewed by the backend (services/tls_identity.py) under
|
||||||
|
# ${DATA_PATH}/tls; phones pin the public key, so renewals need no action.
|
||||||
|
ssl_certificate /etc/nginx/xenia-tls/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/xenia-tls/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 4443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -122,34 +346,24 @@ EOF
|
|||||||
# ── 4. SSL certificates ───────────────────────────────────────────────────────
|
# ── 4. SSL certificates ───────────────────────────────────────────────────────
|
||||||
echo "[ 4/5 ] Checking SSL certificates..."
|
echo "[ 4/5 ] Checking SSL certificates..."
|
||||||
|
|
||||||
|
# Phones normally use plain HTTP on the LAN (http://<HOST_IP>), which needs no
|
||||||
|
# certificate. nginx still needs *a* cert to start its HTTPS listeners, so when
|
||||||
|
# none is present we generate a self-signed one. A real certificate (legacy
|
||||||
|
# domain setup) can replace certs/cert.pem + certs/key.pem at any time.
|
||||||
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
|
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
|
||||||
echo " Certificates already exist — skipping."
|
echo " Certificates already exist — keeping them."
|
||||||
else
|
else
|
||||||
echo ""
|
CERT_IP=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2)
|
||||||
echo " No certificates found in certs/"
|
CERT_SAN="DNS:localhost,IP:127.0.0.1"
|
||||||
echo ""
|
[ -n "$CERT_IP" ] && CERT_SAN="$CERT_SAN,IP:$CERT_IP"
|
||||||
echo " DNS requirement:"
|
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||||
echo " Two subdomains must point to this machine's IP:"
|
-keyout "$SCRIPT_DIR/certs/key.pem" \
|
||||||
echo " waiter.YOURDOMAIN → this machine's IP"
|
-out "$SCRIPT_DIR/certs/cert.pem" \
|
||||||
echo " manager.YOURDOMAIN → this machine's IP"
|
-subj "/CN=xenia-pos" \
|
||||||
echo " The waiter domain should also be registered in the sysadmin"
|
-addext "subjectAltName=$CERT_SAN" >/dev/null 2>&1
|
||||||
echo " panel as the 'Waiter Domain' so phones get the QR code."
|
chmod 600 "$SCRIPT_DIR/certs/key.pem"
|
||||||
echo ""
|
echo " No certificates found — generated a self-signed one (valid 10 years)."
|
||||||
echo " Option A — Let's Encrypt (recommended):"
|
echo " Phones use plain HTTP on the LAN, so this is only for the HTTPS ports."
|
||||||
echo " sudo apt install certbot"
|
|
||||||
echo " sudo certbot certonly --manual --preferred-challenges dns \\"
|
|
||||||
echo " -d waiter.YOURDOMAIN -d manager.YOURDOMAIN"
|
|
||||||
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/fullchain.pem certs/cert.pem"
|
|
||||||
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/privkey.pem certs/key.pem"
|
|
||||||
echo ""
|
|
||||||
echo " Option B — Self-signed / mkcert (local testing only):"
|
|
||||||
echo " sudo apt install mkcert libnss3-tools"
|
|
||||||
echo " mkcert -install"
|
|
||||||
echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem \\"
|
|
||||||
echo " waiter.YOURDOMAIN manager.YOURDOMAIN"
|
|
||||||
echo ""
|
|
||||||
echo " Add certs then run: docker compose up -d"
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 5. Logo ───────────────────────────────────────────────────────────────────
|
# ── 5. Logo ───────────────────────────────────────────────────────────────────
|
||||||
@@ -161,18 +375,20 @@ if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||||
|
HOST_IP_NOW=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2)
|
||||||
|
HOST_IP_NOW="${HOST_IP_NOW:-SERVER-IP}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Setup complete ==="
|
echo "=== Setup complete ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Starting stack..."
|
||||||
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
|
||||||
echo "Starting stack..."
|
echo ""
|
||||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
|
echo "Done! Services running. From any phone or PC on this network:"
|
||||||
echo ""
|
echo " Waiter app: http://${HOST_IP_NOW}"
|
||||||
echo "Done! Services running."
|
echo " Manager app: http://${HOST_IP_NOW}/manager (→ port 8081)"
|
||||||
echo " Waiter app: https://waiter.YOURDOMAIN"
|
echo ""
|
||||||
echo " Manager app: https://manager.YOURDOMAIN"
|
echo "Tip: reserve ${HOST_IP_NOW} for this machine in the router's DHCP settings"
|
||||||
else
|
echo " so the address never changes."
|
||||||
echo "Add SSL certificates to certs/ then run:"
|
echo ""
|
||||||
echo " docker compose up -d"
|
echo "If the proxy config changed, restart it: docker compose restart proxy"
|
||||||
fi
|
|
||||||
|
|||||||
@@ -771,6 +771,9 @@ async def lifespan(app: FastAPI):
|
|||||||
ws_init_loop(loop)
|
ws_init_loop(loop)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_run_migrations()
|
_run_migrations()
|
||||||
|
# Before the app reports healthy: the proxy's :8443 needs these files to start
|
||||||
|
from services.tls_identity import ensure_tls_identity
|
||||||
|
ensure_tls_identity()
|
||||||
start_print_retry_thread()
|
start_print_retry_thread()
|
||||||
pruned = prune_old_events(hours=24)
|
pruned = prune_old_events(hours=24)
|
||||||
if pruned:
|
if pruned:
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ license_state: dict = {
|
|||||||
"latest_version": None,
|
"latest_version": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Paths that bypass all license checks (health probe)
|
# Paths that bypass all license checks (health probe, venue identity for pairing)
|
||||||
EXEMPT_PATHS = {"/api/system/health"}
|
EXEMPT_PATHS = {"/api/system/health", "/api/system/identity"}
|
||||||
|
|
||||||
# Paths that are always allowed so the frontend can read license status
|
# Paths that are always allowed so the frontend can read license status
|
||||||
# and managers can still log in / close the workday when restricted
|
# and managers can still log in / close the workday when restricted
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ bcrypt==4.2.0
|
|||||||
pyjwt==2.9.0
|
pyjwt==2.9.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
|
cryptography==46.0.4
|
||||||
|
|||||||
@@ -168,8 +168,10 @@ def close_business_day(
|
|||||||
if license_state.get("lock_pending"):
|
if license_state.get("lock_pending"):
|
||||||
license_state["lock_pending"] = False
|
license_state["lock_pending"] = False
|
||||||
license_state["locked"] = True
|
license_state["locked"] = True
|
||||||
from services.cloud_sync import _persist_state
|
# Re-evaluate the license now that no workday is open (an expiry whose grace
|
||||||
_persist_state()
|
# ended mid-service takes effect here) — also persists the state
|
||||||
|
from services.cloud_sync import apply_license
|
||||||
|
apply_license()
|
||||||
|
|
||||||
return day
|
return day
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -13,10 +15,13 @@ from models.printer import Printer
|
|||||||
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
||||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from models.settings import PosSettings
|
||||||
from models.product import Category, Product
|
from models.product import Category, Product
|
||||||
from models.table import Table, TableGroup
|
from models.table import Table, TableGroup
|
||||||
from services import printer_service
|
from services import printer_service
|
||||||
from services.cloud_sync import _sync_once, _push_menu_snapshot
|
from services.cloud_sync import _sync_once, _push_menu_snapshot
|
||||||
|
from services.lan_ip import OVERRIDE_KEY, resolve_lan_ip, validate_lan_ip
|
||||||
|
from services.tls_identity import tls_info
|
||||||
from middleware.license_check import license_state
|
from middleware.license_check import license_state
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
@@ -30,15 +35,56 @@ def health():
|
|||||||
return {"status": "ok", "version": settings.VERSION}
|
return {"status": "ok", "version": settings.VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
# Bump when the waiter app <-> backend contract changes incompatibly, so native
|
||||||
|
# apps (which ship their own UI) can tell they're talking to a server they don't support.
|
||||||
|
API_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/identity")
|
||||||
|
def identity(db: Session = Depends(get_db)):
|
||||||
|
"""Public, unauthenticated. Lets a phone confirm which venue a server is
|
||||||
|
(pairing, and rediscovery after the server's IP changes). No secrets here:
|
||||||
|
site_id is an identifier — the secret is SITE_KEY, which never leaves the server."""
|
||||||
|
venue = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
|
||||||
|
return {
|
||||||
|
"app": "xenia-pos",
|
||||||
|
"site_id": settings.SITE_ID or None,
|
||||||
|
"venue_name": (venue.value if venue and venue.value else None),
|
||||||
|
"version": settings.VERSION,
|
||||||
|
"api_version": API_VERSION,
|
||||||
|
# Encrypted LAN endpoint for the native app: https://<ip>:<port>, pin spki_sha256
|
||||||
|
"tls": tls_info(),
|
||||||
|
# Plain-HTTP LAN port: the app downloads UI bundles from it (integrity via
|
||||||
|
# the sha256 in the manifest it fetched over TLS)
|
||||||
|
"http_port": int(os.environ.get("HTTP_PORT", "80")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_reason() -> str | None:
|
||||||
|
"""Why the site is (or will be) blocked, for the manager's banner:
|
||||||
|
"admin" (locked/lock pending) · "clock" (system clock set back) ·
|
||||||
|
"unverified" (no genuine license yet) · "expired" (expiry grace over /
|
||||||
|
site deactivated) · None."""
|
||||||
|
if license_state.get("locked") or license_state.get("lock_pending"):
|
||||||
|
return "admin"
|
||||||
|
problem = license_state.get("license_problem")
|
||||||
|
if problem in ("clock", "unverified"):
|
||||||
|
return problem
|
||||||
|
if problem in ("expired", "inactive") or not license_state.get("licensed", True):
|
||||||
|
return "expired"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
from datetime import datetime, timezone
|
|
||||||
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||||
printer_statuses = []
|
printer_statuses = []
|
||||||
for p in printers:
|
for p in printers:
|
||||||
reachable = printer_service.check_printer(p.ip_address, p.port)
|
reachable = printer_service.check_printer(p.ip_address, p.port)
|
||||||
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
||||||
|
|
||||||
|
lan = resolve_lan_ip(db)
|
||||||
|
|
||||||
licensed = license_state.get("licensed", True)
|
licensed = license_state.get("licensed", True)
|
||||||
locked = license_state.get("locked", False)
|
locked = license_state.get("locked", False)
|
||||||
lock_pending = license_state.get("lock_pending", False)
|
lock_pending = license_state.get("lock_pending", False)
|
||||||
@@ -46,26 +92,10 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
|
|||||||
days_until_expiry = license_state.get("days_until_expiry")
|
days_until_expiry = license_state.get("days_until_expiry")
|
||||||
grace_expires_at = license_state.get("grace_expires_at")
|
grace_expires_at = license_state.get("grace_expires_at")
|
||||||
|
|
||||||
# Determine lock_reason for the frontend banner logic
|
lock_reason = _lock_reason()
|
||||||
# "admin" — locked by sysadmin (immediately or deferred)
|
|
||||||
# "expired" — license grace period over, site is blocked
|
|
||||||
# None — all good
|
|
||||||
lock_reason = None
|
|
||||||
if locked or lock_pending:
|
|
||||||
lock_reason = "admin"
|
|
||||||
elif not licensed:
|
|
||||||
lock_reason = "expired"
|
|
||||||
|
|
||||||
# Grace days remaining (only meaningful while in expiry grace period)
|
# Computed by services/license.py (only set during the expiry grace period)
|
||||||
grace_days_remaining = None
|
grace_days_remaining = license_state.get("grace_days_remaining")
|
||||||
if grace_expires_at:
|
|
||||||
try:
|
|
||||||
grace_dt = datetime.fromisoformat(grace_expires_at)
|
|
||||||
if grace_dt.tzinfo is None:
|
|
||||||
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
|
|
||||||
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uptime_seconds": int(time.time() - _start_time),
|
"uptime_seconds": int(time.time() - _start_time),
|
||||||
@@ -81,11 +111,46 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
|
|||||||
"grace_days_remaining": grace_days_remaining,
|
"grace_days_remaining": grace_days_remaining,
|
||||||
"sync_failed": license_state.get("sync_failed", False),
|
"sync_failed": license_state.get("sync_failed", False),
|
||||||
"last_sync": license_state.get("last_sync"),
|
"last_sync": license_state.get("last_sync"),
|
||||||
|
# Offline licensing (KI-006): days without cloud contact; the signed license
|
||||||
|
# keeps the site running until expiry regardless
|
||||||
|
"offline_days": license_state.get("offline_days"),
|
||||||
|
"license_verified": license_state.get("license_verified", False),
|
||||||
|
"license_problem": license_state.get("license_problem"),
|
||||||
|
"grace_over": license_state.get("grace_over", False),
|
||||||
"waiter_domain": license_state.get("waiter_domain"),
|
"waiter_domain": license_state.get("waiter_domain"),
|
||||||
|
"site_id": settings.SITE_ID or None,
|
||||||
|
"lan_ip": lan.get("effective"),
|
||||||
|
"lan_ip_info": lan,
|
||||||
|
"tls": tls_info(),
|
||||||
"printers": printer_statuses,
|
"printers": printer_statuses,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LanIpOverride(BaseModel):
|
||||||
|
ip: str | None = None # null / empty → clear the override (back to automatic)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/lan-ip-override")
|
||||||
|
def set_lan_ip_override(body: LanIpOverride, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
"""Pin the server's LAN IP (what phones and the pairing QR use). Clearing it
|
||||||
|
returns to automatic detection / HOST_IP."""
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
|
||||||
|
if body.ip and body.ip.strip():
|
||||||
|
try:
|
||||||
|
value = validate_lan_ip(body.ip)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
row.updated_by_id = user.id or None
|
||||||
|
else:
|
||||||
|
db.add(PosSettings(key=OVERRIDE_KEY, value=value, updated_by_id=user.id or None))
|
||||||
|
elif row:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return resolve_lan_ip(db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-license")
|
@router.post("/sync-license")
|
||||||
async def sync_license_now(user: User = Depends(require_manager)):
|
async def sync_license_now(user: User = Depends(require_manager)):
|
||||||
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
|
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
|
||||||
@@ -94,11 +159,7 @@ async def sync_license_now(user: User = Depends(require_manager)):
|
|||||||
"licensed": license_state.get("licensed", True),
|
"licensed": license_state.get("licensed", True),
|
||||||
"locked": license_state.get("locked", False),
|
"locked": license_state.get("locked", False),
|
||||||
"lock_pending": license_state.get("lock_pending", False),
|
"lock_pending": license_state.get("lock_pending", False),
|
||||||
"lock_reason": (
|
"lock_reason": _lock_reason(),
|
||||||
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
|
|
||||||
else "expired" if not license_state.get("licensed", True)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"expires_at": license_state.get("expires_at"),
|
"expires_at": license_state.get("expires_at"),
|
||||||
"days_until_expiry": license_state.get("days_until_expiry"),
|
"days_until_expiry": license_state.get("days_until_expiry"),
|
||||||
"sync_failed": license_state.get("sync_failed", False),
|
"sync_failed": license_state.get("sync_failed", False),
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
|
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
|
||||||
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
|
|
||||||
|
|
||||||
Lock behaviour:
|
Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a
|
||||||
- cloud sets locked=true → set lock_pending=true in state
|
license, not to RUN one. Each heartbeat returns a signed license token; the
|
||||||
- lock_pending is enforced at workday-close time (see business_day router)
|
site stores it and enforces it offline — until expiry (+5 days grace), however
|
||||||
- while a workday is open, the site keeps running; lock applies once it closes
|
long it has no internet. apply_license() re-evaluates the stored token without
|
||||||
|
network: at startup, after every heartbeat attempt and when a workday closes.
|
||||||
|
|
||||||
License expiry behaviour:
|
Lock behaviour (unchanged):
|
||||||
- 5 days before expiry → warning only (days_until_expiry in state)
|
- cloud sets locked=true → lock_pending while a workday is open, locked once it closes
|
||||||
- on expiry → 5-day grace period begins (grace_expires_at in state)
|
- expiry past grace / site deactivated → likewise never mid-service
|
||||||
- after grace + no open workday → licensed=False enforced by business_day router
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from middleware.license_check import license_state
|
from middleware.license_check import license_state
|
||||||
|
from services.license import evaluate, parse_dt, verify_token
|
||||||
|
|
||||||
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
||||||
|
|
||||||
@@ -31,20 +30,30 @@ logging.basicConfig(level=logging.INFO)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
||||||
GRACE_HOURS = 72 # 3 days offline grace
|
|
||||||
EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking
|
|
||||||
EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning
|
def _data_dir() -> Path:
|
||||||
STATE_FILE = Path(__file__).parent.parent / "license_state.json"
|
"""Directory of the SQLite database — the persistent data volume in Docker."""
|
||||||
|
from services.tls_identity import tls_dir
|
||||||
|
return tls_dir().parent
|
||||||
|
|
||||||
|
|
||||||
|
# In the data volume: it must survive container re-creation (image updates,
|
||||||
|
# compose changes), or an offline site would lose its license. Earlier versions
|
||||||
|
# kept it inside the container at local_backend/license_state.json.
|
||||||
|
STATE_FILE = _data_dir() / "license_state.json"
|
||||||
|
LEGACY_STATE_FILE = Path(__file__).parent.parent / "license_state.json"
|
||||||
|
|
||||||
|
|
||||||
def _load_persisted_state():
|
def _load_persisted_state():
|
||||||
if STATE_FILE.exists():
|
for path in (STATE_FILE, LEGACY_STATE_FILE):
|
||||||
try:
|
if path.exists():
|
||||||
data = json.loads(STATE_FILE.read_text())
|
try:
|
||||||
license_state.update(data)
|
license_state.update(json.loads(path.read_text()))
|
||||||
logger.info("Loaded persisted license state: %s", data)
|
logger.info("Loaded persisted license state from %s", path)
|
||||||
except Exception as e:
|
return
|
||||||
logger.warning("Could not load license state file: %s", e)
|
except Exception as e:
|
||||||
|
logger.warning("Could not load license state file %s: %s", path, e)
|
||||||
|
|
||||||
|
|
||||||
def _persist_state():
|
def _persist_state():
|
||||||
@@ -54,44 +63,69 @@ def _persist_state():
|
|||||||
logger.warning("Could not persist license state: %s", e)
|
logger.warning("Could not persist license state: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
|
def _latest_activity(db) -> datetime | None:
|
||||||
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
|
"""Newest order timestamp in the database — the clock can't be set before it."""
|
||||||
if not expires_at_str:
|
from sqlalchemy import func
|
||||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
from models.order import Order, OrderItem
|
||||||
|
stamps = [db.query(func.max(Order.opened_at)).scalar(), db.query(func.max(OrderItem.added_at)).scalar()]
|
||||||
|
stamps = [parse_dt(x) for x in stamps if x]
|
||||||
|
return max(stamps, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_license(now: datetime | None = None) -> None:
|
||||||
|
"""Re-evaluate the license from the stored signed token (no network)."""
|
||||||
|
if not settings.SITE_ID:
|
||||||
|
return # dev / unregistered install: licensing off, as before
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
payload = verify_token(license_state.get("license_token"), settings.SITE_ID)
|
||||||
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
expires_at = datetime.fromisoformat(expires_at_str)
|
workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None
|
||||||
if expires_at.tzinfo is None:
|
latest_activity = _latest_activity(db)
|
||||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
finally:
|
||||||
except ValueError:
|
db.close()
|
||||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
floors = [parse_dt(license_state.get("time_high_water")), latest_activity,
|
||||||
days_until = (expires_at - now).days # negative once expired
|
parse_dt(payload["issued_at"]) if payload else None]
|
||||||
|
floor = max((f for f in floors if f), default=None)
|
||||||
|
result = evaluate(payload, now, floor, workday_open, parse_dt(license_state.get("legacy_ok_at")))
|
||||||
|
if result["license_problem"] != "clock":
|
||||||
|
license_state["time_high_water"] = max(floor or now, now).isoformat()
|
||||||
|
|
||||||
grace_expires_at = None
|
if payload: # lock requested by the cloud — deferred while a workday is open
|
||||||
if days_until < 0:
|
if payload.get("locked"):
|
||||||
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
|
if workday_open and not license_state.get("locked"):
|
||||||
|
license_state["lock_pending"] = True
|
||||||
|
else:
|
||||||
|
license_state["lock_pending"] = False
|
||||||
|
license_state["locked"] = True
|
||||||
|
else:
|
||||||
|
license_state["lock_pending"] = False
|
||||||
|
license_state["locked"] = False
|
||||||
|
|
||||||
return {
|
last_sync = parse_dt(license_state.get("last_sync"))
|
||||||
"days_until_expiry": days_until,
|
license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None})
|
||||||
"grace_expires_at": grace_expires_at,
|
_persist_state()
|
||||||
}
|
if result["license_problem"]:
|
||||||
|
logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"])
|
||||||
|
|
||||||
|
|
||||||
def _get_local_ip() -> str | None:
|
def _get_local_ip() -> str | None:
|
||||||
"""Best-effort detection of the host machine's LAN IP address.
|
"""The server's LAN IP as phones see it — same resolver as the manager's
|
||||||
When running inside Docker the socket trick returns the container/bridge IP,
|
pairing QR (override → live detection → HOST_IP), see services/lan_ip.py.
|
||||||
so we honour HOST_IP if it is explicitly provided via the environment."""
|
No socket tricks: inside Docker they return the unreachable bridge IP."""
|
||||||
import os
|
from database import SessionLocal
|
||||||
if host_ip := os.environ.get("HOST_IP", "").strip():
|
from services.lan_ip import resolve_lan_ip
|
||||||
return host_ip
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
return resolve_lan_ip(db)["effective"]
|
||||||
s.connect(("8.8.8.8", 80))
|
|
||||||
return s.getsockname()[0]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def _sync_once():
|
async def _sync_once():
|
||||||
@@ -113,69 +147,39 @@ async def _sync_once():
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
licensed = data.get("licensed", True)
|
token = data.get("license_token")
|
||||||
cloud_locked = data.get("locked", False)
|
if token:
|
||||||
expires_at = data.get("expires_at")
|
if verify_token(token, settings.SITE_ID):
|
||||||
expiry_fields = _compute_expiry_fields(expires_at)
|
license_state["license_token"] = token
|
||||||
|
else:
|
||||||
# If cloud says locked, check whether a workday is currently open.
|
logger.error("Cloud sent a license token that does not verify - keeping the previous one")
|
||||||
# No open workday → lock immediately.
|
else:
|
||||||
# Open workday → defer to workday close (business_day router enforces it).
|
# Cloud without license signing (transition): trust this answer for 72h,
|
||||||
if cloud_locked:
|
# keep the old immediate lock handling
|
||||||
from database import SessionLocal
|
if data.get("licensed", True):
|
||||||
from models.business_day import BusinessDay
|
license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
db = SessionLocal()
|
license_state["expires_at"] = data.get("expires_at")
|
||||||
try:
|
if data.get("locked"):
|
||||||
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
license_state["lock_pending"] = True
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if open_day:
|
|
||||||
if not license_state.get("lock_pending"):
|
|
||||||
license_state["lock_pending"] = True
|
|
||||||
logger.info("Cloud requested lock — workday open, deferring to workday close")
|
|
||||||
else:
|
else:
|
||||||
license_state["lock_pending"] = False
|
license_state["lock_pending"] = False
|
||||||
license_state["locked"] = True
|
license_state["locked"] = False
|
||||||
logger.info("Cloud requested lock — no open workday, locking immediately")
|
|
||||||
|
|
||||||
# If cloud lifts the lock, clear pending too
|
|
||||||
if not cloud_locked:
|
|
||||||
license_state["lock_pending"] = False
|
|
||||||
license_state["locked"] = False
|
|
||||||
|
|
||||||
license_state.update({
|
license_state.update({
|
||||||
"licensed": licensed,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"latest_version": data.get("latest_version"),
|
"latest_version": data.get("latest_version"),
|
||||||
"waiter_domain": data.get("waiter_domain"),
|
"waiter_domain": data.get("waiter_domain"),
|
||||||
"site_numeric_id": data.get("site_numeric_id"),
|
"site_numeric_id": data.get("site_numeric_id"),
|
||||||
"last_sync": datetime.now(timezone.utc).isoformat(),
|
"last_sync": datetime.now(timezone.utc).isoformat(),
|
||||||
"sync_failed": False,
|
"sync_failed": False,
|
||||||
**expiry_fields,
|
|
||||||
})
|
})
|
||||||
_persist_state()
|
logger.info("Cloud sync OK (signed license: %s)", bool(token))
|
||||||
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Cloud sync failed: %s", e)
|
logger.warning("Cloud sync failed: %s", e)
|
||||||
license_state["sync_failed"] = True
|
license_state["sync_failed"] = True
|
||||||
|
|
||||||
last_sync_str = license_state.get("last_sync")
|
# Online or not: the stored signed license decides (no more 72h offline rule)
|
||||||
if last_sync_str:
|
apply_license()
|
||||||
try:
|
|
||||||
last_sync = datetime.fromisoformat(last_sync_str)
|
|
||||||
grace_expires = last_sync + timedelta(hours=GRACE_HOURS)
|
|
||||||
if datetime.now(timezone.utc) > grace_expires:
|
|
||||||
logger.error("72-hour offline grace period expired — marking unlicensed")
|
|
||||||
license_state["licensed"] = False
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Recompute expiry fields from cached expires_at even when offline
|
|
||||||
expiry_fields = _compute_expiry_fields(license_state.get("expires_at"))
|
|
||||||
license_state.update(expiry_fields)
|
|
||||||
|
|
||||||
|
|
||||||
IMAGE_DIR = Path("/app/data/product_images")
|
IMAGE_DIR = Path("/app/data/product_images")
|
||||||
|
|
||||||
@@ -398,6 +402,7 @@ async def _pull_pending_orders():
|
|||||||
|
|
||||||
async def _sync_loop():
|
async def _sync_loop():
|
||||||
_load_persisted_state()
|
_load_persisted_state()
|
||||||
|
apply_license() # decide from the stored license before the first network attempt
|
||||||
while True:
|
while True:
|
||||||
await _sync_once()
|
await _sync_once()
|
||||||
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
The server's LAN address — the one phones use (http://<ip>), the pairing QR
|
||||||
|
encodes and the cloud heartbeat reports. One resolver, used everywhere.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. override — set once in the manager (pos_settings 'network.lan_ip_override')
|
||||||
|
2. detected — live, from the netinfo helper container (services/netinfo_helper.py)
|
||||||
|
3. env — HOST_IP from .env (written by install.sh)
|
||||||
|
→ None: the manager falls back to the address it was opened with.
|
||||||
|
|
||||||
|
When the address in use (override / env) differs from what the helper sees
|
||||||
|
right now, `mismatch` is set so the manager can warn before phones break
|
||||||
|
(typical cause: the router's DHCP handed the server a new address).
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
OVERRIDE_KEY = "network.lan_ip_override"
|
||||||
|
NETINFO_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
|
||||||
|
DETECTION_MAX_AGE_SECONDS = 300 # helper writes every 60s; older means it stopped
|
||||||
|
|
||||||
|
|
||||||
|
def validate_lan_ip(value: str) -> str:
|
||||||
|
"""Normalise and check an override: a private, non-loopback IPv4 address."""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("Μη έγκυρη διεύθυνση IP (π.χ. 192.168.1.50)")
|
||||||
|
if addr.version != 4 or not addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||||
|
raise ValueError("Η διεύθυνση πρέπει να είναι IPv4 τοπικού δικτύου (π.χ. 192.168.x.x ή 10.x.x.x)")
|
||||||
|
return str(addr)
|
||||||
|
|
||||||
|
|
||||||
|
def read_detected(path: str = NETINFO_FILE, now: datetime | None = None) -> dict | None:
|
||||||
|
"""Latest helper result, or None if the helper isn't running / file is stale."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
detected_at = datetime.fromisoformat(data["detected_at"])
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
data["age_seconds"] = int((now - detected_at).total_seconds())
|
||||||
|
data["stale"] = data["age_seconds"] > DETECTION_MAX_AGE_SECONDS
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_lan_ip(db=None, override: str | None = None, detected: dict | None = None,
|
||||||
|
env: str | None = None) -> dict:
|
||||||
|
"""Effective LAN IP + where it came from. Pass db to read the override setting;
|
||||||
|
the explicit arguments exist for tests."""
|
||||||
|
if db is not None and override is None:
|
||||||
|
from models.settings import PosSettings
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
|
||||||
|
override = row.value.strip() if row and row.value and row.value.strip() else None
|
||||||
|
if detected is None:
|
||||||
|
detected = read_detected()
|
||||||
|
if env is None:
|
||||||
|
env = os.environ.get("HOST_IP", "").strip() or None
|
||||||
|
|
||||||
|
live = detected if detected and not detected.get("stale") and detected.get("ip") else None
|
||||||
|
live_ip = live["ip"] if live else None
|
||||||
|
live_all = {c["ip"] for c in (live or {}).get("candidates", [])} | ({live_ip} if live_ip else set())
|
||||||
|
|
||||||
|
if override:
|
||||||
|
effective, source = override, "override"
|
||||||
|
elif live_ip:
|
||||||
|
effective, source = live_ip, "detected"
|
||||||
|
elif env:
|
||||||
|
effective, source = env, "env"
|
||||||
|
else:
|
||||||
|
effective, source = None, None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"effective": effective,
|
||||||
|
"source": source, # override | detected | env | None
|
||||||
|
"override": override,
|
||||||
|
"detected": live_ip,
|
||||||
|
"detected_candidates": (live or {}).get("candidates", []),
|
||||||
|
"detection": (
|
||||||
|
"unsupported" if detected and detected.get("unsupported")
|
||||||
|
else "stale" if detected and detected.get("stale")
|
||||||
|
else "ok" if live_ip
|
||||||
|
else "unavailable"
|
||||||
|
),
|
||||||
|
"env": env,
|
||||||
|
# Using a fixed address that this machine no longer has on any NIC
|
||||||
|
"mismatch": bool(effective and source in ("override", "env") and live_ip and effective not in live_all),
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Offline-capable licensing (KI-006).
|
||||||
|
|
||||||
|
The cloud is needed to RENEW a license, not to RUN one. Every heartbeat
|
||||||
|
brings a license token signed by the cloud (Ed25519). The site stores it and
|
||||||
|
enforces it by itself, so a venue that paid for a year keeps working for that
|
||||||
|
year even if it never goes online again. The old rule ("unlicensed after 72h
|
||||||
|
without a heartbeat") is gone.
|
||||||
|
|
||||||
|
Tamper resistance:
|
||||||
|
- Editing the stored token breaks the signature → treated as no license.
|
||||||
|
- Turning the clock back: "now" may not be earlier than the latest time this
|
||||||
|
system has provably seen — the token's signed cloud time, the newest order
|
||||||
|
in the database, and a stored high-water mark (1 day tolerance).
|
||||||
|
Limits (documented): someone with root on the server could still patch the
|
||||||
|
code itself, and a remote lock only reaches a site when it next goes online.
|
||||||
|
|
||||||
|
Rules (evaluate()):
|
||||||
|
valid token, clock OK:
|
||||||
|
active and not expired → licensed
|
||||||
|
expired: 5-day grace → licensed, warnings in the manager
|
||||||
|
grace over / site deactivated → unlicensed, but never mid-service:
|
||||||
|
deferred while a workday is open
|
||||||
|
no valid token:
|
||||||
|
old cloud without tokens, heartbeat said licensed < 72h ago → licensed (transition)
|
||||||
|
otherwise → unlicensed ("unverified")
|
||||||
|
Locks from the cloud (token.locked) keep the existing workday-deferred behaviour.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
# Matches LICENSE_SIGNING_KEY in the cloud's .env (generated 2026-09-28).
|
||||||
|
# Built into the code on purpose: a key taken from configuration could simply
|
||||||
|
# be replaced together with a self-made token.
|
||||||
|
LICENSE_PUBLIC_KEY = "2oeFHV6hgAlJsx/ZBvG6fqmWYn5tjSW5hURrrPhLoOw="
|
||||||
|
|
||||||
|
EXPIRY_GRACE = timedelta(days=5)
|
||||||
|
CLOCK_TOLERANCE = timedelta(days=1)
|
||||||
|
LEGACY_UNSIGNED_OK = timedelta(hours=72) # only while the cloud sends no tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dt(value) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value) if isinstance(value, str) else value
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str | None, site_id: str, public_key_b64: str = LICENSE_PUBLIC_KEY) -> dict | None:
|
||||||
|
"""Payload of a genuine token for this site, else None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
body, _, sig = token.partition(".")
|
||||||
|
try:
|
||||||
|
Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64)).verify(_b64url_decode(sig), body.encode())
|
||||||
|
payload = json.loads(_b64url_decode(body))
|
||||||
|
except (InvalidSignature, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if payload.get("v") != 1 or payload.get("site_id") != site_id:
|
||||||
|
return None
|
||||||
|
if not parse_dt(payload.get("expires_at")) or not parse_dt(payload.get("issued_at")):
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(payload: dict | None, now: datetime, floor: datetime | None, workday_open: bool,
|
||||||
|
legacy_ok_at: datetime | None = None) -> dict:
|
||||||
|
"""Pure license decision. `floor` = latest time this system has provably seen."""
|
||||||
|
base = {"license_verified": payload is not None, "license_problem": None, "grace_over": False,
|
||||||
|
"days_until_expiry": None, "grace_expires_at": None, "grace_days_remaining": None}
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
legacy = legacy_ok_at is not None and now - legacy_ok_at <= LEGACY_UNSIGNED_OK
|
||||||
|
return {**base, "licensed": legacy, "license_problem": None if legacy else "unverified"}
|
||||||
|
|
||||||
|
if floor is not None and now < floor - CLOCK_TOLERANCE:
|
||||||
|
return {**base, "licensed": False, "license_problem": "clock"}
|
||||||
|
|
||||||
|
expires = parse_dt(payload["expires_at"])
|
||||||
|
grace_end = expires + EXPIRY_GRACE
|
||||||
|
days_until = (expires - now).days # negative once expired
|
||||||
|
grace_over = now > grace_end
|
||||||
|
fields = {
|
||||||
|
**base,
|
||||||
|
"expires_at": expires.isoformat(),
|
||||||
|
"days_until_expiry": days_until,
|
||||||
|
"grace_expires_at": grace_end.isoformat() if days_until < 0 else None,
|
||||||
|
# Rounded up: 2 days 23 hours left reads as "3 days", as people count it
|
||||||
|
"grace_days_remaining": math.ceil((grace_end - now) / timedelta(days=1)) if days_until < 0 and not grace_over else None,
|
||||||
|
"grace_over": grace_over,
|
||||||
|
}
|
||||||
|
problem = "inactive" if not payload.get("active", True) else "expired" if grace_over else None
|
||||||
|
# Never cut a restaurant off mid-service: an open workday finishes first
|
||||||
|
return {**fields, "licensed": problem is None or workday_open, "license_problem": problem}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Host LAN-IP detector — runs as its own container with `network_mode: host`.
|
||||||
|
|
||||||
|
The backend lives in a Docker bridge network and can only see its container
|
||||||
|
address, never the machine's real network cards. This helper shares the host's
|
||||||
|
network namespace, finds the server's address on the PHYSICAL network
|
||||||
|
(Ethernet/WiFi — never a WireGuard/Tailscale/ZeroTier tunnel, Docker bridge or
|
||||||
|
veth) and writes it to a small JSON file on a volume the backend reads
|
||||||
|
(services/lan_ip.py). It refreshes every minute, so a DHCP change shows up
|
||||||
|
without anyone re-running install.sh.
|
||||||
|
|
||||||
|
Selection (same rules as install.sh's detect_host_ip):
|
||||||
|
1. the interface of the main-table default route, if it is real hardware
|
||||||
|
(real NICs have /sys/class/net/<if>/device; tunnels and bridges don't);
|
||||||
|
2. otherwise the first real-hardware interface that has an IPv4 address.
|
||||||
|
|
||||||
|
On Docker Desktop (Windows/Mac dev machines) "the host network" is Docker's own
|
||||||
|
Linux VM, whose address means nothing to phones — the helper detects that and
|
||||||
|
reports no IP instead of a wrong one.
|
||||||
|
|
||||||
|
Standalone on purpose: standard library only, no app imports, no database.
|
||||||
|
Run: python -m services.netinfo_helper
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
OUT_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
|
||||||
|
INTERVAL_SECONDS = int(os.environ.get("NETINFO_INTERVAL", "60"))
|
||||||
|
SIOCGIFADDR = 0x8915
|
||||||
|
|
||||||
|
|
||||||
|
def ipv4_of(ifname: str) -> str | None:
|
||||||
|
"""Primary IPv4 address of an interface, or None if it has none."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
try:
|
||||||
|
packed = fcntl.ioctl(s.fileno(), SIOCGIFADDR, struct.pack("256s", ifname[:15].encode()))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return socket.inet_ntoa(packed[20:24])
|
||||||
|
|
||||||
|
|
||||||
|
def default_route_iface(route_file: str = "/proc/net/route") -> str | None:
|
||||||
|
"""Interface of the main routing table's default route (lowest metric)."""
|
||||||
|
best = None
|
||||||
|
try:
|
||||||
|
with open(route_file) as f:
|
||||||
|
next(f) # header
|
||||||
|
for line in f:
|
||||||
|
cols = line.split()
|
||||||
|
if len(cols) < 7:
|
||||||
|
continue
|
||||||
|
iface, dest, flags, metric = cols[0], cols[1], int(cols[3], 16), int(cols[6])
|
||||||
|
if dest == "00000000" and flags & 0x1: # default route, RTF_UP
|
||||||
|
if best is None or metric < best[1]:
|
||||||
|
best = (iface, metric)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return best[0] if best else None
|
||||||
|
|
||||||
|
|
||||||
|
def detect(sys_net: str = "/sys/class/net", route_file: str = "/proc/net/route", ipv4=ipv4_of) -> dict:
|
||||||
|
"""Pick the physical LAN address. Pure apart from the injected lookups (testable)."""
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(sys_net))
|
||||||
|
except OSError:
|
||||||
|
names = []
|
||||||
|
candidates = []
|
||||||
|
for name in names:
|
||||||
|
if not os.path.exists(os.path.join(sys_net, name, "device")):
|
||||||
|
continue # tunnel, bridge, veth, loopback
|
||||||
|
addr = ipv4(name)
|
||||||
|
if addr and not addr.startswith("169.254."):
|
||||||
|
candidates.append({"iface": name, "ip": addr})
|
||||||
|
|
||||||
|
default_if = default_route_iface(route_file)
|
||||||
|
chosen = next((c for c in candidates if c["iface"] == default_if), None)
|
||||||
|
if chosen is None and candidates:
|
||||||
|
chosen = candidates[0]
|
||||||
|
return {
|
||||||
|
"ip": chosen["ip"] if chosen else None,
|
||||||
|
"iface": chosen["iface"] if chosen else None,
|
||||||
|
"candidates": candidates,
|
||||||
|
"default_iface": default_if,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def docker_desktop() -> bool:
|
||||||
|
"""Docker Desktop's VM kernel identifies itself; its 'host' network isn't the LAN."""
|
||||||
|
release = platform.release().lower()
|
||||||
|
return "linuxkit" in release or "microsoft" in release
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(path: str, data: dict) -> None:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def run_once() -> dict:
|
||||||
|
if docker_desktop():
|
||||||
|
result = {"ip": None, "iface": None, "candidates": [], "default_iface": None,
|
||||||
|
"unsupported": "docker-desktop"}
|
||||||
|
else:
|
||||||
|
result = detect()
|
||||||
|
result["detected_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
write_atomic(OUT_FILE, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
last_ip = object()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = run_once()
|
||||||
|
if result["ip"] != last_ip:
|
||||||
|
print(f"netinfo: LAN IP {result['ip']} via {result['iface']} "
|
||||||
|
f"(candidates: {result['candidates']}{', ' + result['unsupported'] if result.get('unsupported') else ''})",
|
||||||
|
flush=True)
|
||||||
|
last_ip = result["ip"]
|
||||||
|
except Exception as e: # never die — the backend treats a stale file as "unknown"
|
||||||
|
print(f"netinfo: detection failed: {e}", flush=True)
|
||||||
|
time.sleep(INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
The server's own TLS identity for the native app's encrypted LAN connection
|
||||||
|
(https://<LAN IP>:8443, plan step 7). Zero maintenance by design:
|
||||||
|
|
||||||
|
- Created automatically at backend startup if missing: an EC P-256 key and a
|
||||||
|
self-signed certificate valid for 10 years, stored next to the database
|
||||||
|
(<data dir>/tls/), so a backup of the data directory keeps the same identity.
|
||||||
|
- Re-issued automatically at startup when less than 2 years of validity remain
|
||||||
|
— always with the SAME key.
|
||||||
|
- Phones pin the SHA-256 of the public key (SPKI), not the certificate, so
|
||||||
|
renewals, expiry and IP changes never require touching a phone. Only a lost
|
||||||
|
key (new machine without a backup) needs the waiters to re-scan the QR.
|
||||||
|
|
||||||
|
The proxy serves :8443 with these files (nginx-proxy/nginx.conf) and starts only
|
||||||
|
after the backend is healthy, i.e. after this ran.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VALIDITY = timedelta(days=3650)
|
||||||
|
RENEW_BEFORE = timedelta(days=730)
|
||||||
|
TLS_PORT = int(os.environ.get("TLS_PORT", "8443"))
|
||||||
|
|
||||||
|
_cached_spki: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def tls_dir() -> Path:
|
||||||
|
"""<directory of the SQLite database>/tls, overridable with TLS_DIR."""
|
||||||
|
if os.environ.get("TLS_DIR"):
|
||||||
|
return Path(os.environ["TLS_DIR"])
|
||||||
|
from config import settings
|
||||||
|
db_url = settings.DATABASE_URL
|
||||||
|
db_path = db_url.split("sqlite:///", 1)[1] if db_url.startswith("sqlite:///") else "./pos.db"
|
||||||
|
return Path(db_path).resolve().parent / "tls"
|
||||||
|
|
||||||
|
|
||||||
|
def spki_sha256(public_key) -> str:
|
||||||
|
"""Base64 SHA-256 of the DER SubjectPublicKeyInfo — what phones pin."""
|
||||||
|
der = public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
return base64.b64encode(hashlib.sha256(der).digest()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_cert(key, host_ip: str | None, now: datetime) -> x509.Certificate:
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "xenia-pos")])
|
||||||
|
sans: list[x509.GeneralName] = [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]
|
||||||
|
if host_ip:
|
||||||
|
try:
|
||||||
|
sans.append(x509.IPAddress(ipaddress.ip_address(host_ip)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(name)
|
||||||
|
.issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(days=1))
|
||||||
|
.not_valid_after(now + VALIDITY)
|
||||||
|
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tls_identity(directory: Path | None = None, now: datetime | None = None) -> dict:
|
||||||
|
"""Create the key/cert if missing, renew the cert (same key) if it expires
|
||||||
|
within RENEW_BEFORE. Returns {spki_sha256, not_after, created, renewed}."""
|
||||||
|
global _cached_spki
|
||||||
|
directory = directory or tls_dir()
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
key_file, cert_file = directory / "key.pem", directory / "cert.pem"
|
||||||
|
created = renewed = False
|
||||||
|
|
||||||
|
if key_file.exists():
|
||||||
|
key = serialization.load_pem_private_key(key_file.read_bytes(), password=None)
|
||||||
|
else:
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
tmp = key_file.with_suffix(".tmp")
|
||||||
|
tmp.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption()))
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, key_file)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
cert = None
|
||||||
|
if cert_file.exists() and not created:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_file.read_bytes())
|
||||||
|
if cert.public_key().public_numbers() != key.public_key().public_numbers():
|
||||||
|
cert = None # cert from another key — never serve a mismatched pair
|
||||||
|
if cert is None or cert.not_valid_after_utc - now < RENEW_BEFORE:
|
||||||
|
renewed = cert is not None
|
||||||
|
cert = _issue_cert(key, os.environ.get("HOST_IP", "").strip() or None, now)
|
||||||
|
tmp = cert_file.with_suffix(".tmp")
|
||||||
|
tmp.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
os.replace(tmp, cert_file)
|
||||||
|
|
||||||
|
_cached_spki = spki_sha256(key.public_key())
|
||||||
|
if created or renewed:
|
||||||
|
logger.info("TLS identity %s (key pin %s, valid until %s)",
|
||||||
|
"created" if created else "renewed", _cached_spki, cert.not_valid_after_utc.date())
|
||||||
|
return {"spki_sha256": _cached_spki, "not_after": cert.not_valid_after_utc.isoformat(),
|
||||||
|
"created": created, "renewed": renewed}
|
||||||
|
|
||||||
|
|
||||||
|
def tls_info() -> dict | None:
|
||||||
|
"""What /api/system/identity advertises, or None if TLS isn't set up."""
|
||||||
|
if _cached_spki is None:
|
||||||
|
try:
|
||||||
|
ensure_tls_identity()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("TLS identity unavailable")
|
||||||
|
return None
|
||||||
|
return {"port": TLS_PORT, "spki_sha256": _cached_spki}
|
||||||
@@ -15,11 +15,18 @@ import client from '../api/client'
|
|||||||
* grace_expires_at string | null (ISO)
|
* grace_expires_at string | null (ISO)
|
||||||
* grace_days_remaining number | null
|
* grace_days_remaining number | null
|
||||||
* sync_failed bool
|
* sync_failed bool
|
||||||
|
* offline_days number | null days since the last successful cloud contact
|
||||||
|
* license_problem "clock" | "unverified" | "expired" | "inactive" | null
|
||||||
|
* grace_over bool
|
||||||
|
*
|
||||||
|
* The site enforces its signed license offline (KI-006): it keeps running
|
||||||
|
* until expiry however long it has no internet — offline_days is informational.
|
||||||
*
|
*
|
||||||
* Derived helpers:
|
* Derived helpers:
|
||||||
* showExpiryWarning bool — true when 0 < days_until_expiry <= 5
|
* showExpiryWarning bool — true when 0 <= days_until_expiry <= 14
|
||||||
* inGracePeriod bool — true when expired but grace not yet over
|
* inGracePeriod bool — true when expired but grace not yet over
|
||||||
* isBlocked bool — locked=true OR (unlicensed AND grace over)
|
* isBlocked bool — locked=true OR unlicensed
|
||||||
|
* longOffline bool — no cloud contact for 7+ days (renewal needs it)
|
||||||
*/
|
*/
|
||||||
export default function useLicenseStatus() {
|
export default function useLicenseStatus() {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -41,9 +48,13 @@ export default function useLicenseStatus() {
|
|||||||
grace_expires_at: null,
|
grace_expires_at: null,
|
||||||
grace_days_remaining: null,
|
grace_days_remaining: null,
|
||||||
sync_failed: false,
|
sync_failed: false,
|
||||||
|
offline_days: null,
|
||||||
|
license_problem: null,
|
||||||
|
grace_over: false,
|
||||||
showExpiryWarning: false,
|
showExpiryWarning: false,
|
||||||
inGracePeriod: false,
|
inGracePeriod: false,
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
|
longOffline: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +68,21 @@ export default function useLicenseStatus() {
|
|||||||
grace_expires_at = null,
|
grace_expires_at = null,
|
||||||
grace_days_remaining = null,
|
grace_days_remaining = null,
|
||||||
sync_failed = false,
|
sync_failed = false,
|
||||||
|
offline_days = null,
|
||||||
|
license_problem = null,
|
||||||
|
grace_over = false,
|
||||||
} = data
|
} = data
|
||||||
|
|
||||||
const showExpiryWarning =
|
const showExpiryWarning =
|
||||||
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 5
|
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 14
|
||||||
|
|
||||||
const inGracePeriod =
|
const inGracePeriod =
|
||||||
days_until_expiry != null && days_until_expiry < 0 && licensed
|
days_until_expiry != null && days_until_expiry < 0 && licensed && !grace_over
|
||||||
|
|
||||||
const isBlocked = locked || !licensed
|
const isBlocked = locked || !licensed
|
||||||
|
|
||||||
|
const longOffline = offline_days != null && offline_days >= 7
|
||||||
|
|
||||||
return {
|
return {
|
||||||
licensed,
|
licensed,
|
||||||
locked,
|
locked,
|
||||||
@@ -77,8 +93,12 @@ export default function useLicenseStatus() {
|
|||||||
grace_expires_at,
|
grace_expires_at,
|
||||||
grace_days_remaining,
|
grace_days_remaining,
|
||||||
sync_failed,
|
sync_failed,
|
||||||
|
offline_days,
|
||||||
|
license_problem,
|
||||||
|
grace_over,
|
||||||
showExpiryWarning,
|
showExpiryWarning,
|
||||||
inGracePeriod,
|
inGracePeriod,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
|
longOffline,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ function PageTitle() {
|
|||||||
|
|
||||||
function LicenseBanner({ license }) {
|
function LicenseBanner({ license }) {
|
||||||
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
|
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
|
||||||
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license
|
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked,
|
||||||
|
sync_failed, offline_days, longOffline } = license
|
||||||
|
// Renewal needs the cloud: say so whenever the site can't reach it right now
|
||||||
|
const connectHint = sync_failed ? ' Συνδέστε τον server στο internet για να ανανεωθεί.' : ''
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
@@ -103,12 +106,30 @@ function LicenseBanner({ license }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lock_reason === 'clock') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||||
|
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||||
|
Η ώρα του server έχει γυρίσει πίσω. Διορθώστε την ημερομηνία/ώρα του server ή συνδέστε τον στο internet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lock_reason === 'unverified') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||||
|
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||||
|
Δεν υπάρχει επιβεβαιωμένη άδεια χρήσης. Συνδέστε τον server στο internet για να ενεργοποιηθεί.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isBlocked && lock_reason === 'expired') {
|
if (isBlocked && lock_reason === 'expired') {
|
||||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
|
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.{connectHint}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +140,7 @@ function LicenseBanner({ license }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
|
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
|
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.{connectHint}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,7 +150,17 @@ function LicenseBanner({ license }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
|
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.
|
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.{connectHint}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informational: the license keeps working offline, but renewal needs the cloud
|
||||||
|
if (longOffline) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-slate-50 border-b border-slate-200 text-slate-600 text-[13px] font-medium">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
Χωρίς σύνδεση με το cloud εδώ και {offline_days} μέρες. Το σύστημα λειτουργεί κανονικά — η άδεια ισχύει έως {fmtDate(expires_at)}.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ function DataTransferSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function QRModal({ url, onClose }) {
|
function QRModal({ url, caption = 'Σαρώστε με το κινητό για σύνδεση στο σύστημα.', onClose }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -314,15 +314,156 @@ function QRModal({ url, onClose }) {
|
|||||||
<div className="p-3 bg-white border border-gray-200 rounded-xl">
|
<div className="p-3 bg-white border border-gray-200 rounded-xl">
|
||||||
<QRCodeSVG value={url} size={220} includeMargin={false} />
|
<QRCodeSVG value={url} size={220} includeMargin={false} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 text-center">
|
<p className="text-xs text-gray-400 text-center">{caption}</p>
|
||||||
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
|
|
||||||
</p>
|
|
||||||
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
|
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/
|
||||||
|
|
||||||
|
// Address phones use on the LAN. The server resolves it (manual override →
|
||||||
|
// live detection → HOST_IP, see local_backend/services/lan_ip.py); if it knows
|
||||||
|
// nothing and this dashboard was itself opened by IP, that IP is the server.
|
||||||
|
function resolveLanIp(status) {
|
||||||
|
if (status?.lan_ip) return { ip: status.lan_ip, source: status.lan_ip_info?.source ?? 'env' }
|
||||||
|
const host = window.location.hostname
|
||||||
|
return IPV4.test(host) && !host.startsWith('127.') ? { ip: host, source: 'browser' } : { ip: null, source: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAN_SOURCE_LABEL = {
|
||||||
|
override: 'Χειροκίνητη ρύθμιση',
|
||||||
|
detected: 'Αυτόματη ανίχνευση',
|
||||||
|
env: 'Από το .env (HOST_IP)',
|
||||||
|
browser: 'Από τη διεύθυνση του browser',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETECTION_NOTE = {
|
||||||
|
unsupported: 'Η αυτόματη ανίχνευση λειτουργεί μόνο σε Linux server (όχι σε Docker Desktop).',
|
||||||
|
stale: 'Η αυτόματη ανίχνευση σταμάτησε να ενημερώνεται — ελέγξτε το container netinfo.',
|
||||||
|
unavailable: 'Η αυτόματη ανίχνευση δεν είναι διαθέσιμη (το container netinfo δεν τρέχει).',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SMALL_BTN = 'flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0 disabled:opacity-50'
|
||||||
|
|
||||||
|
function LanAccessSection({ status, onShowQr }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const info = status?.lan_ip_info
|
||||||
|
const { ip, source } = resolveLanIp(status)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: newIp => client.put('/api/system/lan-ip-override', { ip: newIp }).then(r => r.data),
|
||||||
|
onSuccess: (_, newIp) => {
|
||||||
|
toast.success(newIp ? 'Η IP αποθηκεύτηκε' : 'Επαναφορά σε αυτόματη ανίχνευση')
|
||||||
|
setEditing(false)
|
||||||
|
qc.invalidateQueries({ queryKey: ['system-status'] })
|
||||||
|
},
|
||||||
|
onError: err => toast.error(err.response?.data?.detail || 'Αποτυχία αποθήκευσης'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setValue(info?.override || ip || '')
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<h2 className="font-semibold text-gray-700">Τοπικό δίκτυο</h2>
|
||||||
|
|
||||||
|
{info?.mismatch && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 space-y-2">
|
||||||
|
<p>
|
||||||
|
Χρησιμοποιείται η IP <span className="font-mono font-semibold">{ip}</span>, όμως ο server έχει τώρα
|
||||||
|
την <span className="font-mono font-semibold">{info.detected}</span>. Τα κινητά και το QR δεν θα
|
||||||
|
συνδέονται μέχρι να διορθωθεί.
|
||||||
|
</p>
|
||||||
|
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
|
||||||
|
Χρήση αυτόματης ανίχνευσης ({info.detected})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="text-gray-500">IP server</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && value.trim() && saveMut.mutate(value.trim())}
|
||||||
|
placeholder={info?.detected || '192.168.1.50'}
|
||||||
|
className="input h-7 text-xs font-mono w-36"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className={SMALL_BTN} disabled={saveMut.isPending || !value.trim()} onClick={() => saveMut.mutate(value.trim())}>
|
||||||
|
Αποθήκευση
|
||||||
|
</button>
|
||||||
|
{info?.override && (
|
||||||
|
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
|
||||||
|
Αυτόματα
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className={SMALL_BTN} onClick={() => setEditing(false)}>Ακύρωση</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-gray-800 text-xs font-mono">{ip ?? '—'}</span>
|
||||||
|
{source && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{LAN_SOURCE_LABEL[source]}</span>
|
||||||
|
)}
|
||||||
|
<button className={SMALL_BTN} onClick={startEdit}>Αλλαγή</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editing && info && info.detection !== 'ok' && (
|
||||||
|
<p className="text-xs text-gray-400">{DETECTION_NOTE[info.detection]}</p>
|
||||||
|
)}
|
||||||
|
{!ip && !editing && (
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
Άγνωστη IP — ορίστε την εδώ (π.χ. 192.168.1.50) ή <span className="font-mono">HOST_IP</span> στο .env.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ip && (
|
||||||
|
<>
|
||||||
|
<div className="text-gray-500">Σερβιτόροι (WiFi)</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onShowQr({
|
||||||
|
url: waiterPairUrl(ip, status?.site_id, status?.tls?.spki_sha256),
|
||||||
|
caption: 'Σαρώστε με την κάμερα του κινητού — ανοίγει την εφαρμογή σερβιτόρου. Το κινητό πρέπει να είναι στο ίδιο WiFi.',
|
||||||
|
})}
|
||||||
|
className={SMALL_BTN}
|
||||||
|
>
|
||||||
|
QR Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">Διαχείριση (WiFi)</div>
|
||||||
|
<div className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}/manager</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing URL: opens the waiter app when scanned with the phone camera, and the
|
||||||
|
// native app reads the same code (?pair=<site_id>) to pair with this venue.
|
||||||
|
// k = the server's TLS public-key pin: the app refuses a server whose key
|
||||||
|
// differs, then talks to it encrypted on :8443 (plan step 7).
|
||||||
|
function waiterPairUrl(lanIp, siteId, spki) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (siteId) params.set('pair', siteId)
|
||||||
|
if (siteId && spki) params.set('k', spki)
|
||||||
|
const query = params.toString()
|
||||||
|
return `http://${lanIp}/` + (query ? `?${query}` : '')
|
||||||
|
}
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
function formatUptime(seconds) {
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
@@ -334,7 +475,7 @@ export default function AppInfoTab() {
|
|||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [qrOpen, setQrOpen] = useState(false)
|
const [qr, setQr] = useState(null) // { url, caption } | null
|
||||||
const { data: status, isLoading } = useQuery({
|
const { data: status, isLoading } = useQuery({
|
||||||
queryKey: ['system-status'],
|
queryKey: ['system-status'],
|
||||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||||
@@ -416,11 +557,12 @@ export default function AppInfoTab() {
|
|||||||
)}
|
)}
|
||||||
{status?.waiter_domain && (
|
{status?.waiter_domain && (
|
||||||
<>
|
<>
|
||||||
<div className="text-gray-500">Waiter Domain</div>
|
{/* Retired per-client domain setup (plan step 9): shown only for sites that still have one */}
|
||||||
|
<div className="text-gray-500">Waiter Domain (παλιό σύστημα)</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
|
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setQrOpen(true)}
|
onClick={() => setQr({ url: status.waiter_domain })}
|
||||||
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
|
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
QR Code
|
QR Code
|
||||||
@@ -431,6 +573,8 @@ export default function AppInfoTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LanAccessSection status={status} onShowQr={setQr} />
|
||||||
|
|
||||||
<TimezoneSection />
|
<TimezoneSection />
|
||||||
|
|
||||||
<StatsSection />
|
<StatsSection />
|
||||||
@@ -450,9 +594,7 @@ export default function AppInfoTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{qrOpen && status?.waiter_domain && (
|
{qr && <QRModal url={qr.url} caption={qr.caption} onClose={() => setQr(null)} />}
|
||||||
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,102 @@
|
|||||||
|
# Xenia POS — on-site proxy config.
|
||||||
|
# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the
|
||||||
|
# repo is a byte-identical copy. Change both together (global Working Rule 9).
|
||||||
|
#
|
||||||
|
# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup)
|
||||||
|
# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only
|
||||||
|
# /manager → redirect to :8081
|
||||||
|
# :8081 → manager dashboard over plain HTTP, LAN only
|
||||||
|
# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem)
|
||||||
|
# :4443 → manager over https
|
||||||
|
# :8443 → native app over TLS (backend-managed key, pinned), LAN only
|
||||||
|
#
|
||||||
|
# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and
|
||||||
|
# disables buffering (SSE), otherwise live events never reach phones / KDS.
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Plain HTTP ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_name waiter.* manager.*;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location ~ ^/manager/?$ {
|
||||||
|
return 302 http://$host:8081/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8081 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── HTTPS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name waiter.* _;
|
server_name waiter.*;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
@@ -16,18 +107,20 @@ server {
|
|||||||
proxy_pass http://waiter_pwa:80;
|
proxy_pass http://waiter_pwa:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $http_connection;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_read_timeout 3600;
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 4443 ssl;
|
listen 443 ssl;
|
||||||
server_name manager.* _;
|
server_name manager.*;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
@@ -36,9 +129,115 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://manager_dashboard:80;
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Encrypted LAN entry for the native app (plan step 7). Key + self-signed cert
|
||||||
|
# are created and renewed by the backend (services/tls_identity.py) under
|
||||||
|
# ${DATA_PATH}/tls; phones pin the public key, so renewals need no action.
|
||||||
|
ssl_certificate /etc/nginx/xenia-tls/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/xenia-tls/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# LAN only: plain HTTP must never be reachable from the internet, even if the
|
||||||
|
# client forwards ports on their router. Relies on Docker preserving the real
|
||||||
|
# client IP (default iptables port publishing on Linux).
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 192.168.0.0/16;
|
||||||
|
allow 127.0.0.0/8;
|
||||||
|
allow fc00::/7;
|
||||||
|
allow fe80::/10;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://waiter_pwa:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 4443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://manager_dashboard:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Run this once on the server machine to generate SSL certificates.
|
|
||||||
# Requires mkcert: https://github.com/FiloSottile/mkcert
|
|
||||||
#
|
|
||||||
# After running this script, install the CA on each device that needs
|
|
||||||
# to access the system (phones, tablets, other PCs).
|
|
||||||
#
|
|
||||||
# Usage: bash setup-ssl.sh [SERVER_IP]
|
|
||||||
# Example: bash setup-ssl.sh 192.168.1.50
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SERVER_IP="${1:-$(hostname -I | awk '{print $1}')}"
|
|
||||||
CERT_DIR="$(dirname "$0")/certs"
|
|
||||||
|
|
||||||
echo "Setting up SSL for IP: $SERVER_IP"
|
|
||||||
echo "Certificates will be saved to: $CERT_DIR"
|
|
||||||
|
|
||||||
# Install mkcert if not present
|
|
||||||
if ! command -v mkcert &> /dev/null; then
|
|
||||||
echo "Installing mkcert..."
|
|
||||||
if command -v apt-get &> /dev/null; then
|
|
||||||
sudo apt-get update -q && sudo apt-get install -y mkcert libnss3-tools
|
|
||||||
elif command -v brew &> /dev/null; then
|
|
||||||
brew install mkcert nss
|
|
||||||
else
|
|
||||||
echo "ERROR: Please install mkcert manually: https://github.com/FiloSottile/mkcert"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install the local CA (makes this machine trust its own certs)
|
|
||||||
mkcert -install
|
|
||||||
|
|
||||||
# Generate the certificate for this machine's IP (and localhost for dev)
|
|
||||||
mkdir -p "$CERT_DIR"
|
|
||||||
mkcert \
|
|
||||||
-cert-file "$CERT_DIR/cert.pem" \
|
|
||||||
-key-file "$CERT_DIR/key.pem" \
|
|
||||||
"$SERVER_IP" \
|
|
||||||
"localhost" \
|
|
||||||
"127.0.0.1"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done! Certificates saved to $CERT_DIR"
|
|
||||||
echo ""
|
|
||||||
echo "======================================================"
|
|
||||||
echo " NEXT STEP: Install the CA on each device"
|
|
||||||
echo "======================================================"
|
|
||||||
echo ""
|
|
||||||
echo "The CA certificate is at:"
|
|
||||||
mkcert -CAROOT
|
|
||||||
echo ""
|
|
||||||
echo "On Android phones:"
|
|
||||||
echo " 1. Copy the 'rootCA.pem' file from the path above to the phone"
|
|
||||||
echo " 2. Settings > Security > Install certificate > CA certificate"
|
|
||||||
echo " 3. Select the rootCA.pem file"
|
|
||||||
echo ""
|
|
||||||
echo "On Windows PCs:"
|
|
||||||
echo " 1. Copy rootCA.pem and rename to rootCA.crt"
|
|
||||||
echo " 2. Double-click it > Install Certificate > Local Machine"
|
|
||||||
echo " 3. Place in: Trusted Root Certification Authorities"
|
|
||||||
echo ""
|
|
||||||
echo "The apps will then be accessible at:"
|
|
||||||
echo " Waiter PWA: https://$SERVER_IP"
|
|
||||||
echo " Manager Dashboard: https://$SERVER_IP:4443"
|
|
||||||
echo "======================================================"
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
# Local-only build outputs — never part of the image build context
|
||||||
|
android
|
||||||
|
releases
|
||||||
|
dist-native
|
||||||
|
|||||||
@@ -22,3 +22,10 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Native (Capacitor) web bundle — rebuilt by `npm run build:native`
|
||||||
|
dist-native
|
||||||
|
# Built APKs (npm run apk:debug / apk:release) — distribute, never commit
|
||||||
|
releases/
|
||||||
|
# Release APK bundled into the web build by scripts/build-apk.mjs — never commit
|
||||||
|
public/downloads/
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
FROM node:20-slim AS builder
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
# Shown in the UI bundle manifest (plan step 8); the bundle's identity is its content hash
|
||||||
|
ARG APP_VERSION=0.0.0
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,7 +9,9 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
# Web build (served to browsers) + native UI bundle that phones download from
|
||||||
|
# this server: dist/downloads/waiter-bundle.json + waiter-bundle-<hash>.zip
|
||||||
|
RUN npm run build && npx vite build --mode native && APP_VERSION=$APP_VERSION node scripts/pack-bundle.mjs
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
// Release signing key lives OUTSIDE the repo (never commit it):
|
||||||
|
// %USERPROFILE%\.xenia\keystore.properties (storeFile, storePassword, keyAlias, keyPassword)
|
||||||
|
// Override the location with the XENIA_KEYSTORE_PROPS env var. Every APK handed to
|
||||||
|
// a venue must be signed with this same key, or phones refuse to install updates.
|
||||||
|
def keystorePropsFile = file(System.getenv('XENIA_KEYSTORE_PROPS') ?: "${System.getProperty('user.home')}/.xenia/keystore.properties")
|
||||||
|
def keystoreProps = new Properties()
|
||||||
|
if (keystorePropsFile.exists()) {
|
||||||
|
keystorePropsFile.withInputStream { keystoreProps.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "gr.bonamin.xenia"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "gr.bonamin.xenia"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
// Bump BOTH for every APK handed out: Android only installs an update over
|
||||||
|
// an existing install when versionCode is higher.
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (keystoreProps['storeFile']) {
|
||||||
|
storeFile file(keystoreProps['storeFile'])
|
||||||
|
storePassword keystoreProps['storePassword']
|
||||||
|
keyAlias keystoreProps['keyAlias']
|
||||||
|
keyPassword keystoreProps['keyPassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
if (keystoreProps['storeFile']) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
} else {
|
||||||
|
logger.warn("Xenia: no keystore at ${keystorePropsFile} - release APK will be UNSIGNED")
|
||||||
|
}
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capgo-capacitor-updater')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- allowBackup=false: device backups must not carry login tokens to another phone.
|
||||||
|
networkSecurityConfig: venue servers are reached over plain HTTP on the LAN
|
||||||
|
(http://<LAN IP>), see res/xml/network_security_config.xml. -->
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Camera: scanning the pairing QR code (requested only when the scanner opens) -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package gr.bonamin.xenia;
|
||||||
|
|
||||||
|
import android.net.http.SslError;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.SslErrorHandler;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import com.getcapacitor.BridgeWebViewClient;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
private static final String TAG = "XeniaTLS";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(XeniaTlsPlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
// Venue servers present a self-signed certificate on https://<ip>:8443.
|
||||||
|
// Accept it only if its public key is pinned (TrustStore) — this callback
|
||||||
|
// covers fetch/XHR, images AND WebSockets, and the WebView remembers the
|
||||||
|
// decision for that host for the rest of the session.
|
||||||
|
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
|
||||||
|
@Override
|
||||||
|
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
|
||||||
|
String pin = TrustStore.spkiSha256(error.getCertificate());
|
||||||
|
if (pin != null && TrustStore.get(getApplicationContext()).contains(pin)) {
|
||||||
|
handler.proceed();
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Refused TLS connection to " + error.getUrl() + " (key " + pin + " not pinned)");
|
||||||
|
handler.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package gr.bonamin.xenia;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.http.SslCertificate;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Base64;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-key pins of every paired venue server (plan step 7).
|
||||||
|
*
|
||||||
|
* Venue servers use self-signed certificates, so the WebView always reports an
|
||||||
|
* SSL error for them. We accept such a connection only when the server's public
|
||||||
|
* key (SHA-256 of its SubjectPublicKeyInfo, base64) belongs to a venue this
|
||||||
|
* phone paired with. Pins are per key, not per address: IP changes, certificate
|
||||||
|
* renewals and expiry never break the connection; a different key always does.
|
||||||
|
* The JS side keeps the set in sync through XeniaTlsPlugin.
|
||||||
|
*/
|
||||||
|
final class TrustStore {
|
||||||
|
private static final String PREFS = "xenia_tls";
|
||||||
|
private static final String KEY = "trusted_spki_sha256";
|
||||||
|
private static Set<String> cache;
|
||||||
|
|
||||||
|
private TrustStore() {}
|
||||||
|
|
||||||
|
static synchronized Set<String> get(Context ctx) {
|
||||||
|
if (cache == null) {
|
||||||
|
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
cache = new HashSet<>(prefs.getStringSet(KEY, Collections.emptySet()));
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
static synchronized void set(Context ctx, Set<String> pins) {
|
||||||
|
cache = new HashSet<>(pins);
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY, cache).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base64 SHA-256 of the certificate's SubjectPublicKeyInfo, or null if unreadable. */
|
||||||
|
static String spkiSha256(SslCertificate sslCert) {
|
||||||
|
try {
|
||||||
|
X509Certificate x509;
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
x509 = sslCert.getX509Certificate();
|
||||||
|
} else {
|
||||||
|
// API 24-28: the DER bytes are only reachable through the saved state
|
||||||
|
Bundle state = SslCertificate.saveState(sslCert);
|
||||||
|
byte[] der = state.getByteArray("x509-certificate");
|
||||||
|
x509 = (X509Certificate) CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(new ByteArrayInputStream(der));
|
||||||
|
}
|
||||||
|
if (x509 == null) return null;
|
||||||
|
byte[] hash = MessageDigest.getInstance("SHA-256").digest(x509.getPublicKey().getEncoded());
|
||||||
|
return Base64.encodeToString(hash, Base64.NO_WRAP);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package gr.bonamin.xenia;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSArray;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.json.JSONException;
|
||||||
|
|
||||||
|
/** JS → native: the set of venue-server key pins the WebView may trust (see TrustStore). */
|
||||||
|
@CapacitorPlugin(name = "XeniaTls")
|
||||||
|
public class XeniaTlsPlugin extends Plugin {
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void setTrustedKeys(PluginCall call) {
|
||||||
|
JSArray keys = call.getArray("keys");
|
||||||
|
Set<String> pins = new HashSet<>();
|
||||||
|
try {
|
||||||
|
if (keys != null) {
|
||||||
|
for (int i = 0; i < keys.length(); i++) {
|
||||||
|
String k = keys.getString(i);
|
||||||
|
if (k != null && !k.isEmpty()) pins.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (JSONException e) {
|
||||||
|
call.reject("keys must be an array of strings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TrustStore.set(getContext(), pins);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 251 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 899 B |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 329 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Xenia</string>
|
||||||
|
<string name="title_activity_main">Xenia</string>
|
||||||
|
<string name="package_name">gr.bonamin.xenia</string>
|
||||||
|
<string name="custom_url_scheme">gr.bonamin.xenia</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Venue servers are local machines reached by LAN IP over plain HTTP
|
||||||
|
(http://192.168.x.x). IP ranges can't be listed in a domain-config, so
|
||||||
|
cleartext is allowed app-wide. The app only ever talks to servers the user
|
||||||
|
explicitly paired (QR code / typed address) and validated via
|
||||||
|
/api/system/identity. Plan step 7 replaces this with a pinned self-signed cert.
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capgo-capacitor-updater'
|
||||||
|
project(':capgo-capacitor-updater').projectDir = new File('../node_modules/@capgo/capacitor-updater/android')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||