Compare commits
No commits in common. "626344cc1625193734f92e9b527e7570fe96b34f" and "43db97aeaa0c219255cf5dab17912e6ff87e3099" have entirely different histories.
626344cc16
...
43db97aeaa
@ -5,10 +5,8 @@ CM_DEBUG=false
|
||||
# === Deployment Identity ===
|
||||
# Unique name prefix for containers and network (avoid conflicts on same host)
|
||||
CM_DEPLOY_NAME=rex-cm
|
||||
# Host port for the Next.js dashboard (each deployment needs a unique port).
|
||||
# Was 8000/8001/8005 in the Flask era; bumped to 8010/8011/8012 to keep
|
||||
# legacy aaPanel routes intact during the B4 cutover window.
|
||||
CM_WEB_HOST_PORT=8011
|
||||
# Host port for web view (each deployment needs a unique port)
|
||||
CM_WEB_HOST_PORT=8001
|
||||
|
||||
# === Docker Registry ===
|
||||
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,8 +3,3 @@ __pycache__
|
||||
*.html
|
||||
logs
|
||||
envs/*/.env
|
||||
.env
|
||||
.venv/
|
||||
.playwright-mcp/
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@ -3,11 +3,12 @@
|
||||
## Project Structure & Module Organization
|
||||
- `app/` contains service modules:
|
||||
- `cm_api.py` (Flask API, serves on `3000`)
|
||||
- `cm_web_view.py` (Flask UI, container `8000`, host `8001`)
|
||||
- `cm_telegram.py` (Telegram bot + account monitor thread)
|
||||
- `cm_transfer_credit.py` (scheduled transfer worker)
|
||||
- `db.py` (MySQL connection/retry logic)
|
||||
- `web/` is the Next.js 15 dashboard (`cm-web` service, container port `3000`, host `CM_WEB_HOST_PORT`). Tailwind v4, App Router, TypeScript. Replaced the legacy Flask `app/cm_web_view.py` in the B4 cutover.
|
||||
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`).
|
||||
- `web/` is the Next.js 15 app for the new web view (`cm-web-next` service). Tailwind v4, App Router, TypeScript. Side-by-side with the legacy Flask `cm_web_view.py` until B4 cuts over.
|
||||
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`).
|
||||
- `docker-compose.yml` uses registry images; `docker-compose.override.yml` swaps to local builds.
|
||||
- `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx.
|
||||
|
||||
@ -38,24 +39,13 @@
|
||||
bash scripts/dev.sh up
|
||||
```
|
||||
This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and
|
||||
`web` (Next.js dashboard). The schema and a 4-row seed are applied
|
||||
automatically from `docker/mysql/init.d/`. Bots (`telegram-bot`,
|
||||
`transfer-bot`) are gated behind a compose `bots` profile and do not
|
||||
start in dev.
|
||||
|
||||
## Auth
|
||||
- The Next.js dashboard (`cm-web`) gates every route except `/cm-auth` behind a session cookie.
|
||||
- **Password sign-in** uses `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the deployment's `.env` (constant-time compare). No separate user table.
|
||||
- **WebAuthn passkey** sign-in is the preferred path on devices with platform authenticators (Face ID, Touch ID, Android fingerprint). Enroll one at `/cm-passkeys` after the first password login.
|
||||
- Session: signed `httpOnly` cookie (`cm_auth`), 30-day rolling. Requires `CM_AUTH_SECRET` env var (≥32 chars). Generate with `bash scripts/gen_auth_secret.sh --write`.
|
||||
- Passkey storage: `/data/auth/passkeys.json` inside the container, mounted from the `${CM_DEPLOY_NAME}-web-auth-data` named volume. Atomic writes; persists across container restarts and image rebuilds.
|
||||
- "Forgot password" recovery: contact whoever holds the deployment's `.env`. There's no email reset flow.
|
||||
- Rotating `CM_AUTH_SECRET` invalidates all sessions (forces everyone to re-login).
|
||||
- The `Secure` cookie attribute is gated on `CM_DEBUG`: `CM_DEBUG=true` drops `Secure` so phone-on-LAN testing over plain HTTP works in dev. Production must keep `CM_DEBUG=false` so the cookie only flies over HTTPS.
|
||||
`web-view`. The schema and a 4-row seed are applied automatically
|
||||
from `docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`)
|
||||
are gated behind a compose `bots` profile and do not start in dev.
|
||||
|
||||
## Dev Tier (Local Development)
|
||||
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
|
||||
- URL: `http://localhost:8010/` (Next.js dashboard).
|
||||
- URLs: `http://localhost:8000/` (legacy Flask UI), `http://localhost:8010/` (new Next.js scaffold). Both run side-by-side until the B4 cutover retires the Flask version.
|
||||
- Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or
|
||||
`bash scripts/bot_cli.sh <subcommand>` (e.g., `register`, `set-pin <link>`,
|
||||
`monitor-once --target 5`). The CLI runs in your local `.venv` and
|
||||
@ -72,11 +62,11 @@
|
||||
- `bash scripts/publish.sh <tag>`: build + push all service images (`gitea.04080616.xyz/yiekheng`).
|
||||
|
||||
## Verification Checklist
|
||||
- API responds (only reachable inside the docker network — exec into a service): `docker compose exec web wget -qO- http://api-server:3000/acc/`
|
||||
- Web UI loads: open `http://localhost:8010` (dev) or `http://localhost:8011` (rex prod) / `http://localhost:8012` (siong prod). Unauthenticated requests bounce to `/cm-auth`.
|
||||
- API responds: `curl http://localhost:3000/acc/`
|
||||
- Web UI loads: open `http://localhost:8000` (dev) or `http://localhost:8001` (rex prod) / `http://localhost:8005` (siong prod).
|
||||
- Service logs are clean:
|
||||
```bash
|
||||
docker compose logs -f api-server web telegram-bot transfer-bot
|
||||
docker compose logs -f api-server web-view telegram-bot transfer-bot
|
||||
```
|
||||
- Telegram bot validates with `/menu` and `/9` in chat after startup.
|
||||
|
||||
@ -98,9 +88,9 @@
|
||||
- problem statement and solution summary,
|
||||
- services/files affected,
|
||||
- required env/config changes,
|
||||
- API/log evidence (and UI screenshot if `web/` changed).
|
||||
- API/log evidence (and UI screenshot if `cm_web_view.py` changed).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit real secrets in `.env`.
|
||||
- `CM_DEBUG` defaults to `false` for `api-server`. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable). The Next.js `web` service also reads `CM_DEBUG` to decide whether the session cookie carries the `Secure` flag — keep it `false` in production so the cookie is HTTPS-only.
|
||||
- `CM_DEBUG` defaults to `false` for both Flask services. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable).
|
||||
- Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.
|
||||
|
||||
23
README.md
23
README.md
@ -3,10 +3,9 @@
|
||||
Brief, copy/paste-ready steps to run the published images from `gitea.04080616.xyz` using Portainer.
|
||||
|
||||
## What gets deployed
|
||||
- `cm-api` (port 3000, internal-only), `cm-web` (Next.js dashboard, container port 3000 → host `CM_WEB_HOST_PORT`), `cm-telegram`, `cm-transfer`
|
||||
- `cm-api` (port 3000), `cm-web` (port 8000 → host `CM_WEB_HOST_PORT`), `cm-telegram`, `cm-transfer`
|
||||
- Container names prefixed with `CM_DEPLOY_NAME` (e.g. `rex-cm-telegram-bot`)
|
||||
- Docker network: `${CM_DEPLOY_NAME}-network` (bridge)
|
||||
- Named volume: `${CM_DEPLOY_NAME}-web-auth-data` for `/data/auth` (passkey JSON store)
|
||||
|
||||
## Environment configs
|
||||
|
||||
@ -14,9 +13,9 @@ Per-deployment templates live in `envs/<name>/.env.example` (committed). Each op
|
||||
|
||||
```
|
||||
envs/
|
||||
├── dev/.env.example # Local development tier (port 8010)
|
||||
├── rex/.env.example # Rex deployment (port 8011)
|
||||
└── siong/.env.example # Siong deployment (port 8012)
|
||||
├── dev/.env.example # Local development tier — see "Local Development" below
|
||||
├── rex/.env.example # Rex deployment (port 8001)
|
||||
└── siong/.env.example # Siong deployment (port 8005)
|
||||
```
|
||||
|
||||
For Portainer-hosted deployments (rex/siong):
|
||||
@ -36,12 +35,11 @@ bash scripts/dev.sh up
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `CM_DEPLOY_NAME` | Unique prefix for containers/network (e.g. `rex-cm`, `siong-cm`) |
|
||||
| `CM_WEB_HOST_PORT` | Host port for the Next.js dashboard (unique per deployment; e.g. 8010/8011/8012) |
|
||||
| `CM_AUTH_SECRET` | 64-hex session signing secret (`bash scripts/gen_auth_secret.sh --write`) |
|
||||
| `CM_WEB_HOST_PORT` | Host port for web view (must be unique per deployment) |
|
||||
| `TELEGRAM_BOT_TOKEN` | Your Telegram bot token |
|
||||
| `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Database connection |
|
||||
| `CM_PREFIX_PATTERN` | Username prefix pattern |
|
||||
| `CM_AGENT_ID` / `CM_AGENT_PASSWORD` / `CM_SECURITY_PIN` | Agent credentials (also used as the dashboard sign-in identity) |
|
||||
| `CM_AGENT_ID` / `CM_AGENT_PASSWORD` / `CM_SECURITY_PIN` | Agent credentials |
|
||||
| `CM_BOT_BASE_URL` | Bot API base URL |
|
||||
|
||||
## One-time: add the registry in Portainer
|
||||
@ -53,16 +51,9 @@ bash scripts/dev.sh up
|
||||
## Deploy the stack (fast path)
|
||||
1) Portainer → **Stacks** → **Add stack** → **Web editor**.
|
||||
2) Paste the contents of `docker-compose.yml` from this repo (not the override).
|
||||
3) Load all variables from the appropriate `envs/<name>/.env` into the stack environment variables. Make sure `CM_AUTH_SECRET` is present (generate with `bash scripts/gen_auth_secret.sh`).
|
||||
3) Load all variables from the appropriate `envs/<name>/.env` into the stack environment variables.
|
||||
4) Click **Deploy the stack**. Portainer will pull `cm-<service>:<tag>` from `gitea.04080616.xyz/yiekheng` and start all four containers.
|
||||
|
||||
### Migrating an existing pre-B4 stack
|
||||
|
||||
The Flask web (port 8000-range) was retired and replaced by the Next.js dashboard. To upgrade:
|
||||
1. In your stack `.env`, drop `CM_WEB_NEXT_HOST_PORT`. Set `CM_WEB_HOST_PORT` to what `CM_WEB_NEXT_HOST_PORT` was (e.g. 8011/8012). Add `CM_AUTH_SECRET=$(openssl rand -hex 32)`.
|
||||
2. Update aaPanel `proxy_pass` if it pointed to the old Flask port (8001/8005) — switch it to the new one (8011/8012).
|
||||
3. Redeploy the stack. The old `${CM_DEPLOY_NAME}-web-view` and `${CM_DEPLOY_NAME}-web-next` containers go away; a single `${CM_DEPLOY_NAME}-web` takes over.
|
||||
|
||||
## Updating to a new image tag
|
||||
1) Edit the stack → change `DOCKER_IMAGE_TAG` → **Update the stack**.
|
||||
2) Portainer re-pulls and recreates the services with the new tag.
|
||||
|
||||
@ -20,7 +20,7 @@ class CM_API:
|
||||
self.app = Flask(__name__)
|
||||
# No CORS middleware: api-server is internal-only (no host port
|
||||
# in prod compose, per C5). Browsers can't reach it directly,
|
||||
# and server-side fetches from the web service don't trigger
|
||||
# and server-side fetches from web-view / web-next don't trigger
|
||||
# CORS. Removing flask_cors removes a permissive '*' origin
|
||||
# default that becomes an attack surface if a host port is ever
|
||||
# accidentally re-exposed.
|
||||
|
||||
758
app/cm_web_view.py
Normal file
758
app/cm_web_view.py
Normal file
@ -0,0 +1,758 @@
|
||||
from flask import Flask, render_template_string, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import requests
|
||||
import json
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# API base URL - use environment variable for Docker Compose
|
||||
import os
|
||||
API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:3000')
|
||||
PREFIX_PATTERN = os.getenv('CM_PREFIX_PATTERN', '')
|
||||
print("API: ", API_BASE_URL)
|
||||
print("Prefix pattern: ", PREFIX_PATTERN)
|
||||
|
||||
|
||||
def _debug_enabled() -> bool:
|
||||
"""Return True iff CM_DEBUG env var is set to a truthy value.
|
||||
|
||||
Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed).
|
||||
Anything else, including unset, is False. Default-off so the
|
||||
Werkzeug debugger is never reachable in production containers.
|
||||
"""
|
||||
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
# Beautiful HTML template with modern styling
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CM Bot Database Viewer</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
margin: 0 10px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: linear-gradient(135deg, #f8f9ff 0%, #f0f2ff 100%);
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.editable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.editable:hover {
|
||||
background: #e3f2fd !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editable.editing {
|
||||
background: #fff3e0 !important;
|
||||
border: 2px solid #ff9800;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px 8px;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-buttons {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
margin-left: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.editable:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 5px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sortable:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading i {
|
||||
font-size: 2rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
margin: 5px 0;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-database"></i> CM Bot Database Viewer</h1>
|
||||
<p>Real-time view of accounts and users data</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('acc')">
|
||||
<i class="fas fa-user-circle"></i> Accounts
|
||||
</button>
|
||||
<button class="tab" onclick="showTab('user')">
|
||||
<i class="fas fa-users"></i> Users
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button class="refresh-btn" onclick="refreshData()">
|
||||
<i class="fas fa-sync-alt"></i> Refresh Data
|
||||
</button>
|
||||
|
||||
<div id="stats" class="stats" style="display: none;">
|
||||
<div class="stat-card">
|
||||
<h3 id="acc-count">0</h3>
|
||||
<p>Total Accounts</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="user-count">0</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="acc-content" class="tab-content">
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner"></i>
|
||||
<p>Loading accounts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="user-content" class="tab-content" style="display: none;">
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner"></i>
|
||||
<p>Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PREFIX_PATTERN = {{ prefix_pattern|tojson }};
|
||||
let currentTab = 'acc';
|
||||
let accData = [];
|
||||
let userData = [];
|
||||
let editingCell = null;
|
||||
let originalValue = null;
|
||||
|
||||
function showTab(tab) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Show/hide content
|
||||
document.getElementById('acc-content').style.display = tab === 'acc' ? 'block' : 'none';
|
||||
document.getElementById('user-content').style.display = tab === 'user' ? 'block' : 'none';
|
||||
|
||||
currentTab = tab;
|
||||
|
||||
// Load data if not already loaded
|
||||
if (tab === 'acc' && accData.length === 0) {
|
||||
loadAccData();
|
||||
} else if (tab === 'user' && userData.length === 0) {
|
||||
loadUserData();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccData() {
|
||||
try {
|
||||
const response = await fetch('/api/acc/');
|
||||
if (!response.ok) throw new Error('Failed to fetch accounts');
|
||||
|
||||
accData = await response.json();
|
||||
displayAccData();
|
||||
updateStats();
|
||||
} catch (error) {
|
||||
document.getElementById('acc-content').innerHTML =
|
||||
`<div class="error">Error loading accounts: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserData() {
|
||||
try {
|
||||
const response = await fetch('/api/user/');
|
||||
if (!response.ok) throw new Error('Failed to fetch users');
|
||||
|
||||
userData = await response.json();
|
||||
displayUserData();
|
||||
updateStats();
|
||||
} catch (error) {
|
||||
document.getElementById('user-content').innerHTML =
|
||||
`<div class="error">Error loading users: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function displayAccData() {
|
||||
const container = document.getElementById('acc-content');
|
||||
|
||||
if (accData.length === 0) {
|
||||
container.innerHTML = '<div class="error">No accounts found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort data with configured prefix priority
|
||||
const sortedData = sortData([...accData], 'acc');
|
||||
|
||||
const table = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable"><i class="fas fa-user"></i> Username</th>
|
||||
<th><i class="fas fa-lock"></i> Password <i class="fas fa-edit edit-icon"></i></th>
|
||||
<th><i class="fas fa-info-circle"></i> Status <i class="fas fa-edit edit-icon"></i></th>
|
||||
<th><i class="fas fa-link"></i> Link <i class="fas fa-edit edit-icon"></i></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((acc, index) => `
|
||||
<tr>
|
||||
<td><strong>${acc.username}</strong></td>
|
||||
<td class="editable" onclick="startEdit(this, 'password', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
|
||||
${acc.password || ''}
|
||||
</td>
|
||||
<td class="editable" onclick="startEdit(this, 'status', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
|
||||
<span class="status-badge ${acc.status === 'active' ? 'status-active' : 'status-inactive'}">
|
||||
${acc.status || ''}
|
||||
</span>
|
||||
</td>
|
||||
<td class="editable" onclick="startEdit(this, 'link', 'acc', ${accData.findIndex(a => a.username === acc.username)})">
|
||||
${acc.link || ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = table;
|
||||
}
|
||||
|
||||
function displayUserData() {
|
||||
const container = document.getElementById('user-content');
|
||||
|
||||
if (userData.length === 0) {
|
||||
container.innerHTML = '<div class="error">No users found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort data with configured prefix priority and by update time
|
||||
const sortedData = sortData([...userData], 'user');
|
||||
|
||||
const table = `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable"><i class="fas fa-user"></i> From Username</th>
|
||||
<th><i class="fas fa-lock"></i> From Password <i class="fas fa-edit edit-icon"></i></th>
|
||||
<th><i class="fas fa-user"></i> To Username <i class="fas fa-edit edit-icon"></i></th>
|
||||
<th><i class="fas fa-lock"></i> To Password <i class="fas fa-edit edit-icon"></i></th>
|
||||
<th class="sortable"><i class="fas fa-clock"></i> Last Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sortedData.map((user, index) => `
|
||||
<tr>
|
||||
<td><strong>${user.f_username}</strong></td>
|
||||
<td class="editable" onclick="startEdit(this, 'f_password', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
|
||||
${user.f_password || ''}
|
||||
</td>
|
||||
<td class="editable" onclick="startEdit(this, 't_username', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
|
||||
<strong>${user.t_username || ''}</strong>
|
||||
</td>
|
||||
<td class="editable" onclick="startEdit(this, 't_password', 'user', ${userData.findIndex(u => u.f_username === user.f_username)})">
|
||||
${user.t_password || ''}
|
||||
</td>
|
||||
<td>${user.last_update_time || 'No record'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = table;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('acc-count').textContent = accData.length;
|
||||
document.getElementById('user-count').textContent = userData.length;
|
||||
document.getElementById('stats').style.display = 'grid';
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
if (currentTab === 'acc') {
|
||||
loadAccData();
|
||||
} else {
|
||||
loadUserData();
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting functions
|
||||
function sortData(data, type) {
|
||||
if (type === 'acc') {
|
||||
return data.sort((a, b) => {
|
||||
// Configured prefix always on top
|
||||
const aIsPreferred = PREFIX_PATTERN && a.username && a.username.startsWith(PREFIX_PATTERN);
|
||||
const bIsPreferred = PREFIX_PATTERN && b.username && b.username.startsWith(PREFIX_PATTERN);
|
||||
|
||||
if (aIsPreferred && !bIsPreferred) return -1;
|
||||
if (!aIsPreferred && bIsPreferred) return 1;
|
||||
|
||||
// If both are preferred or both are not, sort by username in descending order
|
||||
if (aIsPreferred && bIsPreferred) {
|
||||
return (b.username || '').localeCompare(a.username || '');
|
||||
} else {
|
||||
return (b.username || '').localeCompare(a.username || '');
|
||||
}
|
||||
});
|
||||
} else if (type === 'user') {
|
||||
return data.sort((a, b) => {
|
||||
// Configured prefix always on top
|
||||
const aIsPreferred = PREFIX_PATTERN && a.f_username && a.f_username.startsWith(PREFIX_PATTERN);
|
||||
const bIsPreferred = PREFIX_PATTERN && b.f_username && b.f_username.startsWith(PREFIX_PATTERN);
|
||||
|
||||
if (aIsPreferred && !bIsPreferred) return -1;
|
||||
if (!aIsPreferred && bIsPreferred) return 1;
|
||||
|
||||
// Then sort by last_update_time (newest first)
|
||||
const aTime = new Date(a.last_update_time || 0);
|
||||
const bTime = new Date(b.last_update_time || 0);
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Editing functions
|
||||
function startEdit(cell, field, type, index) {
|
||||
if (editingCell) {
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
editingCell = cell;
|
||||
originalValue = cell.textContent.trim();
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'edit-input';
|
||||
input.value = originalValue;
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'edit-buttons';
|
||||
buttons.innerHTML = `
|
||||
<button class="edit-btn save-btn" onclick="saveEdit('${field}', '${type}', ${index})">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="edit-btn cancel-btn" onclick="cancelEdit()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
cell.innerHTML = '';
|
||||
cell.appendChild(input);
|
||||
cell.appendChild(buttons);
|
||||
cell.classList.add('editing');
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (editingCell) {
|
||||
editingCell.textContent = originalValue;
|
||||
editingCell.classList.remove('editing');
|
||||
editingCell = null;
|
||||
originalValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(field, type, index) {
|
||||
if (!editingCell) return;
|
||||
|
||||
const input = editingCell.querySelector('.edit-input');
|
||||
const newValue = input.value.trim();
|
||||
|
||||
try {
|
||||
if (type === 'acc') {
|
||||
const data = {
|
||||
username: accData[index].username,
|
||||
password: field === 'password' ? newValue : accData[index].password,
|
||||
status: field === 'status' ? newValue : accData[index].status,
|
||||
link: field === 'link' ? newValue : accData[index].link
|
||||
};
|
||||
|
||||
const response = await fetch('/api/update-acc-data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
accData[index][field] = newValue;
|
||||
editingCell.textContent = newValue;
|
||||
editingCell.classList.remove('editing');
|
||||
editingCell = null;
|
||||
originalValue = null;
|
||||
} else {
|
||||
throw new Error('Failed to update account data');
|
||||
}
|
||||
} else if (type === 'user') {
|
||||
const data = {
|
||||
f_username: userData[index].f_username,
|
||||
f_password: field === 'f_password' ? newValue : userData[index].f_password,
|
||||
t_username: field === 't_username' ? newValue : userData[index].t_username,
|
||||
t_password: field === 't_password' ? newValue : userData[index].t_password
|
||||
};
|
||||
|
||||
const response = await fetch('/api/update-user-data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
userData[index][field] = newValue;
|
||||
userData[index].last_update_time = new Date().toUTCString();
|
||||
editingCell.textContent = newValue;
|
||||
editingCell.classList.remove('editing');
|
||||
editingCell = null;
|
||||
originalValue = null;
|
||||
} else {
|
||||
throw new Error('Failed to update user data');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error updating data: ' + error.message);
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadAccData();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
if (currentTab === 'acc') {
|
||||
loadAccData();
|
||||
} else {
|
||||
loadUserData();
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template_string(HTML_TEMPLATE, prefix_pattern=PREFIX_PATTERN)
|
||||
|
||||
@app.route('/api/acc/')
|
||||
def proxy_acc():
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/acc/")
|
||||
return jsonify(response.json())
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/user/')
|
||||
def proxy_user():
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/user/")
|
||||
return jsonify(response.json())
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/update-acc-data', methods=['POST'])
|
||||
def proxy_update_acc():
|
||||
try:
|
||||
data = request.get_json()
|
||||
response = requests.post(f"{API_BASE_URL}/update-acc-data", json=data)
|
||||
return jsonify(response.json()), response.status_code
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/update-user-data', methods=['POST'])
|
||||
def proxy_update_user():
|
||||
try:
|
||||
data = request.get_json()
|
||||
response = requests.post(f"{API_BASE_URL}/update-user-data", json=data)
|
||||
return jsonify(response.json()), response.status_code
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Starting CM Web View...")
|
||||
print("Web interface will be available at: http://localhost:8000")
|
||||
print("Make sure the API server is running on port 3000")
|
||||
app.run(host='0.0.0.0', port=8000, debug=_debug_enabled())
|
||||
@ -18,11 +18,18 @@ services:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
web-view:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/web/Dockerfile
|
||||
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
|
||||
command: ["python", "-m", "app.cm_web_view"]
|
||||
|
||||
web-next:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/web-next/Dockerfile
|
||||
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
|
||||
|
||||
transfer-bot:
|
||||
build:
|
||||
@ -63,5 +70,3 @@ services:
|
||||
volumes:
|
||||
mysql-data:
|
||||
name: ${CM_DEPLOY_NAME:-cm}-mysql-data
|
||||
web-auth-data:
|
||||
name: ${CM_DEPLOY_NAME:-cm}-web-auth-data
|
||||
|
||||
@ -52,23 +52,38 @@ services:
|
||||
networks:
|
||||
- bot-network
|
||||
|
||||
# Next.js Web Dashboard (replaces the legacy Flask cm-web after B4 cutover).
|
||||
web:
|
||||
# Web View Service
|
||||
web-view:
|
||||
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web:${DOCKER_IMAGE_TAG:-latest}"
|
||||
container_name: ${CM_DEPLOY_NAME:-cm}-web
|
||||
container_name: ${CM_DEPLOY_NAME:-cm}-web-view
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CM_WEB_HOST_PORT:-8010}:3000"
|
||||
- "${CM_WEB_HOST_PORT:-8001}:8000"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
CM_DEBUG: ${CM_DEBUG:-false}
|
||||
API_BASE_URL: http://api-server:3000
|
||||
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
|
||||
volumes:
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- bot-network
|
||||
depends_on:
|
||||
- api-server
|
||||
|
||||
# Next.js Web View (side-by-side with web-view during B-cycle migration).
|
||||
web-next:
|
||||
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
|
||||
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
API_BASE_URL: http://api-server:3000
|
||||
CM_AUTH_SECRET: ${CM_AUTH_SECRET}
|
||||
CM_DEBUG: ${CM_DEBUG:-false}
|
||||
CM_AGENT_ID: ${CM_AGENT_ID}
|
||||
CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD}
|
||||
volumes:
|
||||
- web-auth-data:/data/auth
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
@ -106,13 +121,10 @@ services:
|
||||
- bot-network
|
||||
depends_on:
|
||||
- api-server
|
||||
- web-view
|
||||
|
||||
|
||||
networks:
|
||||
bot-network:
|
||||
name: ${CM_DEPLOY_NAME:-cm}-network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
web-auth-data:
|
||||
name: ${CM_DEPLOY_NAME:-cm}-web-auth-data
|
||||
|
||||
31
docker/web-next/Dockerfile
Normal file
31
docker/web-next/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- deps ---
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
# `npm install` (not `npm ci`) because the host doesn't generate the
|
||||
# lockfile during dev. Docker resolves it on first build; subsequent
|
||||
# builds reuse the cached layer until package.json changes.
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
# --- build ---
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- runtime ---
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@ -1,31 +1,20 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.9-slim
|
||||
|
||||
# --- deps ---
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
# `npm install` (not `npm ci`) because the host doesn't generate the
|
||||
# lockfile during dev. Docker resolves it on first build; subsequent
|
||||
# builds reuse the cached layer until package.json changes.
|
||||
RUN npm install --no-audit --no-fund
|
||||
ENV PIP_DEFAULT_TIMEOUT=120
|
||||
|
||||
# --- build ---
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- runtime ---
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --retries 5 -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY app ./app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the web view with gunicorn (Flask dev server is for the dev override).
|
||||
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"]
|
||||
|
||||
@ -6,13 +6,11 @@ Companion spec: [superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md]
|
||||
|
||||
## Threat model
|
||||
|
||||
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable Next.js dashboard ports on each host (8011 rex, 8012 siong, 8010 dev). A scanner on the public internet → aaPanel → app. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy. With them, aaPanel returns 444 immediately and the app never sees the request.
|
||||
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable web-view ports on the Flask hosts (8001 rex, 8005 siong, 8000 dev). A scanner on the public internet → aaPanel → Flask. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy to Flask. With them, aaPanel returns 444 immediately and Flask never sees the request.
|
||||
|
||||
> **Post-B4 update.** The dashboard now has built-in `/cm-auth` (password + WebAuthn passkey) that gates every route via Next.js middleware. C3 (basic auth at the proxy) is no longer the *primary* defense — it's optional belt-and-braces. Keep it only if you want a second factor at the edge before the Next.js middleware sees a request. The C4 (scanner deflection + rate limit) and C7 (host firewall) sections still apply unchanged in spirit; only the port numbers moved.
|
||||
## C3 — Basic auth on the rex/siong/dev vhosts
|
||||
|
||||
## C3 — (Optional) Basic auth on the rex/siong/dev vhosts
|
||||
|
||||
Goal: an extra password challenge at the edge before requests reach `/cm-auth`. Skip this if `/cm-auth` is enough for your threat model.
|
||||
Goal: the web-view UI requires a password. Anyone hitting `https://<domain>/` with no creds gets 401.
|
||||
|
||||
Generate an htpasswd file (one per deployment is cleaner):
|
||||
|
||||
@ -86,65 +84,62 @@ limit_req_status 429;
|
||||
|
||||
## Dev vhost — `heng.04080616.xyz` → dev PC
|
||||
|
||||
The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → Next.js dashboard on `0.0.0.0:8010`. Routing aaPanel to it adds public reach (with `/cm-auth` gating) so you can hand someone a URL to test against without giving them VPN.
|
||||
The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → web-view on `0.0.0.0:8000`. Routing aaPanel to it adds public reach (with auth) so you can hand someone a URL to test against without giving them VPN.
|
||||
|
||||
aaPanel vhost for `heng.04080616.xyz` (in addition to the C4/C7 blocks above):
|
||||
aaPanel vhost for `heng.04080616.xyz` (in addition to the C3/C4 blocks above):
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://<dev-pc-lan-ip>:8010;
|
||||
proxy_pass http://<dev-pc-lan-ip>:8000;
|
||||
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_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
```
|
||||
|
||||
`X-Forwarded-Host` and `X-Forwarded-Proto` are required so WebAuthn passkey enrollment uses the public hostname (`heng.04080616.xyz`) as the relying-party ID, not the LAN IP — passkeys enrolled at one rpID can't authenticate at another, so a misconfigured proxy will silently break passkey login.
|
||||
|
||||
Replace `<dev-pc-lan-ip>` with the dev PC's address on your LAN.
|
||||
|
||||
⚠️ **Important: keep `CM_DEBUG=false` in the dev `.env` whenever aaPanel proxies the dev PC publicly.** Setting `CM_DEBUG=true` does two things:
|
||||
⚠️ **Important: turn `CM_DEBUG` OFF in the dev `.env` before letting aaPanel proxy to dev.** The dev tier defaults to `CM_DEBUG=true` (per `envs/dev/.env.example`), which enables Werkzeug's debugger. With aaPanel proxying publicly, basic auth is the only thing standing between the internet and an interactive Python REPL on the dev PC. The right pattern is:
|
||||
|
||||
1. The api-server (Flask) exposes the Werkzeug debugger — RCE if reachable.
|
||||
2. The Next.js dashboard drops the `Secure` flag on the session cookie so phone-on-LAN HTTP testing works.
|
||||
- `CM_DEBUG=true` only when iterating *fully locally* (no aaPanel proxy active, no port forward).
|
||||
- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`.
|
||||
|
||||
Both are dev-only conveniences. With aaPanel proxying through HTTPS, leave `CM_DEBUG=false` and use the in-app `/cm-auth` flow.
|
||||
If you'd rather not flip the flag manually, set `CM_DEBUG=false` permanently in your dev `.env` and run `bash scripts/bot_cli.sh` for the workflows you used to want the debugger for. The Flask in-browser tracebacks aren't worth the RCE surface.
|
||||
|
||||
## C7 — Host firewall on each web host
|
||||
## C7 — Host firewall on each Flask host
|
||||
|
||||
Restrict the LAN-reachable Next.js dashboard ports to only aaPanel's IP. Without this, anyone else on the LAN can hit the app directly and bypass everything in C4. Apply on each host that runs a stack: rex, siong, *and* the dev PC.
|
||||
Restrict the LAN-reachable web-view ports to only aaPanel's IP. Without this, anyone else on the LAN can hit Flask directly and bypass everything in C3 and C4. Apply on each host that runs a Flask stack: rex, siong, *and* the dev PC.
|
||||
|
||||
Replace `<aapanel-host-ip>` with the address of your aaPanel box.
|
||||
|
||||
On rex/siong hosts (ports 8011 / 8012):
|
||||
On rex/siong hosts (ports 8001 / 8005):
|
||||
|
||||
```bash
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8011 proto tcp comment 'rex web ← aaPanel only'
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8012 proto tcp comment 'siong web ← aaPanel only'
|
||||
sudo ufw deny 8011/tcp
|
||||
sudo ufw deny 8012/tcp
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8001 proto tcp comment 'rex web-view ← aaPanel only'
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8005 proto tcp comment 'siong web-view ← aaPanel only'
|
||||
sudo ufw deny 8001/tcp
|
||||
sudo ufw deny 8005/tcp
|
||||
sudo ufw reload
|
||||
sudo ufw status numbered
|
||||
```
|
||||
|
||||
On the dev PC (port 8010 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`):
|
||||
On the dev PC (port 8000 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`):
|
||||
|
||||
```bash
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8010 proto tcp comment 'dev web ← aaPanel only'
|
||||
sudo ufw allow from 127.0.0.1 to any port 8010 proto tcp comment 'dev web ← localhost'
|
||||
sudo ufw deny 8010/tcp
|
||||
sudo ufw allow from <aapanel-host-ip> to any port 8000 proto tcp comment 'dev web-view ← aaPanel only'
|
||||
sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost'
|
||||
sudo ufw deny 8000/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
The localhost rule on the dev PC is so you can still load `http://localhost:8010` directly while iterating, without going through aaPanel.
|
||||
The localhost rule on the dev PC is so you can still load `http://localhost:8000` directly while iterating, without going through aaPanel.
|
||||
|
||||
Verify from a third machine on the LAN:
|
||||
|
||||
```bash
|
||||
nmap -p 8010,8011,8012 <web-host-ip>
|
||||
nmap -p 8000,8001,8005 <flask-host-ip>
|
||||
# All three ports should show 'filtered' from anywhere except the aaPanel host
|
||||
# (and except localhost on the dev PC).
|
||||
```
|
||||
@ -152,22 +147,22 @@ nmap -p 8010,8011,8012 <web-host-ip>
|
||||
If you don't run ufw and prefer iptables directly, the equivalent rules are:
|
||||
|
||||
```bash
|
||||
iptables -A INPUT -p tcp --dport 8011 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8012 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8010 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8010 -s 127.0.0.1 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8011 -j DROP
|
||||
iptables -A INPUT -p tcp --dport 8012 -j DROP
|
||||
iptables -A INPUT -p tcp --dport 8010 -j DROP
|
||||
iptables -A INPUT -p tcp --dport 8001 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8005 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8000 -s <aapanel-host-ip> -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --dport 8001 -j DROP
|
||||
iptables -A INPUT -p tcp --dport 8005 -j DROP
|
||||
iptables -A INPUT -p tcp --dport 8000 -j DROP
|
||||
```
|
||||
|
||||
(Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.)
|
||||
|
||||
## Verification (after all blocks applied)
|
||||
|
||||
1. Hit any UI without a session: `curl -sI https://<rex-domain>/` → `307` redirect to `/cm-auth?next=/`. Same shape for siong and `https://heng.04080616.xyz/`. (If C3 basic auth is also configured, you get `401` first.)
|
||||
2. After signing in via `/cm-auth`: subsequent requests return `200 OK`. Use the browser; curl alone won't carry the cookie unless you `-c`/`-b` it.
|
||||
3. Scanner path: `curl -i https://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). The app logs show no entry for this request.
|
||||
4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; done | sort | uniq -c` → mix of `307`s up to the burst, then `429`s.
|
||||
5. From a non-aaPanel host on the LAN: `nmap -p 8010,8011,8012 <web-host-ip>` → all three ports `filtered` (localhost on dev PC still allowed).
|
||||
6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs api-server | grep "Debugger PIN"` should return nothing once `CM_DEBUG=false`. Sign in via the browser at `https://heng.04080616.xyz/cm-auth` and confirm the dashboard renders.
|
||||
1. Hit any UI without creds: `curl -i https://<rex-domain>/` → `401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`.
|
||||
2. With creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/` → `200 OK` with JSON.
|
||||
3. Scanner path: `curl -i https://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request.
|
||||
4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; done | sort | uniq -c` → mix of `200`/`401` (depending on auth state) up to the burst, then `429`s.
|
||||
5. From a non-aaPanel host on the LAN: `nmap -p 8000,8001,8005 <flask-host-ip>` → all three ports `filtered` (localhost on dev PC still allowed).
|
||||
6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs | grep "Debugger PIN"` should return nothing once `CM_DEBUG` is off. Then `curl -i -u dev-operator:<password> https://heng.04080616.xyz/api/acc/` returns the seed accounts.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,310 +0,0 @@
|
||||
# B-auth: Login + WebAuthn Passkeys Design
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Status:** Approved (design)
|
||||
**Sequel to:** [2026-05-02-b2-b3-ui-port-pwa-design.md](2026-05-02-b2-b3-ui-port-pwa-design.md)
|
||||
**Followed by:** B4 cutover (delete `app/cm_web_view.py`, retire `cm-web` Flask service, rename `cm-web-next` → `cm-web`).
|
||||
|
||||
## Problem
|
||||
|
||||
The Next.js dashboard (`cm-web-next`) currently has zero auth. Anyone who can reach `https://heng.04080616.xyz/` (the public vhost) lands directly on the accounts table. The plan was for aaPanel basic auth (C3) to gate the URL — and that's a fine outer defense — but the user wants:
|
||||
|
||||
1. **In-PWA Face ID / fingerprint sign-in.** Once the PWA is installed, opening it should hit a real WebAuthn flow, not an OS-mediated basic-auth dialog. Passkeys feel native; basic auth in a chromeless PWA feels jarring.
|
||||
2. **A password fallback** for first-time login on a new device, or when biometric isn't available.
|
||||
|
||||
The existing `CM_AGENT_ID` / `CM_AGENT_PASSWORD` env vars already define an operator identity per deployment (rex-cm has an agent, siong-cm has an agent). Reusing those as the dashboard password — instead of building a separate user table — keeps B-auth scope small and avoids duplicating identity state.
|
||||
|
||||
## Goal
|
||||
|
||||
Add an in-app login flow to `cm-web-next`:
|
||||
|
||||
- A `/cm-auth` page that shows two options side-by-side: a "Sign in with passkey" button (preferred when one is enrolled on this device), and a username + password form (fallback).
|
||||
- Password sign-in compares against the existing `CM_AGENT_ID` and `CM_AGENT_PASSWORD` env vars using a constant-time compare.
|
||||
- WebAuthn passkey enrollment (after first password sign-in, on a settings page) lets the operator add a Face ID / Touch ID / fingerprint credential bound to the device. Subsequent visits skip the password.
|
||||
- Session state: a signed `httpOnly` cookie via `iron-session`. 30-day rolling expiry; refreshes on activity.
|
||||
- All auth state lives in `cm-web-next` — no api-server changes, no mysql schema change. Passkeys are stored as JSON in a docker volume mounted into the container.
|
||||
- Middleware gates every dashboard route except `/cm-auth` and the WebAuthn Server Actions, which are reachable while logged out.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **No mysql schema change.** Passkeys live in a JSON file in a docker volume. For one operator with maybe 2-4 devices total, a real DB table is overkill.
|
||||
- **No separate identity service** (Authelia, Keycloak, Cloudflare Access). All auth lives in `cm-web-next`. Authelia remains an out-of-scope upgrade path if multi-tenant or multi-deployment SSO ever becomes a need.
|
||||
- **No multi-user support.** One operator per deployment, identified by `CM_AGENT_ID`. The passkey JSON is keyed by `CM_AGENT_ID` so that if a deployment ever swaps identity, the passkeys for the old identity stay scoped to the old identity.
|
||||
- **No "forgot password" flow.** The password is the env var. If the operator can't remember it, they look it up in the deployment's `.env`. There is no recovery email, no reset token, none of that.
|
||||
- **No api-server-side auth.** api-server stays internal-only (per C5), reached only from inside the docker network by web-view and web-next. Auth is a `cm-web-next` concern, not an api-server concern.
|
||||
- **No public `/api/*` routes for the auth flow.** WebAuthn challenge/response goes through Server Actions, preserving the "no scrapable JSON surface" architecture.
|
||||
- **B4 cutover is not in this scope.** Legacy Flask `cm_web_view.py` keeps running with no auth (gated only by aaPanel basic auth on its `https://...` vhost) until B4 retires it.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Identity model
|
||||
|
||||
One operator per `cm-web-next` instance, identified by `CM_AGENT_ID`. The same env var the bots use to log into cm99.net is reused as the dashboard username. The "session" is a cookie that says "the holder has authenticated as `CM_AGENT_ID`." Nothing more granular.
|
||||
|
||||
When `CM_AGENT_ID` changes (rex-cm gets a new agent, say), all existing passkeys for the old `CM_AGENT_ID` become inaccessible — by design. The passkey JSON is keyed by username, so swapping identities re-enrolls from scratch.
|
||||
|
||||
### Login flow — password
|
||||
|
||||
1. Browser hits `/` → middleware sees no session cookie → 302 to `/cm-auth?next=/`.
|
||||
2. `/cm-auth` page is a Server Component (form is a Client Component for state).
|
||||
3. User types `CM_AGENT_ID` and `CM_AGENT_PASSWORD`, submits.
|
||||
4. Client calls `loginWithPassword(username, password)` Server Action.
|
||||
5. Server Action:
|
||||
- Reads `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from env.
|
||||
- **Constant-time compare** both fields using `crypto.timingSafeEqual` over equal-length buffers.
|
||||
- If both match: sets the session cookie with `{ username: CM_AGENT_ID, authenticatedAt: Date.now() }`.
|
||||
- If either doesn't: returns `{ ok: false, error: "invalid credentials" }` (no leakage about which one).
|
||||
6. Browser redirects to `next` (default `/`).
|
||||
|
||||
### Login flow — passkey
|
||||
|
||||
1. `/cm-auth` page detects (client-side) whether `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` returns true and whether at least one passkey is enrolled (server-supplied flag in the page payload).
|
||||
2. If both true: render a "Sign in with passkey" button as the primary CTA, password form below.
|
||||
3. Click triggers `beginAuthentication()` Server Action → returns `PublicKeyCredentialRequestOptionsJSON` with a fresh server-generated challenge.
|
||||
4. Client invokes `@simplewebauthn/browser`'s `startAuthentication()`, which prompts Face ID / fingerprint.
|
||||
5. Browser returns signed assertion → client passes to `finishAuthentication(response)` Server Action.
|
||||
6. Server verifies via `@simplewebauthn/server`'s `verifyAuthenticationResponse`, looks up the matching credential by ID, increments the counter, sets the session cookie.
|
||||
7. Browser redirects to `next`.
|
||||
|
||||
### Passkey enrollment flow
|
||||
|
||||
1. Once authenticated (via password), user visits `/cm-passkeys`.
|
||||
2. "Add passkey" button → `beginRegistration()` Server Action returns `PublicKeyCredentialCreationOptionsJSON`.
|
||||
3. Client invokes `@simplewebauthn/browser`'s `startRegistration()` — Face ID / fingerprint enrolls a new credential.
|
||||
4. Client sends attestation to `finishRegistration(response, deviceName)` Server Action.
|
||||
5. Server verifies via `verifyRegistrationResponse`, persists `{ id, publicKey, counter, name, createdAt }` to the JSON file.
|
||||
6. Page revalidates, the new passkey appears in the list.
|
||||
|
||||
The settings page lists existing passkeys with their device names + a "Remove" button. Removing a passkey deletes its row from the JSON file.
|
||||
|
||||
### Session
|
||||
|
||||
| Concern | Choice |
|
||||
|---|---|
|
||||
| Library | `iron-session` (single small dep, hooks into Next.js cleanly via App Router cookies API) |
|
||||
| Cookie name | `cm_auth` |
|
||||
| Cookie attrs | `httpOnly`, `secure` (when `NODE_ENV=production`), `sameSite=lax`, `path=/` |
|
||||
| Expiry | 30-day rolling — refresh on every request that touches a page |
|
||||
| Secret | `CM_AUTH_SECRET` env var. ≥32 chars random. Operator generates with `openssl rand -hex 32`. |
|
||||
| Body | `{ username: string, authenticatedAt: number }` — kept minimal so a stale session doesn't carry stale state. |
|
||||
|
||||
### Passkey storage
|
||||
|
||||
JSON file at `/data/auth/passkeys.json` inside the container. Mounted from a named volume `${CM_DEPLOY_NAME:-cm}-web-next-auth-data` so it persists across container restarts and image rebuilds.
|
||||
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"<CM_AGENT_ID>": [
|
||||
{
|
||||
"id": "base64url-credential-id",
|
||||
"publicKey": "base64url-public-key",
|
||||
"counter": 42,
|
||||
"transports": ["internal", "hybrid"],
|
||||
"name": "iPhone 15 Pro",
|
||||
"createdAt": "2026-05-02T12:34:56Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Top-level keys are `CM_AGENT_ID` values; values are arrays of credential records. The JSON file is read on every WebAuthn flow (small file, no caching needed) and written atomically (write to `passkeys.json.tmp`, fsync, rename).
|
||||
|
||||
A small wrapper module `web/lib/auth-store.ts` owns the read/write and locks via a single in-process mutex to prevent concurrent writes from racing.
|
||||
|
||||
### Server Actions inventory
|
||||
|
||||
All in `web/app/auth-actions.ts` with `"use server"`:
|
||||
|
||||
| Action | Purpose |
|
||||
|---|---|
|
||||
| `loginWithPassword({ username, password })` | Constant-time compare → set cookie → return `{ ok }` |
|
||||
| `logout()` | Clear cookie → return `{ ok: true }` |
|
||||
| `beginRegistration()` | Generate registration options, store challenge in session, return options. Requires authenticated session. |
|
||||
| `finishRegistration({ response, deviceName })` | Verify attestation, persist credential to JSON. Requires authenticated session. |
|
||||
| `beginAuthentication()` | Generate authentication options, store challenge in session, return options. NO auth required (this IS the login). |
|
||||
| `finishAuthentication({ response })` | Verify assertion, set cookie, return `{ ok }`. NO auth required. |
|
||||
| `removePasskey({ credentialId })` | Delete from JSON. Requires authenticated session. |
|
||||
|
||||
The challenge for register/authenticate is stored in the session cookie (small, signed, transient). On the next call (`finishRegistration` / `finishAuthentication`) the server retrieves it from the cookie and clears it.
|
||||
|
||||
### Middleware
|
||||
|
||||
`web/middleware.ts` runs on every request:
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionFromCookie } from "@/lib/auth";
|
||||
|
||||
const PUBLIC_PATHS = new Set(["/cm-auth"]);
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (PUBLIC_PATHS.has(path)) return NextResponse.next();
|
||||
|
||||
const session = await getSessionFromCookie(req.cookies);
|
||||
if (!session) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/cm-auth";
|
||||
url.searchParams.set("next", path);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Skip _next, static, favicon, manifest, icon endpoints, etc.
|
||||
matcher: ["/((?!_next|icon|apple-icon|manifest.webmanifest|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
Server Actions live OUTSIDE the matcher (Next.js routes them through a separate POST handler with magic encoded payloads). Auth-required actions check the session manually inside the action body (because middleware doesn't run on Server Action invocations the same way).
|
||||
|
||||
### Files Created / Modified
|
||||
|
||||
| File | Operation | Purpose |
|
||||
|---|---|---|
|
||||
| `web/middleware.ts` | Create | Route gate |
|
||||
| `web/lib/auth.ts` | Create | Session create/read/destroy helpers (iron-session wrapper) |
|
||||
| `web/lib/auth-store.ts` | Create | JSON-file CRUD for passkeys with in-process write lock |
|
||||
| `web/app/auth-actions.ts` | Create | All Server Actions listed above |
|
||||
| `web/app/cm-auth/page.tsx` | Create | Login UI (Server Component shell) |
|
||||
| `web/app/cm-auth/auth-form.tsx` | Create | Client Component for the form + passkey button |
|
||||
| `web/app/cm-passkeys/page.tsx` | Create | Passkey list + add/remove (Server Component) |
|
||||
| `web/app/cm-passkeys/passkey-list.tsx` | Create | Client Component handling enrollment + removal |
|
||||
| `web/components/nav.tsx` | Modify | Add Settings link + Sign-out button (account menu) |
|
||||
| `web/package.json` | Modify | Add `iron-session`, `@simplewebauthn/server`, `@simplewebauthn/browser` |
|
||||
| `docker-compose.yml` | Modify | Add `web-next-auth-data` named volume + mount in `web-next` service |
|
||||
| `docker-compose.override.yml` | Modify | Same volume mount in dev override |
|
||||
| `envs/dev/.env.example` | Modify | Add `CM_AUTH_SECRET=devsecret-32-bytes-or-more-please-rotate` |
|
||||
| `envs/rex/.env.example` | Modify | Same with placeholder, operator generates real value |
|
||||
| `envs/siong/.env.example` | Modify | Same |
|
||||
| `AGENTS.md` | Modify | Add a "Auth" subsection documenting `CM_AUTH_SECRET` and the passkey JSON volume |
|
||||
|
||||
No file deletions. No changes outside `web/` and the per-deployment env templates and AGENTS.md.
|
||||
|
||||
### `web/lib/auth.ts` shape
|
||||
|
||||
```typescript
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { sealData, unsealData } from "iron-session";
|
||||
|
||||
const COOKIE_NAME = "cm_auth";
|
||||
const COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
type Session = {
|
||||
username: string;
|
||||
authenticatedAt: number;
|
||||
// Transient WebAuthn state (challenge, type) lives here too while a flow is in progress.
|
||||
pendingChallenge?: { kind: "register" | "authenticate"; challenge: string; expiresAt: number };
|
||||
};
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.CM_AUTH_SECRET;
|
||||
if (!s || s.length < 32) {
|
||||
throw new Error("CM_AUTH_SECRET missing or shorter than 32 chars");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<Session | null> { /* read cookie, unseal */ }
|
||||
export async function setSession(s: Session): Promise<void> { /* seal, write cookie */ }
|
||||
export async function clearSession(): Promise<void> { /* delete cookie */ }
|
||||
export async function requireSession(): Promise<Session> { /* throws if no session */ }
|
||||
```
|
||||
|
||||
`server-only` ensures this never bundles into client code (poison import — fails the build if imported from a client component).
|
||||
|
||||
### `web/lib/auth-store.ts` shape
|
||||
|
||||
```typescript
|
||||
import "server-only";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json";
|
||||
|
||||
export type PasskeyRecord = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports: AuthenticatorTransportFuture[];
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let writeLock: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function readPasskeys(username: string): Promise<PasskeyRecord[]> { /* ... */ }
|
||||
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> { /* lock, read, append, atomic-write */ }
|
||||
export async function removePasskey(username: string, credentialId: string): Promise<boolean> { /* lock, read, filter, atomic-write */ }
|
||||
export async function bumpCounter(username: string, credentialId: string, counter: number): Promise<void> { /* same */ }
|
||||
```
|
||||
|
||||
The `writeLock` chain serializes writes within a single Node process. With one container (no clustering) this is sufficient. If we ever scale `cm-web-next` horizontally, switch to a real lock file or move to mysql.
|
||||
|
||||
### Login page UI brief
|
||||
|
||||
frontend-design generates `login/page.tsx` shell + `login-form.tsx` client component matching the SaaS aesthetic of the rest of the dashboard. Concrete requirements:
|
||||
|
||||
- Centered card on the workbench backdrop, white with `ring-1 ring-zinc-200/60`, rounded-2xl.
|
||||
- Brand mark (small "CM" tile) + "Sign in" heading.
|
||||
- **Primary CTA:** "Sign in with passkey" button (large, dark zinc-900) — only rendered if the page payload says a passkey is enrolled AND the browser supports `isUserVerifyingPlatformAuthenticatorAvailable()`.
|
||||
- **Below it:** "or username + password" divider, then two inputs (username, password) with a smaller "Sign in" button.
|
||||
- Error state: inline red below the form if `loginWithPassword` returns `{ ok: false }`.
|
||||
- All inputs use `text-base sm:text-[13px]` (the existing iOS auto-zoom fix).
|
||||
- No "remember me" — cookie is rolling 30 days by default.
|
||||
- "Forgot your password? Check the deployment's `.env` file" — small zinc-500 footer (matter-of-fact, internal-tool tone).
|
||||
|
||||
### Settings/passkeys page UI brief
|
||||
|
||||
- Standard dashboard layout (Nav, page heading "Passkeys").
|
||||
- List of enrolled passkeys: name, created date, "Remove" button. Empty state: "No passkeys enrolled yet."
|
||||
- "Add passkey" button at the top: opens a modal with a single text input ("Device name", e.g., "iPhone 15"), then triggers `startRegistration`.
|
||||
- After successful enrollment: row appears, success toast fires (matches existing toast pattern).
|
||||
|
||||
### Nav modification
|
||||
|
||||
Add a small account menu on the right side (next to the existing Accounts/Users tab pills):
|
||||
|
||||
- A subtle button showing `CM_AGENT_ID` (truncated if long).
|
||||
- On click: dropdown with "Passkey settings" → `/cm-passkeys`, and "Sign out" → calls `logout()` Server Action → redirect to `/cm-auth`.
|
||||
|
||||
The dropdown uses the same modal/sheet primitive style — no new component primitive.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Cold start.** `bash scripts/dev.sh up`. Open `http://localhost:8010/`. Redirected to `/cm-auth?next=%2F`.
|
||||
2. **Password sign-in.** Type `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the dev `.env`. Submit. Redirect to `/`. Accounts table renders.
|
||||
3. **Cookie set.** DevTools → Application → Cookies → `cm_auth` present, `httpOnly`, `secure` (in prod) / not (in dev because `NODE_ENV=development`), `sameSite=lax`, expires ~30 days.
|
||||
4. **Wrong password.** Type wrong password. Form shows red "invalid credentials". No success toast. No cookie set.
|
||||
5. **Sign out.** Click the user menu → Sign out. Redirected to `/cm-auth`. Cookie cleared.
|
||||
6. **Passkey enrollment** (Chrome desktop with Touch ID, or iPhone). Sign in with password → settings/passkeys → Add passkey → name "MacBook" → Touch ID prompt → success toast → row appears in list.
|
||||
7. **Passkey login.** Sign out. `/cm-auth` now shows "Sign in with passkey" as primary CTA. Click → Touch ID → redirect to `/`.
|
||||
8. **Passkey persistence.** `bash scripts/dev.sh down && bash scripts/dev.sh up`. Sign-in flow still recognizes the previously enrolled passkey (volume persisted).
|
||||
9. **Passkey removal.** Sign in → settings/passkeys → Remove. Row disappears, JSON file no longer contains it.
|
||||
10. **Middleware coverage.** While signed out: `/`, `/users/`, `/cm-passkeys` all redirect to `/cm-auth`. `/cm-auth` itself does not redirect.
|
||||
11. **Server Actions auth.** Calling `removePasskey` from a client without a valid session returns an error (auth-action body checks `getSession()` and throws/returns 401-equivalent).
|
||||
12. **Constant-time compare.** Manually inspect `loginWithPassword` source — uses `crypto.timingSafeEqual` over zero-padded buffers of equal length. (No timing-channel leak about which field is wrong.)
|
||||
13. **Volume preserved across rebuild.** `sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next` then `up`. Passkey JSON survives.
|
||||
|
||||
## Risk
|
||||
|
||||
Medium.
|
||||
|
||||
- **JSON-file write durability.** A crash mid-write could corrupt the file. Mitigation: atomic write (`tmp` + `rename`), single in-process mutex. For one operator with low write frequency (passkey adds/removes are rare), this is sufficient. If we ever need multi-writer guarantees, switch to mysql.
|
||||
- **`CM_AUTH_SECRET` rotation invalidates all sessions.** Expected behavior — operators understand a secret rotation logs everyone out. Document this.
|
||||
- **Passkeys aren't multi-user.** If two operators ever need to share a deployment, they'd share the same `CM_AGENT_ID` identity and the same passkey list — fine for now but a hard scaling cliff. Captured as out-of-scope.
|
||||
- **Browser support.** WebAuthn is supported in all modern browsers (iOS 16+, Chrome, Edge, Firefox, Safari). On unsupported browsers the password flow is the only path; we feature-detect and hide the passkey CTA.
|
||||
- **iOS PWA standalone WebAuthn.** Apple has had platform bugs in earlier iOS versions where standalone PWAs couldn't trigger WebAuthn. iOS 17+ is reliable. Document the minimum version.
|
||||
- **Server Action surface.** Server Actions ARE network-callable (Next.js routes them). They aren't "private functions" — anyone who reverse-engineers the Next.js wire format can call them. Mitigation: every action that requires auth checks the session inside the action body. The cost of reverse-engineering Next.js's encoding is much higher than calling an open `/api/foo` endpoint, so the practical attack surface is similar to a per-route auth-required `/api/*` proxy.
|
||||
|
||||
## Out-of-Scope Follow-Ups
|
||||
|
||||
- **B4 cutover** — separate cycle: delete `app/cm_web_view.py`, retire `cm-web` (Flask) service, rename `cm-web-next` → `cm-web`. After B4, the legacy Flask UI (which has no auth) goes away entirely.
|
||||
- **Authelia / SSO** — if multi-deployment SSO ever becomes a need, swap the in-app auth for an Authelia container. No timeline; revisit if/when.
|
||||
- **Session listing / revocation** — show "active sessions" on settings, allow remote logout. Useful for "I lost a phone" recovery if you want stricter than "rotate `CM_AUTH_SECRET`". YAGNI for now.
|
||||
- **CSRF token on Server Actions** — Next.js's Server Action transport already includes a hidden token, but reviewing the framework's CSRF posture for our specific deployment is an exercise we can do separately.
|
||||
- **Failed-login lockout** — a small per-IP counter that returns 429 after N bad password attempts. Defense-in-depth; aaPanel C4 rate-limit also helps.
|
||||
@ -6,14 +6,10 @@
|
||||
# === Runtime ===
|
||||
CM_DEBUG=true
|
||||
|
||||
# === Auth (cm-web session signing) ===
|
||||
# 64-character hex (32 bytes). Generate with: openssl rand -hex 32
|
||||
# Rotating this secret invalidates all existing sessions (forces re-login).
|
||||
CM_AUTH_SECRET=devsecret-replace-with-openssl-rand-hex-32-for-real-deploys
|
||||
|
||||
# === Deployment Identity ===
|
||||
CM_DEPLOY_NAME=dev-cm
|
||||
CM_WEB_HOST_PORT=8010
|
||||
CM_WEB_HOST_PORT=8000
|
||||
CM_WEB_NEXT_HOST_PORT=8010
|
||||
|
||||
# === Docker Registry / Build ===
|
||||
CM_IMAGE_PREFIX=local
|
||||
|
||||
@ -9,7 +9,8 @@ CM_DEBUG=false
|
||||
|
||||
# === Deployment Identity ===
|
||||
CM_DEPLOY_NAME=rex-cm
|
||||
CM_WEB_HOST_PORT=8011
|
||||
CM_WEB_HOST_PORT=8001
|
||||
CM_WEB_NEXT_HOST_PORT=8011
|
||||
|
||||
# === Docker Registry / Build ===
|
||||
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
|
||||
@ -36,8 +37,3 @@ CM_AGENT_ID=
|
||||
CM_AGENT_PASSWORD=
|
||||
CM_SECURITY_PIN=
|
||||
CM_BOT_BASE_URL=
|
||||
|
||||
# === Auth (cm-web session signing) ===
|
||||
# 64-character hex (32 bytes). Generate with: openssl rand -hex 32
|
||||
# Rotating this secret invalidates all existing sessions (forces re-login).
|
||||
CM_AUTH_SECRET=
|
||||
|
||||
@ -9,7 +9,8 @@ CM_DEBUG=false
|
||||
|
||||
# === Deployment Identity ===
|
||||
CM_DEPLOY_NAME=siong-cm
|
||||
CM_WEB_HOST_PORT=8012
|
||||
CM_WEB_HOST_PORT=8005
|
||||
CM_WEB_NEXT_HOST_PORT=8012
|
||||
|
||||
# === Docker Registry / Build ===
|
||||
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
|
||||
@ -36,8 +37,3 @@ CM_AGENT_ID=
|
||||
CM_AGENT_PASSWORD=
|
||||
CM_SECURITY_PIN=
|
||||
CM_BOT_BASE_URL=
|
||||
|
||||
# === Auth (cm-web session signing) ===
|
||||
# 64-character hex (32 bytes). Generate with: openssl rand -hex 32
|
||||
# Rotating this secret invalidates all existing sessions (forces re-login).
|
||||
CM_AUTH_SECRET=
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lifecycle commands for the local dev stack (mysql + api-server + web).
|
||||
# Lifecycle commands for the local dev stack (mysql + api-server + web-view).
|
||||
# Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile
|
||||
# and do not start with 'up'. Status is used by scripts/bot_cli.sh.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Lifecycle for the dev stack (mysql + api-server + web).
|
||||
Lifecycle for the dev stack (mysql + api-server + web-view + web-next).
|
||||
|
||||
Usage:
|
||||
scripts/dev.sh up Start all dev services in the background.
|
||||
@ -47,23 +47,22 @@ fi
|
||||
|
||||
case "${1:-}" in
|
||||
up)
|
||||
"${COMPOSE[@]}" up -d --build mysql api-server web
|
||||
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
|
||||
"${COMPOSE[@]}" ps
|
||||
;;
|
||||
down)
|
||||
# --remove-orphans cleans up containers from manual `docker compose
|
||||
# -f docker-compose.yml ...` invocations (e.g., the prod-mode gunicorn
|
||||
# smoke test) that landed in the same compose project but aren't
|
||||
# services in the override. Also catches the legacy web-view /
|
||||
# web-next containers from before B4 cutover.
|
||||
# services in the override.
|
||||
"${COMPOSE[@]}" down --remove-orphans
|
||||
;;
|
||||
reset-db)
|
||||
"${COMPOSE[@]}" down --volumes --remove-orphans
|
||||
"${COMPOSE[@]}" up -d --build mysql api-server web
|
||||
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
|
||||
;;
|
||||
logs)
|
||||
"${COMPOSE[@]}" logs -f mysql api-server web
|
||||
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
|
||||
;;
|
||||
status)
|
||||
if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate a 32-byte (64 hex chars) CM_AUTH_SECRET for cm-web session
|
||||
# signing. Prints the value to stdout, or appends/replaces it in a target
|
||||
# .env file with --write.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Generate a CM_AUTH_SECRET for cm-web.
|
||||
|
||||
Usage:
|
||||
scripts/gen_auth_secret.sh Print a fresh secret to stdout.
|
||||
scripts/gen_auth_secret.sh --write Set CM_AUTH_SECRET= in ./.env
|
||||
(creates the file if missing,
|
||||
replaces the line if present).
|
||||
scripts/gen_auth_secret.sh --write PATH Same, against an explicit .env path.
|
||||
|
||||
Notes:
|
||||
- Requires `openssl` (falls back to /dev/urandom if missing).
|
||||
- Rotating the secret invalidates every existing session — every signed-in
|
||||
operator gets bounced to /cm-auth on the next request.
|
||||
EOF
|
||||
}
|
||||
|
||||
generate() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
else
|
||||
head -c 32 /dev/urandom | xxd -p -c 64
|
||||
fi
|
||||
}
|
||||
|
||||
write_into() {
|
||||
local target="$1"
|
||||
local secret
|
||||
secret="$(generate)"
|
||||
if [[ -f "${target}" ]] && grep -q '^CM_AUTH_SECRET=' "${target}"; then
|
||||
# Replace in place. Use a tmp file so we don't truncate on failure.
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v s="${secret}" '
|
||||
/^CM_AUTH_SECRET=/ { print "CM_AUTH_SECRET=" s; next }
|
||||
{ print }
|
||||
' "${target}" >"${tmp}"
|
||||
mv "${tmp}" "${target}"
|
||||
echo "Replaced CM_AUTH_SECRET in ${target}"
|
||||
else
|
||||
[[ -f "${target}" ]] || touch "${target}"
|
||||
# Add a leading newline only if the file already has content and doesn't
|
||||
# end with a newline.
|
||||
if [[ -s "${target}" && -n "$(tail -c 1 "${target}")" ]]; then
|
||||
printf '\n' >>"${target}"
|
||||
fi
|
||||
printf 'CM_AUTH_SECRET=%s\n' "${secret}" >>"${target}"
|
||||
echo "Appended CM_AUTH_SECRET to ${target}"
|
||||
fi
|
||||
echo "Restart the web service to pick up the new secret:"
|
||||
echo " bash scripts/dev.sh down && bash scripts/dev.sh up"
|
||||
echo " # or, in production: docker compose restart web"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
--write)
|
||||
target="${2:-.env}"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# Resolve relative paths against the repo root, not CWD.
|
||||
[[ "${target}" = /* ]] || target="${ROOT_DIR}/${target}"
|
||||
write_into "${target}"
|
||||
;;
|
||||
"")
|
||||
generate
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@ -60,6 +60,7 @@ SERVICES=(
|
||||
"telegram docker/telegram/Dockerfile"
|
||||
"web docker/web/Dockerfile"
|
||||
"transfer docker/transfer/Dockerfile"
|
||||
"web-next docker/web-next/Dockerfile"
|
||||
)
|
||||
|
||||
echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-<service>:${IMAGE_TAG}"
|
||||
|
||||
120
scripts/verify_debug.sh
Executable file
120
scripts/verify_debug.sh
Executable file
@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the CM_DEBUG env toggle on the cm-web container.
|
||||
# No DB required — web-view has no DB dependency, and we use --no-deps
|
||||
# to skip api-server (which needs MySQL).
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Verify the CM_DEBUG env toggle on the cm-web container.
|
||||
|
||||
Usage:
|
||||
scripts/verify_debug.sh
|
||||
|
||||
What it does:
|
||||
Brings up web-view twice — once with CM_DEBUG=true, once unset (default
|
||||
false) — greps the container logs for the Werkzeug "Debug mode" banner
|
||||
and the "Debugger PIN" line, and reports pass/fail. Tears down on exit.
|
||||
|
||||
Requirements:
|
||||
- docker compose (v2 plugin)
|
||||
- sudo (matches scripts/local_build.sh; set NO_SUDO=1 to skip sudo)
|
||||
- .env at the repo root (copy envs/rex/.env or envs/siong/.env first)
|
||||
|
||||
Exit code:
|
||||
0 if both modes behave correctly, non-zero otherwise.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
SUDO="sudo"
|
||||
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
|
||||
# shellcheck disable=SC2206
|
||||
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
|
||||
|
||||
SERVICE=web-view
|
||||
WAIT_SECS=20
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "ERROR: .env not found at repo root. Copy envs/rex/.env (or envs/siong/.env) first." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "==> Cleaning up..."
|
||||
"${COMPOSE[@]}" down --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_banner() {
|
||||
local left=$WAIT_SECS
|
||||
while (( left > 0 )); do
|
||||
if "${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1 | grep -q "Debug mode:"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
((left--))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_logs() {
|
||||
local expected_debug="$1" expect_pin="$2"
|
||||
local logs
|
||||
logs="$("${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1)"
|
||||
|
||||
if ! echo "${logs}" | grep -q "Debug mode: ${expected_debug}"; then
|
||||
echo "FAIL: expected 'Debug mode: ${expected_debug}' in ${SERVICE} logs"
|
||||
echo "--- captured logs ---"
|
||||
echo "${logs}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${expect_pin}" == "yes" ]]; then
|
||||
if ! echo "${logs}" | grep -q "Debugger PIN:"; then
|
||||
echo "FAIL: expected 'Debugger PIN:' line, none found"
|
||||
echo "--- captured logs ---"
|
||||
echo "${logs}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if echo "${logs}" | grep -q "Debugger PIN:"; then
|
||||
echo "FAIL: 'Debugger PIN:' line present when CM_DEBUG should be off"
|
||||
echo "--- captured logs ---"
|
||||
echo "${logs}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_mode() {
|
||||
local mode="$1" expected_debug="$2" expect_pin="$3"
|
||||
echo "==> CM_DEBUG=${mode} — expecting 'Debug mode: ${expected_debug}', PIN ${expect_pin}"
|
||||
|
||||
CM_DEBUG="${mode}" "${COMPOSE[@]}" up -d --build --no-deps "${SERVICE}" >/dev/null
|
||||
|
||||
if ! wait_for_banner; then
|
||||
echo "FAIL: ${SERVICE} did not print 'Debug mode:' banner within ${WAIT_SECS}s"
|
||||
"${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
check_logs "${expected_debug}" "${expect_pin}" || return 1
|
||||
echo "PASS"
|
||||
echo
|
||||
|
||||
"${COMPOSE[@]}" stop "${SERVICE}" >/dev/null
|
||||
}
|
||||
|
||||
run_mode "true" "on" "yes" || exit 1
|
||||
run_mode "false" "off" "no" || exit 1
|
||||
|
||||
echo "All CM_DEBUG verifications passed."
|
||||
@ -1,27 +1,28 @@
|
||||
"""Regression tests for the _debug_enabled helper.
|
||||
|
||||
`app.cm_api` defines a private _debug_enabled() function that parses the
|
||||
CM_DEBUG environment variable. This test runs a parametrized matrix
|
||||
against it. Historically `app.cm_web_view` had its own copy that this
|
||||
suite kept in lockstep — that module retired in the B4 cutover, leaving
|
||||
only cm_api as the consumer. The matrix-style harness stays so the next
|
||||
Flask entrypoint can be wired in by appending one line to HELPER_MODULES.
|
||||
Both app.cm_api and app.cm_web_view define a private _debug_enabled()
|
||||
function that parses the CM_DEBUG environment variable. They are
|
||||
intentionally duplicated (only two call sites; no shared utility module
|
||||
exists). This test runs the same parametrized cases against every copy
|
||||
to catch drift if one is updated without the other.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
# Import the module at top-level (before any mock.patch.dict with
|
||||
# Import the modules at top-level (before any mock.patch.dict with
|
||||
# clear=True), so module-load-time os.getenv() reads see the real
|
||||
# environment. The patches inside individual tests then only affect the
|
||||
# helper's runtime read of CM_DEBUG.
|
||||
import app.cm_api
|
||||
import app.cm_web_view
|
||||
|
||||
|
||||
# Modules expected to expose a private _debug_enabled() helper.
|
||||
# Add new entries here if more Flask entrypoints adopt the same toggle.
|
||||
HELPER_MODULES = (
|
||||
app.cm_web_view,
|
||||
app.cm_api,
|
||||
)
|
||||
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
import {
|
||||
getSession,
|
||||
setSession,
|
||||
clearSession,
|
||||
requireSession,
|
||||
} from "@/lib/auth";
|
||||
import { getRpInfo } from "@/lib/auth-rp";
|
||||
import {
|
||||
readPasskeys,
|
||||
appendPasskey,
|
||||
removePasskey,
|
||||
bumpCounter,
|
||||
} from "@/lib/auth-store";
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
const len = Math.max(ab.length, bb.length);
|
||||
const ap = Buffer.alloc(len);
|
||||
const bp = Buffer.alloc(len);
|
||||
ab.copy(ap);
|
||||
bb.copy(bp);
|
||||
return timingSafeEqual(ap, bp) && ab.length === bb.length;
|
||||
}
|
||||
|
||||
export async function loginWithPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<ActionResult> {
|
||||
const expectedUsername = process.env.CM_AGENT_ID ?? "";
|
||||
const expectedPassword = process.env.CM_AGENT_PASSWORD ?? "";
|
||||
if (!expectedUsername || !expectedPassword) {
|
||||
return { ok: false, error: "Server credentials not configured" };
|
||||
}
|
||||
const usernameOk = constantTimeEqual(username, expectedUsername);
|
||||
const passwordOk = constantTimeEqual(password, expectedPassword);
|
||||
if (!usernameOk || !passwordOk) {
|
||||
return { ok: false, error: "Invalid credentials" };
|
||||
}
|
||||
await setSession({
|
||||
username: expectedUsername,
|
||||
authenticatedAt: Date.now(),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await clearSession();
|
||||
redirect("/cm-auth");
|
||||
}
|
||||
|
||||
export async function beginRegistration() {
|
||||
const session = await requireSession();
|
||||
const { rpID, rpName } = await getRpInfo();
|
||||
const existing = await readPasskeys(session.username);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: session.username,
|
||||
userID: new TextEncoder().encode(session.username),
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
excludeCredentials: existing.map((p) => ({
|
||||
id: p.id,
|
||||
transports: p.transports,
|
||||
})),
|
||||
});
|
||||
await setSession({
|
||||
...session,
|
||||
pendingChallenge: {
|
||||
kind: "register",
|
||||
challenge: options.challenge,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishRegistration(
|
||||
response: RegistrationResponseJSON,
|
||||
deviceName: string,
|
||||
): Promise<ActionResult> {
|
||||
const session = await requireSession();
|
||||
const pending = session.pendingChallenge;
|
||||
if (!pending || pending.kind !== "register" || pending.expiresAt < Date.now()) {
|
||||
return { ok: false, error: "Registration challenge expired or missing" };
|
||||
}
|
||||
const { rpID, origin } = await getRpInfo();
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: pending.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Verification failed",
|
||||
};
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return { ok: false, error: "Registration not verified" };
|
||||
}
|
||||
const info = verification.registrationInfo;
|
||||
const cred = info.credential;
|
||||
const trimmedName = (deviceName || "").trim() || "Unnamed device";
|
||||
await appendPasskey(session.username, {
|
||||
id: cred.id,
|
||||
publicKey: Buffer.from(cred.publicKey).toString("base64url"),
|
||||
counter: cred.counter,
|
||||
transports: response.response.transports ?? [],
|
||||
name: trimmedName,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await setSession({
|
||||
username: session.username,
|
||||
authenticatedAt: session.authenticatedAt,
|
||||
});
|
||||
revalidatePath("/cm-passkeys");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function beginAuthentication() {
|
||||
const expectedUsername = process.env.CM_AGENT_ID ?? "";
|
||||
const passkeys = await readPasskeys(expectedUsername);
|
||||
const { rpID } = await getRpInfo();
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: "preferred",
|
||||
allowCredentials: passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
transports: p.transports,
|
||||
})),
|
||||
});
|
||||
const existing = (await getSession()) ?? {
|
||||
username: "",
|
||||
authenticatedAt: 0,
|
||||
};
|
||||
await setSession({
|
||||
...existing,
|
||||
pendingChallenge: {
|
||||
kind: "authenticate",
|
||||
challenge: options.challenge,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishAuthentication(
|
||||
response: AuthenticationResponseJSON,
|
||||
): Promise<ActionResult> {
|
||||
const expectedUsername = process.env.CM_AGENT_ID ?? "";
|
||||
if (!expectedUsername) {
|
||||
return { ok: false, error: "Server identity not configured" };
|
||||
}
|
||||
const session = await getSession();
|
||||
const pending = session?.pendingChallenge;
|
||||
if (!pending || pending.kind !== "authenticate" || pending.expiresAt < Date.now()) {
|
||||
return { ok: false, error: "Authentication challenge expired or missing" };
|
||||
}
|
||||
const passkeys = await readPasskeys(expectedUsername);
|
||||
const stored = passkeys.find((p) => p.id === response.id);
|
||||
if (!stored) {
|
||||
return { ok: false, error: "Unknown credential" };
|
||||
}
|
||||
const { rpID, origin } = await getRpInfo();
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: pending.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: stored.id,
|
||||
publicKey: Buffer.from(stored.publicKey, "base64url"),
|
||||
counter: stored.counter,
|
||||
transports: stored.transports,
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Verification failed",
|
||||
};
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return { ok: false, error: "Authentication not verified" };
|
||||
}
|
||||
await bumpCounter(expectedUsername, stored.id, verification.authenticationInfo.newCounter);
|
||||
await setSession({
|
||||
username: expectedUsername,
|
||||
authenticatedAt: Date.now(),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deletePasskey(credentialId: string): Promise<ActionResult> {
|
||||
const session = await requireSession();
|
||||
const removed = await removePasskey(session.username, credentialId);
|
||||
if (!removed) return { ok: false, error: "Passkey not found" };
|
||||
revalidatePath("/cm-passkeys");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function hasPasskeysForLogin(): Promise<boolean> {
|
||||
const expectedUsername = process.env.CM_AGENT_ID ?? "";
|
||||
if (!expectedUsername) return false;
|
||||
const list = await readPasskeys(expectedUsername);
|
||||
return list.length > 0;
|
||||
}
|
||||
@ -1,267 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import {
|
||||
loginWithPassword,
|
||||
beginAuthentication,
|
||||
finishAuthentication,
|
||||
} from "@/app/auth-actions";
|
||||
|
||||
type Props = {
|
||||
passkeysAvailable: boolean;
|
||||
next: string;
|
||||
};
|
||||
|
||||
export default function AuthForm({ passkeysAvailable, next }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [platformReady, setPlatformReady] = useState(false);
|
||||
const [pointerDevice, setPointerDevice] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setPointerDevice(
|
||||
window.matchMedia("(hover: hover) and (pointer: fine)").matches,
|
||||
);
|
||||
if (
|
||||
!passkeysAvailable ||
|
||||
typeof window.PublicKeyCredential === "undefined" ||
|
||||
typeof window.PublicKeyCredential
|
||||
.isUserVerifyingPlatformAuthenticatorAvailable !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
window.PublicKeyCredential
|
||||
.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
.then((ok) => {
|
||||
if (!cancelled) setPlatformReady(Boolean(ok));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [passkeysAvailable]);
|
||||
|
||||
const showPasskey = passkeysAvailable && platformReady;
|
||||
const destination = next && next.startsWith("/") ? next : "/";
|
||||
|
||||
function handlePasskey() {
|
||||
setPasskeyError(null);
|
||||
setFormError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const options = await beginAuthentication();
|
||||
const response = await startAuthentication({ optionsJSON: options });
|
||||
const result = await finishAuthentication(response);
|
||||
if (result.ok) {
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
} else {
|
||||
setPasskeyError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Passkey sign-in failed";
|
||||
if (
|
||||
message.toLowerCase().includes("notallowed") ||
|
||||
message.toLowerCase().includes("cancel")
|
||||
) {
|
||||
setPasskeyError("Sign-in was cancelled");
|
||||
} else {
|
||||
setPasskeyError(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
setPasskeyError(null);
|
||||
startTransition(async () => {
|
||||
const result = await loginWithPassword(username, password);
|
||||
if (result.ok) {
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
} else {
|
||||
setFormError(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center px-2">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-2xl bg-white p-8 ring-1 ring-zinc-200/60 sm:p-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
|
||||
CM
|
||||
</span>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-zinc-900">
|
||||
Sign in
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-zinc-500">
|
||||
CM Bot V2 — operator console
|
||||
</p>
|
||||
|
||||
{showPasskey && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasskey}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-3.5 w-3.5"
|
||||
>
|
||||
<path d="M7 11V8a5 5 0 0 1 10 0v3" />
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
</svg>
|
||||
{isPending ? "…" : "Sign in with passkey"}
|
||||
</button>
|
||||
{passkeyError && (
|
||||
<p className="font-mono text-[11px] text-red-600" role="alert">
|
||||
{passkeyError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-zinc-200" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-zinc-400">
|
||||
or
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-zinc-200" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`flex flex-col gap-3 ${showPasskey ? "" : "mt-6"}`}
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Agent ID
|
||||
</span>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus={pointerDevice}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Password
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 pr-10 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={isPending}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
aria-pressed={showPassword}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-center px-3 text-zinc-400 transition-colors hover:text-zinc-700 focus:outline-none focus-visible:text-zinc-700 disabled:opacity-60"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a19.5 19.5 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 7 11 7a19.6 19.6 0 0 1-3.17 4.19" />
|
||||
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !username || !password}
|
||||
className={`mt-2 inline-flex items-center justify-center rounded-full px-4 py-2.5 text-xs font-medium transition-colors disabled:opacity-60 ${
|
||||
showPasskey
|
||||
? "bg-zinc-100 text-zinc-900 hover:bg-zinc-200"
|
||||
: "bg-zinc-900 text-white hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{isPending ? "…" : "Sign in"}
|
||||
</button>
|
||||
|
||||
{formError && (
|
||||
<p className="font-mono text-[11px] text-red-600" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2 rounded-xl bg-emerald-50 px-3 py-2 ring-1 ring-emerald-100">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<p className="text-[11px] text-emerald-900/80">
|
||||
Forgot the password? Please contact IT.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { hasPasskeysForLogin } from "@/app/auth-actions";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import AuthForm from "./auth-form";
|
||||
|
||||
type SearchParams = { next?: string };
|
||||
|
||||
export default async function AuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const dest = (await searchParams).next ?? "/";
|
||||
redirect(dest);
|
||||
}
|
||||
const passkeysAvailable = await hasPasskeysForLogin();
|
||||
const next = (await searchParams).next ?? "/";
|
||||
return <AuthForm passkeysAvailable={passkeysAvailable} next={next} />;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -2,7 +2,6 @@ import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Nav from "@/components/nav";
|
||||
import AutoRefresh from "@/components/auto-refresh";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CM Bot V2",
|
||||
@ -11,19 +10,22 @@ export const metadata: Metadata = {
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#18181b",
|
||||
// Lets the page draw under the iPhone notch / Dynamic Island when the
|
||||
// PWA runs in standalone mode. Components that pin to the edges (Nav,
|
||||
// Toast) read env(safe-area-inset-*) to keep their content out of the
|
||||
// hardware cutouts.
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-zinc-50 text-zinc-900 antialiased">
|
||||
{session && <Nav username={session.username} />}
|
||||
<Nav />
|
||||
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@ -11,12 +11,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
background_color: "#fafafa",
|
||||
theme_color: "#18181b",
|
||||
icons: [
|
||||
// Trailing slash on /icon/ and /apple-icon/ matches the canonical URL
|
||||
// Next.js serves under `trailingSlash: true`. Without the slash the
|
||||
// browser would hit a 308 redirect, then the gated /icon/ path, then
|
||||
// get HTML back instead of the PNG.
|
||||
{ src: "/icon/", sizes: "any", type: "image/png" },
|
||||
{ src: "/apple-icon/", sizes: "180x180", type: "image/png" },
|
||||
{ src: "/icon", sizes: "any", type: "image/png" },
|
||||
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -195,15 +195,17 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-middle">
|
||||
<EditableCell
|
||||
value={row.status}
|
||||
label={`status for ${row.username}`}
|
||||
isCurrentlyEditing={editingKey === k("status")}
|
||||
onEditStart={() => setEditingKey(k("status"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.username, "status", v)}
|
||||
renderView={(v) => <StatusBadge status={v} />}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={row.status} />
|
||||
<EditableCell
|
||||
value={row.status}
|
||||
label={`status for ${row.username}`}
|
||||
isCurrentlyEditing={editingKey === k("status")}
|
||||
onEditStart={() => setEditingKey(k("status"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.username, "status", v)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-middle">
|
||||
<EditableCell
|
||||
@ -237,30 +239,20 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
const k = (f: string) => `${row.username}::${f}`;
|
||||
return (
|
||||
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
|
||||
{row.username}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<EditableCell
|
||||
value={row.status}
|
||||
label={`status for ${row.username}`}
|
||||
isCurrentlyEditing={editingKey === k("status")}
|
||||
onEditStart={() => setEditingKey(k("status"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.username, "status", v)}
|
||||
renderView={(v) => <StatusBadge status={v} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-base font-semibold text-zinc-900">
|
||||
{row.username}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={row.status} />
|
||||
<DeleteButton
|
||||
label={row.username}
|
||||
onClick={() => {
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(row.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeleteButton
|
||||
label={row.username}
|
||||
onClick={() => {
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(row.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||||
<CardRow label="Password">
|
||||
@ -273,6 +265,16 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
onSave={(v) => saveCell(row.username, "password", v)}
|
||||
/>
|
||||
</CardRow>
|
||||
<CardRow label="Status">
|
||||
<EditableCell
|
||||
value={row.status}
|
||||
label={`status for ${row.username}`}
|
||||
isCurrentlyEditing={editingKey === k("status")}
|
||||
onEditStart={() => setEditingKey(k("status"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.username, "status", v)}
|
||||
/>
|
||||
</CardRow>
|
||||
<CardRow label="Link">
|
||||
<EditableCell
|
||||
value={row.link}
|
||||
|
||||
@ -41,19 +41,6 @@ export default function ConfirmDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Lock body scroll while open. Native <dialog> doesn't do this in all
|
||||
// browsers (notably iOS Safari), so background scroll can leak through
|
||||
// when scrolling on the dialog content. We restore the previous value
|
||||
// on close so other modals stacking later don't regress this.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
|
||||
@ -9,12 +9,6 @@ type EditableCellProps = {
|
||||
isCurrentlyEditing?: boolean;
|
||||
onEditStart?: () => void;
|
||||
onEditEnd?: () => void;
|
||||
/**
|
||||
* Override how the value is rendered in view mode. Use this to show
|
||||
* something other than plain text (e.g., a status pill) — clicking
|
||||
* the rendered element still starts edit mode.
|
||||
*/
|
||||
renderView?: (value: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
export default function EditableCell({
|
||||
@ -24,7 +18,6 @@ export default function EditableCell({
|
||||
isCurrentlyEditing,
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
renderView,
|
||||
}: EditableCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
@ -82,14 +75,10 @@ export default function EditableCell({
|
||||
type="button"
|
||||
onClick={begin}
|
||||
aria-label={label ? `Edit ${label}` : undefined}
|
||||
className="group flex w-full min-w-0 items-center gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
|
||||
className="group flex w-full min-w-0 items-start gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 ${renderView ? "" : "break-all"}`}
|
||||
>
|
||||
{renderView
|
||||
? renderView(value)
|
||||
: value || <em className="not-italic text-zinc-400">—</em>}
|
||||
<span className="min-w-0 flex-1 break-all">
|
||||
{value || <em className="not-italic text-zinc-400">—</em>}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@ -40,16 +40,6 @@ export default function FormDialogShell({
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
// Lock body scroll while open (native <dialog> doesn't on iOS Safari).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
|
||||
@ -2,28 +2,25 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { logout } from "@/app/auth-actions";
|
||||
|
||||
type Props = { username: string };
|
||||
|
||||
export default function Nav({ username }: Props) {
|
||||
export default function Nav() {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const isUsers = pathname.startsWith("/users");
|
||||
const isAccounts = !isUsers;
|
||||
const initial = (username[0] ?? "?").toUpperCase();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/80 backdrop-blur-md"
|
||||
style={{
|
||||
// Push content below the iPhone notch when the PWA is installed.
|
||||
// No-op on browsers without a notch (env() resolves to 0).
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
paddingLeft: "env(safe-area-inset-left)",
|
||||
paddingRight: "env(safe-area-inset-right)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-4 sm:gap-4 sm:px-6">
|
||||
<Link href="/" className="group flex shrink-0 items-center gap-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<Link href="/" className="group flex items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
|
||||
CM
|
||||
</span>
|
||||
@ -37,20 +34,17 @@ export default function Nav({ username }: Props) {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="flex items-center gap-1 rounded-full bg-zinc-100 p-1"
|
||||
>
|
||||
<NavLink href="/" active={isAccounts}>
|
||||
Accounts
|
||||
</NavLink>
|
||||
<NavLink href="/users" active={isUsers}>
|
||||
Users
|
||||
</NavLink>
|
||||
</nav>
|
||||
<AccountMenu username={username} initial={initial} />
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="flex items-center gap-1 rounded-full bg-zinc-100 p-1"
|
||||
>
|
||||
<NavLink href="/" active={isAccounts}>
|
||||
Accounts
|
||||
</NavLink>
|
||||
<NavLink href="/users" active={isUsers}>
|
||||
Users
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@ -79,77 +73,3 @@ function NavLink({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMenu({
|
||||
username,
|
||||
initial,
|
||||
}: {
|
||||
username: string;
|
||||
initial: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!wrapperRef.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-2 rounded-full px-2 py-1 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-900 text-[10px] font-semibold text-white">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="hidden max-w-[10rem] truncate sm:inline">
|
||||
{username}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-[calc(100%+0.5rem)] z-20 w-48 overflow-hidden rounded-xl bg-white py-1 shadow-lg ring-1 ring-zinc-200/60"
|
||||
>
|
||||
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-zinc-400 sm:hidden">
|
||||
{username}
|
||||
</div>
|
||||
{/*
|
||||
No onClick to close the menu — the click would trigger setOpen
|
||||
(which unmounts the form on next render) and the form submit
|
||||
in parallel; React tears down the form before the POST flushes
|
||||
and sign-out silently no-ops. The Server Action redirects to
|
||||
/cm-auth on success, which navigates away and tears the menu
|
||||
down naturally.
|
||||
*/}
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-red-600"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
import "server-only";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export type RpInfo = {
|
||||
rpID: string;
|
||||
origin: string;
|
||||
rpName: string;
|
||||
};
|
||||
|
||||
export async function getRpInfo(): Promise<RpInfo> {
|
||||
const hdrs = await headers();
|
||||
const host = hdrs.get("x-forwarded-host") ?? hdrs.get("host") ?? "localhost:8010";
|
||||
const rpID = host.split(":")[0];
|
||||
const proto = hdrs.get("x-forwarded-proto") ?? "http";
|
||||
const origin = `${proto}://${host}`;
|
||||
return {
|
||||
rpID,
|
||||
origin,
|
||||
rpName: "CM Bot V2",
|
||||
};
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import "server-only";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/types";
|
||||
|
||||
const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json";
|
||||
|
||||
export type PasskeyRecord = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports: AuthenticatorTransportFuture[];
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type StoreShape = Record<string, PasskeyRecord[]>;
|
||||
|
||||
let writeLock: Promise<void> = Promise.resolve();
|
||||
|
||||
async function readAll(): Promise<StoreShape> {
|
||||
try {
|
||||
const raw = await fs.readFile(FILE_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as StoreShape;
|
||||
}
|
||||
return {};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtomic(data: StoreShape): Promise<void> {
|
||||
const dir = path.dirname(FILE_PATH);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${FILE_PATH}.tmp`;
|
||||
const handle = await fs.open(tmp, "w");
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(data, null, 2));
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(tmp, FILE_PATH);
|
||||
}
|
||||
|
||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = writeLock.then(fn, fn);
|
||||
writeLock = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function readPasskeys(username: string): Promise<PasskeyRecord[]> {
|
||||
const all = await readAll();
|
||||
return all[username] ?? [];
|
||||
}
|
||||
|
||||
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> {
|
||||
await withLock(async () => {
|
||||
const all = await readAll();
|
||||
const list = all[username] ?? [];
|
||||
list.push(rec);
|
||||
all[username] = list;
|
||||
await writeAtomic(all);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePasskey(username: string, credentialId: string): Promise<boolean> {
|
||||
return withLock(async () => {
|
||||
const all = await readAll();
|
||||
const list = all[username] ?? [];
|
||||
const next = list.filter((p) => p.id !== credentialId);
|
||||
if (next.length === list.length) return false;
|
||||
all[username] = next;
|
||||
await writeAtomic(all);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function bumpCounter(
|
||||
username: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
): Promise<void> {
|
||||
await withLock(async () => {
|
||||
const all = await readAll();
|
||||
const list = all[username] ?? [];
|
||||
const idx = list.findIndex((p) => p.id === credentialId);
|
||||
if (idx === -1) return;
|
||||
list[idx] = { ...list[idx], counter };
|
||||
all[username] = list;
|
||||
await writeAtomic(all);
|
||||
});
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { sealData, unsealData } from "iron-session";
|
||||
|
||||
const COOKIE_NAME = "cm_auth";
|
||||
const COOKIE_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
export type Session = {
|
||||
username: string;
|
||||
authenticatedAt: number;
|
||||
pendingChallenge?: {
|
||||
kind: "register" | "authenticate";
|
||||
challenge: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.CM_AUTH_SECRET;
|
||||
if (!s || s.length < 32) {
|
||||
throw new Error("CM_AUTH_SECRET missing or shorter than 32 chars");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const jar = await cookies();
|
||||
const raw = jar.get(COOKIE_NAME)?.value;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return await unsealData<Session>(raw, { password: secret() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSession(session: Session): Promise<void> {
|
||||
const sealed = await sealData(session, {
|
||||
password: secret(),
|
||||
ttl: COOKIE_TTL_SECONDS,
|
||||
});
|
||||
const jar = await cookies();
|
||||
jar.set(COOKIE_NAME, sealed, {
|
||||
httpOnly: true,
|
||||
secure: process.env.CM_DEBUG !== "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: COOKIE_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<void> {
|
||||
const jar = await cookies();
|
||||
jar.delete(COOKIE_NAME);
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<Session> {
|
||||
const s = await getSession();
|
||||
if (!s) throw new Error("Unauthenticated");
|
||||
return s;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { unsealData } from "iron-session";
|
||||
|
||||
const COOKIE_NAME = "cm_auth";
|
||||
const PUBLIC_PATHS = new Set<string>(["/cm-auth"]);
|
||||
|
||||
type SessionShape = {
|
||||
username: string;
|
||||
authenticatedAt: number;
|
||||
};
|
||||
|
||||
async function isAuthenticated(req: NextRequest): Promise<boolean> {
|
||||
const raw = req.cookies.get(COOKIE_NAME)?.value;
|
||||
if (!raw) return false;
|
||||
const secret = process.env.CM_AUTH_SECRET;
|
||||
if (!secret || secret.length < 32) return false;
|
||||
try {
|
||||
const session = await unsealData<SessionShape>(raw, { password: secret });
|
||||
return Boolean(session?.username);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
if (PUBLIC_PATHS.has(normalized)) return NextResponse.next();
|
||||
|
||||
if (await isAuthenticated(req)) return NextResponse.next();
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/cm-auth";
|
||||
url.searchParams.set("next", path);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// next.config.ts sets `trailingSlash: true`, so /icon redirects to /icon/.
|
||||
// The icon$/apple-icon$ alternatives below allow the optional slash so the
|
||||
// canonical (slashed) URL bypasses the auth gate too — otherwise the
|
||||
// browser hits the redirect, follows it to the slashed form, and the gate
|
||||
// refuses to serve the image and bounces to /cm-auth.
|
||||
matcher: [
|
||||
"/((?!_next|icon/?$|apple-icon/?$|manifest.webmanifest|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
@ -9,10 +9,6 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^11.0.0",
|
||||
"@simplewebauthn/server": "^11.0.0",
|
||||
"@simplewebauthn/types": "^11.0.0",
|
||||
"iron-session": "^8.0.0",
|
||||
"next": "15.1.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user