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.bonamin.gr
|
||||
REGISTRY=registry.bonamin.net
|
||||
VERSION=0.1.0
|
||||
|
||||
# Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
|
||||
SITE_ID=your-site-id
|
||||
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
|
||||
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)
|
||||
MASTER_USERNAME=
|
||||
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:
|
||||
build:
|
||||
context: ./waiter_pwa
|
||||
args:
|
||||
APP_VERSION: ${VERSION:-dev}
|
||||
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
@@ -11,12 +11,35 @@ services:
|
||||
- DATABASE_URL=sqlite:////app/data/pos.db
|
||||
- VERSION=${VERSION:-0.0.0}
|
||||
- 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_PASSWORD=${MASTER_PASSWORD:-}
|
||||
volumes:
|
||||
- ${DATA_PATH}:/app/data
|
||||
- ${LOGO_PATH}:/app/logo.png:ro
|
||||
- ${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:
|
||||
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
||||
@@ -36,10 +59,20 @@ services:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "4443:4443"
|
||||
- "8081:8081" # manager over plain HTTP (LAN only)
|
||||
- "8443:8443" # native app over TLS with a pinned key (LAN only)
|
||||
volumes:
|
||||
- ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
- ${DATA_PATH}/tls:/etc/nginx/xenia-tls:ro
|
||||
depends_on:
|
||||
- waiter_pwa
|
||||
- manager_dashboard
|
||||
backend:
|
||||
condition: service_healthy
|
||||
waiter_pwa:
|
||||
condition: service_started
|
||||
manager_dashboard:
|
||||
condition: service_started
|
||||
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)"
|
||||
|
||||
# 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 ""
|
||||
|
||||
@@ -27,13 +61,16 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
||||
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 ""
|
||||
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 ""
|
||||
|
||||
read -rp " Enter SITE_ID: " INPUT_SITE_ID
|
||||
read -rp " Enter SITE_KEY: " INPUT_SITE_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
|
||||
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_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/^HOST_IP=.*/HOST_IP=${INPUT_HOST_IP}/" "$SCRIPT_DIR/.env"
|
||||
|
||||
echo ""
|
||||
echo " .env written. Review it at: $SCRIPT_DIR/.env"
|
||||
echo ""
|
||||
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
|
||||
|
||||
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
|
||||
echo "[ 3/5 ] Writing nginx proxy config..."
|
||||
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 {
|
||||
listen 80;
|
||||
server_name waiter.* manager.*;
|
||||
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 {
|
||||
listen 443 ssl;
|
||||
server_name waiter.*;
|
||||
@@ -70,10 +205,16 @@ server {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,33 +229,116 @@ server {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
@@ -122,34 +346,24 @@ EOF
|
||||
# ── 4. 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
|
||||
echo " Certificates already exist — skipping."
|
||||
echo " Certificates already exist — keeping them."
|
||||
else
|
||||
echo ""
|
||||
echo " No certificates found in certs/"
|
||||
echo ""
|
||||
echo " DNS requirement:"
|
||||
echo " Two subdomains must point to this machine's IP:"
|
||||
echo " waiter.YOURDOMAIN → this machine's IP"
|
||||
echo " manager.YOURDOMAIN → this machine's IP"
|
||||
echo " The waiter domain should also be registered in the sysadmin"
|
||||
echo " panel as the 'Waiter Domain' so phones get the QR code."
|
||||
echo ""
|
||||
echo " Option A — Let's Encrypt (recommended):"
|
||||
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 ""
|
||||
CERT_IP=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2)
|
||||
CERT_SAN="DNS:localhost,IP:127.0.0.1"
|
||||
[ -n "$CERT_IP" ] && CERT_SAN="$CERT_SAN,IP:$CERT_IP"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-keyout "$SCRIPT_DIR/certs/key.pem" \
|
||||
-out "$SCRIPT_DIR/certs/cert.pem" \
|
||||
-subj "/CN=xenia-pos" \
|
||||
-addext "subjectAltName=$CERT_SAN" >/dev/null 2>&1
|
||||
chmod 600 "$SCRIPT_DIR/certs/key.pem"
|
||||
echo " No certificates found — generated a self-signed one (valid 10 years)."
|
||||
echo " Phones use plain HTTP on the LAN, so this is only for the HTTPS ports."
|
||||
fi
|
||||
|
||||
# ── 5. Logo ───────────────────────────────────────────────────────────────────
|
||||
@@ -161,18 +375,20 @@ if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
|
||||
fi
|
||||
|
||||
# ── 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 "=== Setup complete ==="
|
||||
echo ""
|
||||
|
||||
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
|
||||
echo "Starting stack..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
|
||||
echo ""
|
||||
echo "Done! Services running."
|
||||
echo " Waiter app: https://waiter.YOURDOMAIN"
|
||||
echo " Manager app: https://manager.YOURDOMAIN"
|
||||
else
|
||||
echo "Add SSL certificates to certs/ then run:"
|
||||
echo " docker compose up -d"
|
||||
fi
|
||||
echo "Starting stack..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
|
||||
echo ""
|
||||
echo "Done! Services running. From any phone or PC on this network:"
|
||||
echo " Waiter app: http://${HOST_IP_NOW}"
|
||||
echo " Manager app: http://${HOST_IP_NOW}/manager (→ port 8081)"
|
||||
echo ""
|
||||
echo "Tip: reserve ${HOST_IP_NOW} for this machine in the router's DHCP settings"
|
||||
echo " so the address never changes."
|
||||
echo ""
|
||||
echo "If the proxy config changed, restart it: docker compose restart proxy"
|
||||
|
||||
@@ -771,6 +771,9 @@ async def lifespan(app: FastAPI):
|
||||
ws_init_loop(loop)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_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()
|
||||
pruned = prune_old_events(hours=24)
|
||||
if pruned:
|
||||
|
||||
@@ -24,8 +24,8 @@ license_state: dict = {
|
||||
"latest_version": None,
|
||||
}
|
||||
|
||||
# Paths that bypass all license checks (health probe)
|
||||
EXEMPT_PATHS = {"/api/system/health"}
|
||||
# Paths that bypass all license checks (health probe, venue identity for pairing)
|
||||
EXEMPT_PATHS = {"/api/system/health", "/api/system/identity"}
|
||||
|
||||
# Paths that are always allowed so the frontend can read license status
|
||||
# and managers can still log in / close the workday when restricted
|
||||
|
||||
@@ -9,3 +9,4 @@ bcrypt==4.2.0
|
||||
pyjwt==2.9.0
|
||||
httpx==0.27.2
|
||||
python-multipart==0.0.9
|
||||
cryptography==46.0.4
|
||||
|
||||
@@ -168,8 +168,10 @@ def close_business_day(
|
||||
if license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
from services.cloud_sync import _persist_state
|
||||
_persist_state()
|
||||
# Re-evaluate the license now that no workday is open (an expiry whose grace
|
||||
# ended mid-service takes effect here) — also persists the state
|
||||
from services.cloud_sync import apply_license
|
||||
apply_license()
|
||||
|
||||
return day
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
@@ -13,10 +15,13 @@ from models.printer import Printer
|
||||
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||
from models.user import User
|
||||
from models.settings import PosSettings
|
||||
from models.product import Category, Product
|
||||
from models.table import Table, TableGroup
|
||||
from services import printer_service
|
||||
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 config import settings
|
||||
|
||||
@@ -30,15 +35,56 @@ def health():
|
||||
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")
|
||||
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()
|
||||
printer_statuses = []
|
||||
for p in printers:
|
||||
reachable = printer_service.check_printer(p.ip_address, p.port)
|
||||
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
||||
|
||||
lan = resolve_lan_ip(db)
|
||||
|
||||
licensed = license_state.get("licensed", True)
|
||||
locked = license_state.get("locked", 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")
|
||||
grace_expires_at = license_state.get("grace_expires_at")
|
||||
|
||||
# Determine lock_reason for the frontend banner logic
|
||||
# "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"
|
||||
lock_reason = _lock_reason()
|
||||
|
||||
# Grace days remaining (only meaningful while in expiry grace period)
|
||||
grace_days_remaining = None
|
||||
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
|
||||
# Computed by services/license.py (only set during the expiry grace period)
|
||||
grace_days_remaining = license_state.get("grace_days_remaining")
|
||||
|
||||
return {
|
||||
"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,
|
||||
"sync_failed": license_state.get("sync_failed", False),
|
||||
"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"),
|
||||
"site_id": settings.SITE_ID or None,
|
||||
"lan_ip": lan.get("effective"),
|
||||
"lan_ip_info": lan,
|
||||
"tls": tls_info(),
|
||||
"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")
|
||||
async def sync_license_now(user: User = Depends(require_manager)):
|
||||
"""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),
|
||||
"locked": license_state.get("locked", False),
|
||||
"lock_pending": license_state.get("lock_pending", False),
|
||||
"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
|
||||
),
|
||||
"lock_reason": _lock_reason(),
|
||||
"expires_at": license_state.get("expires_at"),
|
||||
"days_until_expiry": license_state.get("days_until_expiry"),
|
||||
"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.
|
||||
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
|
||||
|
||||
Lock behaviour:
|
||||
- cloud sets locked=true → set lock_pending=true in state
|
||||
- lock_pending is enforced at workday-close time (see business_day router)
|
||||
- while a workday is open, the site keeps running; lock applies once it closes
|
||||
Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a
|
||||
license, not to RUN one. Each heartbeat returns a signed license token; the
|
||||
site stores it and enforces it offline — until expiry (+5 days grace), however
|
||||
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:
|
||||
- 5 days before expiry → warning only (days_until_expiry in state)
|
||||
- on expiry → 5-day grace period begins (grace_expires_at in state)
|
||||
- after grace + no open workday → licensed=False enforced by business_day router
|
||||
Lock behaviour (unchanged):
|
||||
- cloud sets locked=true → lock_pending while a workday is open, locked once it closes
|
||||
- expiry past grace / site deactivated → likewise never mid-service
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import settings
|
||||
from middleware.license_check import license_state
|
||||
from services.license import evaluate, parse_dt, verify_token
|
||||
|
||||
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
||||
|
||||
@@ -31,20 +30,30 @@ logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
STATE_FILE = Path(__file__).parent.parent / "license_state.json"
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""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():
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
license_state.update(data)
|
||||
logger.info("Loaded persisted license state: %s", data)
|
||||
except Exception as e:
|
||||
logger.warning("Could not load license state file: %s", e)
|
||||
for path in (STATE_FILE, LEGACY_STATE_FILE):
|
||||
if path.exists():
|
||||
try:
|
||||
license_state.update(json.loads(path.read_text()))
|
||||
logger.info("Loaded persisted license state from %s", path)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Could not load license state file %s: %s", path, e)
|
||||
|
||||
|
||||
def _persist_state():
|
||||
@@ -54,44 +63,69 @@ def _persist_state():
|
||||
logger.warning("Could not persist license state: %s", e)
|
||||
|
||||
|
||||
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
|
||||
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
|
||||
if not expires_at_str:
|
||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
||||
def _latest_activity(db) -> datetime | None:
|
||||
"""Newest order timestamp in the database — the clock can't be set before it."""
|
||||
from sqlalchemy import func
|
||||
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:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
||||
workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None
|
||||
latest_activity = _latest_activity(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
days_until = (expires_at - now).days # negative once expired
|
||||
floors = [parse_dt(license_state.get("time_high_water")), latest_activity,
|
||||
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 days_until < 0:
|
||||
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
|
||||
if payload: # lock requested by the cloud — deferred while a workday is open
|
||||
if payload.get("locked"):
|
||||
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 {
|
||||
"days_until_expiry": days_until,
|
||||
"grace_expires_at": grace_expires_at,
|
||||
}
|
||||
last_sync = parse_dt(license_state.get("last_sync"))
|
||||
license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None})
|
||||
_persist_state()
|
||||
if result["license_problem"]:
|
||||
logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"])
|
||||
|
||||
|
||||
def _get_local_ip() -> str | None:
|
||||
"""Best-effort detection of the host machine's LAN IP address.
|
||||
When running inside Docker the socket trick returns the container/bridge IP,
|
||||
so we honour HOST_IP if it is explicitly provided via the environment."""
|
||||
import os
|
||||
if host_ip := os.environ.get("HOST_IP", "").strip():
|
||||
return host_ip
|
||||
"""The server's LAN IP as phones see it — same resolver as the manager's
|
||||
pairing QR (override → live detection → HOST_IP), see services/lan_ip.py.
|
||||
No socket tricks: inside Docker they return the unreachable bridge IP."""
|
||||
from database import SessionLocal
|
||||
from services.lan_ip import resolve_lan_ip
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
return resolve_lan_ip(db)["effective"]
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _sync_once():
|
||||
@@ -113,69 +147,39 @@ async def _sync_once():
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
licensed = data.get("licensed", True)
|
||||
cloud_locked = data.get("locked", False)
|
||||
expires_at = data.get("expires_at")
|
||||
expiry_fields = _compute_expiry_fields(expires_at)
|
||||
|
||||
# If cloud says locked, check whether a workday is currently open.
|
||||
# No open workday → lock immediately.
|
||||
# Open workday → defer to workday close (business_day router enforces it).
|
||||
if cloud_locked:
|
||||
from database import SessionLocal
|
||||
from models.business_day import BusinessDay
|
||||
db = SessionLocal()
|
||||
try:
|
||||
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
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")
|
||||
token = data.get("license_token")
|
||||
if token:
|
||||
if verify_token(token, settings.SITE_ID):
|
||||
license_state["license_token"] = token
|
||||
else:
|
||||
logger.error("Cloud sent a license token that does not verify - keeping the previous one")
|
||||
else:
|
||||
# Cloud without license signing (transition): trust this answer for 72h,
|
||||
# keep the old immediate lock handling
|
||||
if data.get("licensed", True):
|
||||
license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat()
|
||||
license_state["expires_at"] = data.get("expires_at")
|
||||
if data.get("locked"):
|
||||
license_state["lock_pending"] = True
|
||||
else:
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
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["locked"] = False
|
||||
|
||||
license_state.update({
|
||||
"licensed": licensed,
|
||||
"expires_at": expires_at,
|
||||
"latest_version": data.get("latest_version"),
|
||||
"waiter_domain": data.get("waiter_domain"),
|
||||
"site_numeric_id": data.get("site_numeric_id"),
|
||||
"last_sync": datetime.now(timezone.utc).isoformat(),
|
||||
"sync_failed": False,
|
||||
**expiry_fields,
|
||||
})
|
||||
_persist_state()
|
||||
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
|
||||
logger.info("Cloud sync OK (signed license: %s)", bool(token))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Cloud sync failed: %s", e)
|
||||
license_state["sync_failed"] = True
|
||||
|
||||
last_sync_str = license_state.get("last_sync")
|
||||
if last_sync_str:
|
||||
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)
|
||||
|
||||
# Online or not: the stored signed license decides (no more 72h offline rule)
|
||||
apply_license()
|
||||
|
||||
IMAGE_DIR = Path("/app/data/product_images")
|
||||
|
||||
@@ -398,6 +402,7 @@ async def _pull_pending_orders():
|
||||
|
||||
async def _sync_loop():
|
||||
_load_persisted_state()
|
||||
apply_license() # decide from the stored license before the first network attempt
|
||||
while True:
|
||||
await _sync_once()
|
||||
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_days_remaining number | null
|
||||
* 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:
|
||||
* 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
|
||||
* 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() {
|
||||
const { data } = useQuery({
|
||||
@@ -41,9 +48,13 @@ export default function useLicenseStatus() {
|
||||
grace_expires_at: null,
|
||||
grace_days_remaining: null,
|
||||
sync_failed: false,
|
||||
offline_days: null,
|
||||
license_problem: null,
|
||||
grace_over: false,
|
||||
showExpiryWarning: false,
|
||||
inGracePeriod: false,
|
||||
isBlocked: false,
|
||||
longOffline: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +68,21 @@ export default function useLicenseStatus() {
|
||||
grace_expires_at = null,
|
||||
grace_days_remaining = null,
|
||||
sync_failed = false,
|
||||
offline_days = null,
|
||||
license_problem = null,
|
||||
grace_over = false,
|
||||
} = data
|
||||
|
||||
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 =
|
||||
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 longOffline = offline_days != null && offline_days >= 7
|
||||
|
||||
return {
|
||||
licensed,
|
||||
locked,
|
||||
@@ -77,8 +93,12 @@ export default function useLicenseStatus() {
|
||||
grace_expires_at,
|
||||
grace_days_remaining,
|
||||
sync_failed,
|
||||
offline_days,
|
||||
license_problem,
|
||||
grace_over,
|
||||
showExpiryWarning,
|
||||
inGracePeriod,
|
||||
isBlocked,
|
||||
longOffline,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,10 @@ function PageTitle() {
|
||||
|
||||
function LicenseBanner({ license }) {
|
||||
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) {
|
||||
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') {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
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" />
|
||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
|
||||
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.{connectHint}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -119,7 +140,7 @@ function LicenseBanner({ license }) {
|
||||
return (
|
||||
<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" />
|
||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
|
||||
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.{connectHint}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -129,7 +150,17 @@ function LicenseBanner({ license }) {
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ function DataTransferSection() {
|
||||
}
|
||||
|
||||
|
||||
function QRModal({ url, onClose }) {
|
||||
function QRModal({ url, caption = 'Σαρώστε με το κινητό για σύνδεση στο σύστημα.', onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
@@ -314,15 +314,156 @@ function QRModal({ url, onClose }) {
|
||||
<div className="p-3 bg-white border border-gray-200 rounded-xl">
|
||||
<QRCodeSVG value={url} size={220} includeMargin={false} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 text-center">{caption}</p>
|
||||
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
|
||||
</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) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
@@ -334,7 +475,7 @@ export default function AppInfoTab() {
|
||||
const user = useAuthStore(s => s.user)
|
||||
const qc = useQueryClient()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [qrOpen, setQrOpen] = useState(false)
|
||||
const [qr, setQr] = useState(null) // { url, caption } | null
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['system-status'],
|
||||
queryFn: () => client.get('/api/system/status').then(r => r.data),
|
||||
@@ -416,11 +557,12 @@ export default function AppInfoTab() {
|
||||
)}
|
||||
{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">
|
||||
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
|
||||
<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"
|
||||
>
|
||||
QR Code
|
||||
@@ -431,6 +573,8 @@ export default function AppInfoTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LanAccessSection status={status} onShowQr={setQr} />
|
||||
|
||||
<TimezoneSection />
|
||||
|
||||
<StatsSection />
|
||||
@@ -450,9 +594,7 @@ export default function AppInfoTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrOpen && status?.waiter_domain && (
|
||||
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
|
||||
)}
|
||||
{qr && <QRModal url={qr.url} caption={qr.caption} onClose={() => setQr(null)} />}
|
||||
</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 {
|
||||
listen 80;
|
||||
server_name waiter.* manager.*;
|
||||
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 {
|
||||
listen 443 ssl;
|
||||
server_name waiter.* _;
|
||||
server_name waiter.*;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
@@ -16,18 +107,20 @@ server {
|
||||
proxy_pass http://waiter_pwa:80;
|
||||
proxy_http_version 1.1;
|
||||
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 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 3600;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 4443 ssl;
|
||||
server_name manager.* _;
|
||||
listen 443 ssl;
|
||||
server_name manager.*;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
@@ -36,9 +129,115 @@ server {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
dist
|
||||
.env
|
||||
# Local-only build outputs — never part of the image build context
|
||||
android
|
||||
releases
|
||||
dist-native
|
||||
|
||||
@@ -22,3 +22,10 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.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
|
||||
|
||||
@@ -6,7 +9,9 @@ COPY package.json package-lock.json* ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||