feat: add demo mode and ALTCHA integration for better security for internet deployments and showcase functionality

This commit is contained in:
Ivan 2026-07-26 07:06:45 -05:00
parent 5745d53202
commit 22b4fcce09
No known key found for this signature in database
51 changed files with 1201 additions and 110 deletions

63
docker-compose.demo.yml Normal file
View file

@ -0,0 +1,63 @@
# Coolify public demo stack (read-only mesh showcase, ALTCHA, session auth).
# https://coolify.io/docs/knowledge-base/docker/compose
#
# Set MESHCHAT_ALTCHA_HMAC_KEY in Coolify before deploy (required below).
# Showcase login password defaults to demo (override MESHCHAT_DEMO_AUTH_PASSWORD).
#
# Assign a domain in Coolify with container port 8000, for example:
# https://meshchatx.example.com:8000
# The :8000 suffix is the container port for the proxy, not the public URL port.
#
# Coolify terminates TLS at the proxy. Plain HTTP inside the container
# (MESHCHAT_NO_HTTPS=1) avoids a self-signed HTTPS backend. Do not add host
# ports unless you intend to bypass the proxy.
services:
meshchatx:
image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest}
restart: unless-stopped
init: true
user: "1000:1000"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=256m
- /home/meshchat:nosuid,size=64m
environment:
- SERVICE_URL_MESHCHATX_8000
- MESHCHAT_DEMO_MODE=${MESHCHAT_DEMO_MODE:-1}
- MESHCHAT_AUTH=${MESHCHAT_AUTH:-1}
- MESHCHAT_AUTH_BYPASS=${MESHCHAT_AUTH_BYPASS:-0}
- MESHCHAT_ALTCHA_ENABLED=${MESHCHAT_ALTCHA_ENABLED:-1}
- MESHCHAT_ALTCHA_HMAC_KEY=${MESHCHAT_ALTCHA_HMAC_KEY:?}
- MESHCHAT_DEMO_AUTH_PASSWORD=${MESHCHAT_DEMO_AUTH_PASSWORD:-demo}
- "MESHCHAT_AUTH_PAGE_HINT=${MESHCHAT_AUTH_PAGE_HINT:-Username: demo, Password: demo}"
- MESHCHAT_DISABLE_PLUGINS=${MESHCHAT_DISABLE_PLUGINS:-1}
- MESHCHAT_HOST=${MESHCHAT_HOST:-0.0.0.0}
- MESHCHAT_PORT=${MESHCHAT_PORT:-8000}
- MESHCHAT_NO_HTTPS=${MESHCHAT_NO_HTTPS:-1}
- MESHCHAT_HEADLESS=${MESHCHAT_HEADLESS:-1}
volumes:
- meshchatx-config:/config
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status')",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
cpus: 2.0
mem_limit: 1g
mem_reservation: 256m
pids_limit: 512
volumes:
meshchatx-config:

View file

@ -36,6 +36,16 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e
Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
### Demo mode and ALTCHA
`MESHCHAT_DEMO_MODE=1` (or `--demo`) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports `demo_mode: true`.
When `MESHCHAT_ALTCHA_ENABLED=1`, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use `PBKDF2/SHA-256` by default). Set `MESHCHAT_ALTCHA_HMAC_KEY` to a long random secret on the server. Optional `MESHCHAT_ALTCHA_COST` tunes PoW difficulty. The widget loads from the bundled `altcha` npm package and fetches challenges from `/api/v1/auth/altcha/challenge`.
`MESHCHAT_AUTH_PAGE_HINT` sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account.
`MESHCHAT_AUTH_BYPASS=1` skips session auth for local testing only. Do not use it on internet-facing deployments.
## Transport security
- HTTPS and WSS are on by default.

View file

@ -61,6 +61,17 @@ Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the containe
To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
### Public demo instance (Coolify)
For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [`docker-compose.demo.yml`](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml).
- `MESHCHAT_DEMO_MODE=1` blocks outbound mesh actions and almost all API mutations.
- `MESHCHAT_AUTH=1` with default showcase password `demo` (`MESHCHAT_DEMO_AUTH_PASSWORD`).
- Optional `MESHCHAT_AUTH_PAGE_HINT` shows custom text on the login page (for example `Username: demo` and `Password: demo`). Demo compose sets a default hint.
- `MESHCHAT_ALTCHA_ENABLED=1` and a strong `MESHCHAT_ALTCHA_HMAC_KEY` (required in demo compose via `:?`). The UI uses ALTCHA widget v3 with `PBKDF2/SHA-256` challenges from `/api/v1/auth/altcha/challenge`.
- Assign a domain with container port **8000**, for example `https://meshchatx.example.com:8000`.
- Do not set `MESHCHAT_AUTH_BYPASS=1` on a public host.
## Python wheel
1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).

Binary file not shown.

View file

@ -81,6 +81,12 @@ from meshchatx.src.backend.csrf import (
rotate_session_csrf_token,
validate_csrf_header,
)
from meshchatx.src.backend.altcha_auth import altcha_enabled_from_env
from meshchatx.src.backend.auth_page_hint import auth_page_hint_from_env
from meshchatx.src.backend.demo_mode import (
auth_bypass_from_env,
demo_auth_password_from_env,
)
from meshchatx.src.backend.database.access_attempts import (
LOGIN_PATH,
MAX_FAILED_BEFORE_LOCKOUT,
@ -560,9 +566,14 @@ class ReticulumMeshChat:
plugins_enabled: bool = True,
defer_network_setup: bool = False,
headless: bool = False,
demo_mode: bool = False,
altcha_enabled: bool = False,
):
self.running = True
self.plugins_enabled = plugins_enabled
self.demo_mode = bool(demo_mode)
self.altcha_enabled = bool(altcha_enabled)
self.auth_page_hint = auth_page_hint_from_env()
self._memory_diag_enabled = memory_diag_enabled
self._mem_diag = None
self._headless = bool(headless)
@ -1034,6 +1045,8 @@ class ReticulumMeshChat:
@property
def auth_enabled(self):
if auth_bypass_from_env():
return False
if self.config:
return self.config.auth_enabled.get()
return self.auth_enabled_initial
@ -1682,6 +1695,11 @@ class ReticulumMeshChat:
self._sideband_telemetry_running = False
def _startup_status_payload(self) -> dict:
demo_fields = {
"demo_mode": self.demo_mode,
"altcha_enabled": self.altcha_enabled,
"auth_page_hint": self.auth_page_hint,
}
if self._startup_stage == "failed" or self._startup_error:
payload = {
"status": "failed",
@ -1694,6 +1712,7 @@ class ReticulumMeshChat:
"https_enabled": self.use_https,
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
"plugins_enabled": self.plugins_enabled,
**demo_fields,
**self._landlock_status_dict(),
}
if self._startup_error:
@ -1716,6 +1735,7 @@ class ReticulumMeshChat:
"https_enabled": self.use_https,
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
"plugins_enabled": self.plugins_enabled,
**demo_fields,
**self._landlock_status_dict(),
}
@ -1831,6 +1851,7 @@ class ReticulumMeshChat:
)
if self._network_ready:
self._finish_deferred_startup_services()
self._apply_demo_mode_runtime()
return
# Initialize Reticulum if not already done
@ -1881,6 +1902,27 @@ class ReticulumMeshChat:
if self._network_ready:
self._finish_deferred_startup_services()
self._apply_demo_mode_runtime()
def _apply_demo_mode_runtime(self) -> None:
if not self.demo_mode:
return
ctx = self.current_context
if not ctx or not ctx.config:
return
self.plugins_enabled = False
ctx.config.privacy_mode_enabled.set(True)
ctx.config.auto_announce_enabled.set(False)
if self.auth_enabled_initial:
ctx.config.auth_enabled.set(True)
if self.auth_enabled and ctx.config.auth_password_hash.get() is None:
password = demo_auth_password_from_env()
password_hash = bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
ctx.config.auth_password_hash.set(password_hash)
def _finish_deferred_startup_services(self) -> None:
"""Start non-critical services after network_ready is published."""
context = self.current_context
@ -5106,6 +5148,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
demo_mode_middleware,
) = register_all_routes(routes, self)
return (
@ -5114,6 +5157,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
demo_mode_middleware,
)
def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage:
@ -5215,6 +5259,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
demo_mode_middleware,
) = self._define_routes(routes)
ssl_context = None
@ -5324,6 +5369,7 @@ class ReticulumMeshChat:
security_middleware,
csrf_middleware,
ip_allowlist_middleware,
demo_mode_middleware,
],
)
@ -5521,6 +5567,8 @@ class ReticulumMeshChat:
# handle announcing
async def announce(self, context=None):
if self.demo_mode:
return
ctx = context or self.current_context
if not ctx:
return
@ -10386,6 +10434,12 @@ def main():
default=env_bool("MESHCHAT_AUTH", False),
help="Enable basic authentication for the web interface. Can also be set via MESHCHAT_AUTH environment variable.",
)
parser.add_argument(
"--demo",
action="store_true",
default=env_bool("MESHCHAT_DEMO_MODE", False),
help="Public demo mode: read-only mesh and blocked API mutations. Can also be set via MESHCHAT_DEMO_MODE environment variable.",
)
parser.add_argument(
"--no-https",
action="store_true",
@ -10648,6 +10702,16 @@ def main():
or args.restore_db
or args.restore_from_snapshot,
)
if auth_bypass_from_env():
print(
"WARNING: MESHCHAT_AUTH_BYPASS=1 disables web UI authentication",
file=sys.stderr,
flush=True,
)
demo_mode = bool(args.demo)
altcha_on = altcha_enabled_from_env()
reticulum_meshchat = ReticulumMeshChat(
identity,
args.storage_dir,
@ -10663,9 +10727,11 @@ def main():
rns_loglevel=rns_log_cli,
migration_context=migration_context,
memory_diag_enabled=args.memory_diag,
plugins_enabled=not args.disable_plugins,
plugins_enabled=(not args.disable_plugins) and not demo_mode,
defer_network_setup=not needs_immediate_network,
headless=bool(args.headless),
demo_mode=demo_mode,
altcha_enabled=altcha_on,
)
# store recovery on app for wiring with identity context

View file

@ -0,0 +1,95 @@
# SPDX-License-Identifier: 0BSD
"""ALTCHA proof-of-work verification for login and setup."""
from __future__ import annotations
import os
import time
from typing import Any
import altcha
from aiohttp import web
from meshchatx.src.env_utils import env_bool
ALTCHA_INVALID_CODE = "altcha_invalid"
ALTCHA_ALGORITHM = "PBKDF2/SHA-256"
ALTCHA_DEFAULT_COST = 2000
def altcha_enabled_from_env() -> bool:
return env_bool("MESHCHAT_ALTCHA_ENABLED", False)
def altcha_hmac_secret() -> str | None:
raw = os.environ.get("MESHCHAT_ALTCHA_HMAC_KEY", "").strip()
return raw or None
def altcha_configured() -> bool:
return altcha_enabled_from_env() and bool(altcha_hmac_secret())
def altcha_pow_cost() -> int:
raw = os.environ.get("MESHCHAT_ALTCHA_COST", "").strip()
if not raw:
return ALTCHA_DEFAULT_COST
try:
return max(100, int(raw))
except ValueError:
return ALTCHA_DEFAULT_COST
def create_altcha_challenge_dict() -> dict[str, Any]:
secret = altcha_hmac_secret()
if not secret:
msg = "MESHCHAT_ALTCHA_HMAC_KEY is required when ALTCHA is enabled"
raise RuntimeError(msg)
expires_at = int(time.time()) + 300
challenge = altcha.create_challenge(
ALTCHA_ALGORITHM,
altcha_pow_cost(),
hmac_secret=secret,
expires_at=expires_at,
)
return challenge.to_dict()
def verify_altcha_submission(payload: Any) -> tuple[bool, str | None]:
secret = altcha_hmac_secret()
if not secret:
return False, "altcha_not_configured"
if payload is None:
return False, ALTCHA_INVALID_CODE
if isinstance(payload, dict):
import json
payload = json.dumps(payload)
if not isinstance(payload, str) or not payload.strip():
return False, ALTCHA_INVALID_CODE
try:
result = altcha.verify_solution(payload.strip(), secret)
except Exception:
return False, ALTCHA_INVALID_CODE
if not result.verified:
err = result.error or ALTCHA_INVALID_CODE
return False, err
return True, None
def altcha_error_response(code: str) -> web.Response:
return web.json_response(
{"error": "ALTCHA verification failed", "code": code},
status=400,
)
async def require_altcha_payload(request, data: dict) -> web.Response | None:
if not altcha_enabled_from_env():
return None
payload = data.get("altcha")
ok, code = verify_altcha_submission(payload)
if not ok:
return altcha_error_response(code or ALTCHA_INVALID_CODE)
return None

View file

@ -0,0 +1,12 @@
# SPDX-License-Identifier: 0BSD
"""Optional login page hint text from environment."""
from __future__ import annotations
import os
def auth_page_hint_from_env() -> str | None:
raw = os.environ.get("MESHCHAT_AUTH_PAGE_HINT", "").strip()
return raw or None

View file

@ -0,0 +1,100 @@
# SPDX-License-Identifier: 0BSD
"""Public demo mode: read-only mesh and default-deny HTTP mutations."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from aiohttp import web
from meshchatx.src.env_utils import env_bool
if TYPE_CHECKING:
from meshchatx.meshchat import ReticulumMeshChat
DEMO_READONLY_CODE = "demo_readonly"
DEMO_HTTP_MUTATION_ALLOWLIST: frozenset[str] = frozenset(
{
"/api/v1/auth/login",
"/api/v1/auth/logout",
"/api/v1/auth/setup",
"/api/v1/app/tutorial/seen",
"/api/v1/app/changelog/seen",
},
)
def demo_mode_from_env() -> bool:
return env_bool("MESHCHAT_DEMO_MODE", False)
def auth_bypass_from_env() -> bool:
return env_bool("MESHCHAT_AUTH_BYPASS", False)
def demo_auth_password_from_env() -> str:
return os.environ.get("MESHCHAT_DEMO_AUTH_PASSWORD", "demo")
def normalize_api_path(path: str) -> str:
if not path.startswith("/api/"):
return path
parts = [p for p in path.split("/") if p]
return "/" + "/".join(parts)
def demo_mode_active(app: ReticulumMeshChat) -> bool:
return bool(getattr(app, "demo_mode", False))
def demo_mode_blocks_ws_type(app: ReticulumMeshChat, msg_type: str) -> bool:
if not demo_mode_active(app):
return False
from meshchatx.src.backend.websocket_config_guard import WEBSOCKET_MUTATOR_TYPES
return msg_type in WEBSOCKET_MUTATOR_TYPES
def demo_mode_block_response(app: ReticulumMeshChat) -> web.Response | None:
if not demo_mode_active(app):
return None
return web.json_response(
{
"error": "Demo mode is read-only",
"code": DEMO_READONLY_CODE,
},
status=403,
)
def demo_http_mutation_allowed(method: str, path: str) -> bool:
if method.upper() in ("GET", "HEAD", "OPTIONS"):
return True
if method.upper() not in ("POST", "PUT", "PATCH", "DELETE"):
return True
normalized = normalize_api_path(path)
if not normalized.startswith("/api/v1/"):
return True
return normalized in DEMO_HTTP_MUTATION_ALLOWLIST
def create_demo_mode_middleware(app: ReticulumMeshChat):
@web.middleware
async def demo_mode_middleware(request, handler):
if demo_mode_active(app) and not demo_http_mutation_allowed(
request.method,
request.path,
):
return web.json_response(
{
"error": "Demo mode is read-only",
"code": DEMO_READONLY_CODE,
},
status=403,
)
return await handler(request)
return demo_mode_middleware

View file

@ -94,7 +94,11 @@ def create_auth_middleware(app):
# Allow CSRF bootstrap and auth status while the network stack starts so the
# Vue shell can load and show an in-app waiting state.
if path in ("/api/v1/auth/csrf", "/api/v1/auth/status"):
if path in (
"/api/v1/auth/csrf",
"/api/v1/auth/status",
"/api/v1/auth/altcha/challenge",
):
return await handler(request)
# Serve the web UI shell and static files while an identity context is still
@ -149,6 +153,7 @@ def create_auth_middleware(app):
"/api/v1/auth/login",
"/api/v1/auth/status",
"/api/v1/auth/logout",
"/api/v1/auth/altcha/challenge",
"/manifest.json",
"/service-worker.js",
]

View file

@ -4,6 +4,7 @@
from __future__ import annotations
from meshchatx.src.backend.demo_mode import create_demo_mode_middleware
from meshchatx.src.backend.http.middleware import (
create_auth_middleware,
create_csrf_middleware,
@ -28,4 +29,5 @@ def register_all_routes(routes, app):
create_security_middleware(app),
create_csrf_middleware(app),
create_ip_allowlist_middleware(app),
create_demo_mode_middleware(app),
)

View file

@ -134,6 +134,18 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
def register_auth_routes(routes, app):
@routes.get("/api/v1/auth/altcha/challenge")
async def auth_altcha_challenge(request):
if not app.altcha_enabled:
return web.json_response({"error": "ALTCHA is not enabled"}, status=404)
try:
from meshchatx.src.backend.altcha_auth import create_altcha_challenge_dict
challenge = create_altcha_challenge_dict()
except RuntimeError as exc:
return web.json_response({"error": str(exc)}, status=503)
return web.json_response(challenge)
@routes.get("/api/v1/server/security")
async def server_security_get(request):
settings = load_app_security_settings(app.storage_dir)
@ -208,6 +220,9 @@ def register_auth_routes(routes, app):
"network_ready": False,
"status": "starting",
"stage": app._startup_stage,
"demo_mode": app.demo_mode,
"altcha_enabled": app.altcha_enabled,
"auth_page_hint": app.auth_page_hint,
},
)
try:
@ -226,6 +241,9 @@ def register_auth_routes(routes, app):
"password_set": app.config.auth_password_hash.get() is not None,
"authenticated": actually_authenticated,
"network_ready": True,
"demo_mode": app.demo_mode,
"altcha_enabled": app.altcha_enabled,
"auth_page_hint": app.auth_page_hint,
},
)
except Exception as e:
@ -242,6 +260,9 @@ def register_auth_routes(routes, app):
"network_ready": bool(
app.current_context and app.current_context.running,
),
"demo_mode": app.demo_mode,
"altcha_enabled": app.altcha_enabled,
"auth_page_hint": app.auth_page_hint,
"error": str(e),
},
)
@ -298,6 +319,11 @@ def register_auth_routes(routes, app):
{"error": "Invalid request body"},
status=400,
)
from meshchatx.src.backend.altcha_auth import require_altcha_payload
altcha_blocked = await require_altcha_payload(request, data)
if altcha_blocked is not None:
return altcha_blocked
password = data.get("password")
if not password or len(password) < 8:
@ -380,6 +406,11 @@ def register_auth_routes(routes, app):
{"error": "Invalid request body"},
status=400,
)
from meshchatx.src.backend.altcha_auth import require_altcha_payload
altcha_blocked = await require_altcha_payload(request, data)
if altcha_blocked is not None:
return altcha_blocked
password = data.get("password")
password_hash = app.config.auth_password_hash.get()

View file

@ -532,6 +532,11 @@ def register_lxmf_routes(routes, app):
# send lxmf message
@routes.post("/api/v1/lxmf-messages/send")
async def lxmf_messages_send(request):
from meshchatx.src.backend.demo_mode import demo_mode_block_response
blocked = demo_mode_block_response(app)
if blocked is not None:
return blocked
# get request body as json
data = await request.json()

View file

@ -4,6 +4,7 @@
from __future__ import annotations
from meshchatx.src.backend.http.live_names import inject_meshchat_names
from meshchatx.src.backend.demo_mode import demo_mode_blocks_ws_type
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
AsyncUtils,
json,
@ -54,6 +55,21 @@ async def dispatch_websocket_data(app, client, data):
if not _type:
return
if demo_mode_blocks_ws_type(app, _type):
logger.warning("Rejected WebSocket mutator in demo mode: %s", _type)
AsyncUtils.run_async(
client.send_str(
json.dumps(
{
"type": "error",
"message": "Demo mode is read-only",
"code": "demo_readonly",
},
),
),
)
return
if websocket_type_requires_auth(_type):
if not await app._websocket_session_authorized(client):
logger.warning("Rejected unauthorized WebSocket mutator: %s", _type)

View file

@ -9,6 +9,8 @@
<AppShellBanners
:show-emergency="Boolean(appInfo?.emergency)"
:emergency-label="$t('app.emergency_mode_active')"
:show-demo="GlobalState.demoMode"
:demo-label="$t('app.demo_mode_active')"
:show-ws-disconnected="showWsDisconnectedBanner"
:ws-disconnected-label="backendOfflineBannerLabel"
:show-backend-recovery-actions="showBackendRecoveryActions"
@ -676,6 +678,7 @@ export default {
const vuetifyTheme = useTheme();
return {
vuetifyTheme,
GlobalState,
};
},
data() {

View file

@ -1,90 +1,116 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="h-dvh min-h-0 w-full flex items-center justify-center bg-slate-50 dark:bg-zinc-950">
<div class="w-full max-w-md p-8">
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
>
<div class="text-center mb-8">
<div
class="w-16 h-16 mx-auto mb-4 rounded-2xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner flex items-center justify-center"
>
<img class="w-16 h-16 object-contain p-2" :src="logoUrl" />
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? "Initial Setup" : "Authentication Required" }}
</h1>
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{
isSetup
? "Set an admin password to secure your MeshChatX instance"
: "Please enter your password to continue"
}}
</p>
</div>
<div class="h-dvh min-h-0 w-full flex flex-col bg-slate-50 dark:bg-zinc-950">
<div
v-if="demoMode"
class="relative z-100 shrink-0 bg-amber-600 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-amber-700/80"
role="status"
>
{{ $t("app.demo_mode_active") }}
</div>
<form class="space-y-6" @submit.prevent="handleSubmit">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
autocomplete="current-password"
/>
<p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
Password must be at least 8 characters long
<div class="flex-1 min-h-0 flex items-center justify-center">
<div class="w-full max-w-md p-8">
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
>
<div class="text-center mb-8">
<div
class="w-16 h-16 mx-auto mb-4 rounded-2xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner flex items-center justify-center"
>
<img class="w-16 h-16 object-contain p-2" :src="logoUrl" alt="" />
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? $t("auth.setup_title") : $t("auth.login_title") }}
</h1>
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{ isSetup ? $t("auth.setup_subtitle") : $t("auth.login_subtitle") }}
</p>
<p
v-if="authPageHint"
class="mt-3 text-xs text-gray-600 dark:text-zinc-400 whitespace-pre-line"
>
{{ authPageHint }}
</p>
</div>
<div v-if="isSetup">
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
<form class="space-y-6" @submit.prevent="handleSubmit">
<div>
<label
for="password"
class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
>
{{ $t("auth.password_label") }}
</label>
<input
id="password"
v-model="password"
type="password"
required
:minlength="isSetup ? 8 : 1"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="$t('auth.password_placeholder')"
autocomplete="current-password"
/>
<p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
{{ $t("auth.password_min_length") }}
</p>
</div>
<div v-if="isSetup">
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
>
{{ $t("auth.confirm_password_label") }}
</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="$t('auth.confirm_password_placeholder')"
autocomplete="new-password"
/>
</div>
<div v-if="altchaEnabled" class="min-h-[52px]">
<altcha-widget
ref="altchaWidget"
:challenge="altchaChallengeUrl"
auto="onsubmit"
name="altcha"
></altcha-widget>
</div>
<div
v-if="error"
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
Confirm Password
</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm password"
autocomplete="new-password"
/>
</div>
<p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<div
v-if="error"
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
type="submit"
:disabled="isLoading || (isSetup && password !== confirmPassword)"
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
>
<span v-if="isLoading">Processing...</span>
<span v-else>{{ isSetup ? "Set Password" : "Login" }}</span>
</button>
</form>
<button
type="submit"
:disabled="isLoading || (isSetup && password !== confirmPassword)"
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
>
<span v-if="isLoading">{{ $t("auth.processing") }}</span>
<span v-else>{{ isSetup ? $t("auth.set_password") : $t("auth.login") }}</span>
</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
import "altcha";
import logoUrl from "../../assets/images/logo.png";
export default {
@ -97,6 +123,10 @@ export default {
error: "",
isLoading: false,
isSetup: false,
altchaEnabled: false,
demoMode: false,
authPageHint: "",
altchaChallengeUrl: "/api/v1/auth/altcha/challenge",
};
},
async mounted() {
@ -119,22 +149,39 @@ export default {
}
this.isSetup = !status.password_set;
this.altchaEnabled = status.altcha_enabled === true;
this.demoMode = status.demo_mode === true;
const hint = status.auth_page_hint;
this.authPageHint = typeof hint === "string" ? hint : "";
} catch (e) {
console.error("Failed to check auth status:", e);
this.error = "Failed to check authentication status";
this.error = this.$t("auth.status_check_failed");
}
},
readAltchaPayload() {
const widget = this.$refs.altchaWidget;
if (!widget) {
return null;
}
if (typeof widget.getPayload === "function") {
return widget.getPayload();
}
if (widget.value) {
return widget.value;
}
return null;
},
async handleSubmit() {
this.error = "";
if (this.isSetup) {
if (this.password !== this.confirmPassword) {
this.error = "Passwords do not match";
this.error = this.$t("auth.passwords_mismatch");
return;
}
if (this.password.length < 8) {
this.error = "Password must be at least 8 characters long";
this.error = this.$t("auth.password_min_length");
return;
}
}
@ -143,13 +190,21 @@ export default {
try {
const endpoint = this.isSetup ? "/api/v1/auth/setup" : "/api/v1/auth/login";
await window.api.post(endpoint, {
password: this.password,
});
const body = { password: this.password };
if (this.altchaEnabled) {
const altchaPayload = this.readAltchaPayload();
if (!altchaPayload) {
this.error = this.$t("auth.altcha_required");
this.isLoading = false;
return;
}
body.altcha = altchaPayload;
}
await window.api.post(endpoint, body);
window.location.reload();
} catch (e) {
this.error = e.response?.data?.error || "Authentication failed";
this.error = e.response?.data?.error || this.$t("auth.failed");
this.password = "";
this.confirmPassword = "";
} finally {

View file

@ -12,6 +12,14 @@
</div>
</div>
<div
v-if="showDemo"
class="relative z-100 bg-amber-600 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-amber-700/80"
role="status"
>
{{ demoLabel }}
</div>
<div
v-if="showWsDisconnected"
class="relative z-100 bg-red-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-red-800/80"
@ -103,6 +111,14 @@ export default {
type: String,
default: "",
},
showDemo: {
type: Boolean,
default: false,
},
demoLabel: {
type: String,
default: "",
},
showWsDisconnected: {
type: Boolean,
default: false,

View file

@ -20,6 +20,7 @@ const globalState = reactive({
networkDegradedError: null,
networkStarting: false,
networkReady: true,
demoMode: false,
config: {
show_unknown_contact_banner: true,
banished_effect_enabled: true,

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: 0BSD
export function isDemoReadonly(status) {
return Boolean(status && status.demo_mode === true);
}
export function isAltchaEnabled(status) {
return Boolean(status && status.altcha_enabled === true);
}

View file

@ -343,6 +343,7 @@
"loading_overlay_title": "Profil wird gewechselt",
"loading_overlay_subtitle": "Chats und Einstellungen werden geladen. Das dauert meist nur wenige Sekunden.",
"emergency_mode_active": "Notfallmodus aktiv - In-Memory-Datenbank und eingeschränkte Dienste werden verwendet.",
"demo_mode_active": "Demo-Modus: Schreibgeschützte Mesh-Demo. Senden und Einstellungsänderungen sind deaktiviert.",
"backend_disconnected": "Verbindung zum Backend getrennt",
"backend_process_stopped": "Reticulum-Backend gestoppt",
"backend_reconnected": "Wieder mit dem Backend verbunden",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Ersteinrichtung",
"login_title": "Anmeldung erforderlich",
"setup_subtitle": "Legen Sie ein Admin-Passwort fest, um Ihre MeshChatX-Instanz zu schützen",
"login_subtitle": "Bitte geben Sie Ihr Passwort ein, um fortzufahren",
"password_label": "Passwort",
"password_placeholder": "Passwort eingeben",
"password_min_length": "Das Passwort muss mindestens 8 Zeichen lang sein",
"confirm_password_label": "Passwort bestätigen",
"confirm_password_placeholder": "Passwort bestätigen",
"processing": "Wird verarbeitet...",
"set_password": "Passwort festlegen",
"login": "Anmelden",
"passwords_mismatch": "Passwörter stimmen nicht überein",
"altcha_required": "Schließen Sie zuerst die Verifizierungsaufgabe ab",
"status_check_failed": "Authentifizierungsstatus konnte nicht geprüft werden",
"failed": "Authentifizierung fehlgeschlagen"
},
"post_install": {
"windows_screen_security_title": "Windows-Bildschirmsicherheit aktivieren?",
"windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen -> Datenschutz.",

View file

@ -432,6 +432,7 @@
"loading_overlay_title": "Switching profile",
"loading_overlay_subtitle": "Loading your chats and settings. This usually takes a few seconds.",
"emergency_mode_active": "Emergency Mode Active - Using in-memory database and restricted services.",
"demo_mode_active": "Demo mode: read-only mesh showcase. Sending and settings changes are disabled.",
"backend_disconnected": "Disconnected from backend",
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
@ -4034,6 +4035,24 @@
"action_changelog": "Changelog",
"action_changelog_desc": "Recent changes"
},
"auth": {
"setup_title": "Initial Setup",
"login_title": "Authentication Required",
"setup_subtitle": "Set an admin password to secure your MeshChatX instance",
"login_subtitle": "Please enter your password to continue",
"password_label": "Password",
"password_placeholder": "Enter password",
"password_min_length": "Password must be at least 8 characters long",
"confirm_password_label": "Confirm Password",
"confirm_password_placeholder": "Confirm password",
"processing": "Processing...",
"set_password": "Set Password",
"login": "Login",
"passwords_mismatch": "Passwords do not match",
"altcha_required": "Complete the verification challenge first",
"status_check_failed": "Failed to check authentication status",
"failed": "Authentication failed"
},
"post_install": {
"windows_screen_security_title": "Enable Windows screen security?",
"windows_screen_security_desc": "MeshChatX can use Windows content protection (the same DRM-style display flag media apps use) so screenshots, recorders, and Windows Recall cannot capture the app window. You can change this later under Settings -> Privacy.",

View file

@ -370,6 +370,7 @@
"loading_overlay_title": "Perfil de conmutación",
"loading_overlay_subtitle": "Carga tus chats y configuraciones. Esto normalmente lleva unos segundos.",
"emergency_mode_active": "Modo de Emergencia Activo - Utilizar bases de datos en memoria y servicios restringidos.",
"demo_mode_active": "Modo demo: vitrina de malla de solo lectura. El envío y los cambios de configuración están deshabilitados.",
"backend_disconnected": "Desconectado desde el backend",
"backend_process_stopped": "Backend de Reticulum detenido",
"backend_reconnected": "Reconectado al backend",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Configuración inicial",
"login_title": "Autenticación requerida",
"setup_subtitle": "Establezca una contraseña de administrador para proteger su instancia de MeshChatX",
"login_subtitle": "Introduzca su contraseña para continuar",
"password_label": "Contraseña",
"password_placeholder": "Introducir contraseña",
"password_min_length": "La contraseña debe tener al menos 8 caracteres",
"confirm_password_label": "Confirmar contraseña",
"confirm_password_placeholder": "Confirmar contraseña",
"processing": "Procesando...",
"set_password": "Establecer contraseña",
"login": "Iniciar sesión",
"passwords_mismatch": "Las contraseñas no coinciden",
"altcha_required": "Complete primero el desafío de verificación",
"status_check_failed": "No se pudo comprobar el estado de autenticación",
"failed": "Error de autenticación"
},
"post_install": {
"windows_screen_security_title": "¿Activar la seguridad de pantalla de Windows?",
"windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes -> Privacidad.",

View file

@ -378,6 +378,7 @@
"loading_overlay_title": "Vaihdetaan profiilia",
"loading_overlay_subtitle": "Ladataan viestejä ja asetuksia. Tässä menee yleensä muutama sekunti.",
"emergency_mode_active": "Hätätila käynnissä - Käytetään muistinsisäistä tietokantaa ja rajoitettuja palveluja.",
"demo_mode_active": "Demotila: vain luku -verkkoesittely. Lähetys ja asetusten muutokset on poistettu käytöstä.",
"backend_disconnected": "Irrotettu taustapalvelusta",
"backend_process_stopped": "Reticulum-taustapalvelu pysäytetty",
"backend_reconnected": "Liitetty uudelleen taustapalveluun",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Alkuasennus",
"login_title": "Tunnistautuminen vaaditaan",
"setup_subtitle": "Aseta ylläpitäjän salasana MeshChatX-instanssin suojaamiseksi",
"login_subtitle": "Anna salasana jatkaaksesi",
"password_label": "Salasana",
"password_placeholder": "Anna salasana",
"password_min_length": "Salasanan on oltava vähintään 8 merkkiä",
"confirm_password_label": "Vahvista salasana",
"confirm_password_placeholder": "Vahvista salasana",
"processing": "Käsitellään...",
"set_password": "Aseta salasana",
"login": "Kirjaudu",
"passwords_mismatch": "Salasanat eivät täsmää",
"altcha_required": "Suorita ensin vahvistustehtävä",
"status_check_failed": "Tunnistautumistilan tarkistus epäonnistui",
"failed": "Tunnistautuminen epäonnistui"
},
"post_install": {
"windows_screen_security_title": "Otetaanko Windowsin näytön suojaus käyttöön?",
"windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset -> Tietosuoja.",

View file

@ -370,6 +370,7 @@
"loading_overlay_title": "Profil de commutation",
"loading_overlay_subtitle": "Chargement de vos chats et paramètres. Cela prend généralement quelques secondes.",
"emergency_mode_active": "Mode d'urgence actif - Utilisation de la base de données en mémoire et des services restreints.",
"demo_mode_active": "Mode démo : vitrine mesh en lecture seule. L'envoi et les changements de paramètres sont désactivés.",
"backend_disconnected": "Déconnecté du moteur",
"backend_process_stopped": "Moteur Reticulum arrêté",
"backend_reconnected": "Reconnecté au moteur",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Configuration initiale",
"login_title": "Authentification requise",
"setup_subtitle": "Définissez un mot de passe administrateur pour sécuriser votre instance MeshChatX",
"login_subtitle": "Veuillez saisir votre mot de passe pour continuer",
"password_label": "Mot de passe",
"password_placeholder": "Saisir le mot de passe",
"password_min_length": "Le mot de passe doit contenir au moins 8 caractères",
"confirm_password_label": "Confirmer le mot de passe",
"confirm_password_placeholder": "Confirmer le mot de passe",
"processing": "Traitement...",
"set_password": "Définir le mot de passe",
"login": "Connexion",
"passwords_mismatch": "Les mots de passe ne correspondent pas",
"altcha_required": "Terminez d'abord le défi de vérification",
"status_check_failed": "Impossible de vérifier l'état d'authentification",
"failed": "Échec de l'authentification"
},
"post_install": {
"windows_screen_security_title": "Activer la sécurité décran Windows ?",
"windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur daffichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres -> Confidentialité.",

View file

@ -370,6 +370,7 @@
"loading_overlay_title": "Cambio profilo",
"loading_overlay_subtitle": "Caricamento chat e impostazioni. Di solito bastano pochi secondi.",
"emergency_mode_active": "Modalità Emergenza Attiva - Utilizzo del database in memoria e servizi limitati.",
"demo_mode_active": "Modalità demo: vetrina mesh in sola lettura. Invio e modifiche alle impostazioni disabilitati.",
"backend_disconnected": "Disconnesso dal backend",
"backend_process_stopped": "Backend Reticulum arrestato",
"backend_reconnected": "Riconnesso al backend",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Configurazione iniziale",
"login_title": "Autenticazione richiesta",
"setup_subtitle": "Imposta una password amministratore per proteggere la tua istanza MeshChatX",
"login_subtitle": "Inserisci la password per continuare",
"password_label": "Password",
"password_placeholder": "Inserisci password",
"password_min_length": "La password deve contenere almeno 8 caratteri",
"confirm_password_label": "Conferma password",
"confirm_password_placeholder": "Conferma password",
"processing": "Elaborazione...",
"set_password": "Imposta password",
"login": "Accedi",
"passwords_mismatch": "Le password non corrispondono",
"altcha_required": "Completa prima la verifica",
"status_check_failed": "Impossibile verificare lo stato di autenticazione",
"failed": "Autenticazione non riuscita"
},
"post_install": {
"windows_screen_security_title": "Attivare la sicurezza schermo di Windows?",
"windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni -> Privacy.",

View file

@ -370,6 +370,7 @@
"loading_overlay_title": "Schakelen van profiel",
"loading_overlay_subtitle": "Uw chats en instellingen laden. Dit duurt meestal een paar seconden.",
"emergency_mode_active": "Noodmodus actief - Gebruik van in-geheugen database en beperkte diensten.",
"demo_mode_active": "Demomodus: alleen-lezen mesh-demo. Verzenden en instellingen wijzigen is uitgeschakeld.",
"backend_disconnected": "Verbinding verbroken met backend",
"backend_process_stopped": "Reticulum-backend gestopt",
"backend_reconnected": "Verbinding met backend",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Eerste installatie",
"login_title": "Authenticatie vereist",
"setup_subtitle": "Stel een beheerderswachtwoord in om uw MeshChatX-exemplaar te beveiligen",
"login_subtitle": "Voer uw wachtwoord in om door te gaan",
"password_label": "Wachtwoord",
"password_placeholder": "Wachtwoord invoeren",
"password_min_length": "Wachtwoord moet minstens 8 tekens lang zijn",
"confirm_password_label": "Wachtwoord bevestigen",
"confirm_password_placeholder": "Wachtwoord bevestigen",
"processing": "Bezig...",
"set_password": "Wachtwoord instellen",
"login": "Inloggen",
"passwords_mismatch": "Wachtwoorden komen niet overeen",
"altcha_required": "Voltooi eerst de verificatie-uitdaging",
"status_check_failed": "Authenticatiestatus kon niet worden gecontroleerd",
"failed": "Authenticatie mislukt"
},
"post_install": {
"windows_screen_security_title": "Windows-schermbeveiliging inschakelen?",
"windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen -> Privacy.",

View file

@ -343,6 +343,7 @@
"loading_overlay_title": "Смена профиля",
"loading_overlay_subtitle": "Загрузка чатов и настроек. Обычно это занимает несколько секунд.",
"emergency_mode_active": "Аварийный режим активен. Используется база данных в оперативной памяти и ограниченные службы.",
"demo_mode_active": "Демо-режим: демонстрация сети только для чтения. Отправка и изменение настроек отключены.",
"backend_disconnected": "Нет соединения с сервером",
"backend_process_stopped": "Сервер Reticulum остановлен",
"backend_reconnected": "Соединение с сервером восстановлено",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "Первоначальная настройка",
"login_title": "Требуется аутентификация",
"setup_subtitle": "Задайте пароль администратора для защиты экземпляра MeshChatX",
"login_subtitle": "Введите пароль, чтобы продолжить",
"password_label": "Пароль",
"password_placeholder": "Введите пароль",
"password_min_length": "Пароль должен содержать не менее 8 символов",
"confirm_password_label": "Подтвердите пароль",
"confirm_password_placeholder": "Подтвердите пароль",
"processing": "Обработка...",
"set_password": "Задать пароль",
"login": "Войти",
"passwords_mismatch": "Пароли не совпадают",
"altcha_required": "Сначала завершите проверку",
"status_check_failed": "Не удалось проверить состояние аутентификации",
"failed": "Ошибка аутентификации"
},
"post_install": {
"windows_screen_security_title": "Включить защиту экрана Windows?",
"windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки -> Конфиденциальность.",

View file

@ -370,6 +370,7 @@
"loading_overlay_title": "切换配置",
"loading_overlay_subtitle": "正在加载您的聊天和设置。这通常需要几秒钟。",
"emergency_mode_active": "紧急模式激活 - 使用内存数据库和受限服务。",
"demo_mode_active": "演示模式:只读网状网络展示。已禁用发送和设置更改。",
"backend_disconnected": "后端已断开",
"backend_process_stopped": "Reticulum 后端已停止",
"backend_reconnected": "已重新连接到后端",
@ -3937,6 +3938,24 @@
"failed_load_identities": "Failed to load management identities",
"failed_create_identity": "Failed to create management identity"
},
"auth": {
"setup_title": "初始设置",
"login_title": "需要身份验证",
"setup_subtitle": "设置管理员密码以保护您的 MeshChatX 实例",
"login_subtitle": "请输入密码以继续",
"password_label": "密码",
"password_placeholder": "输入密码",
"password_min_length": "密码长度至少为 8 个字符",
"confirm_password_label": "确认密码",
"confirm_password_placeholder": "确认密码",
"processing": "处理中...",
"set_password": "设置密码",
"login": "登录",
"passwords_mismatch": "两次输入的密码不一致",
"altcha_required": "请先完成验证挑战",
"status_check_failed": "无法检查身份验证状态",
"failed": "身份验证失败"
},
"post_install": {
"windows_screen_security_title": "启用 Windows 屏幕安全?",
"windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 -> 隐私 中更改。",

View file

@ -384,6 +384,12 @@ if (networkReady) {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
}
try {
const statusResponse = await window.api.get("/api/v1/status");
GlobalState.demoMode = !!statusResponse.data?.demo_mode;
} catch {
// status optional during early boot
}
try {
await fetchCsrfToken(window.api);
} catch {
@ -396,6 +402,7 @@ if (networkReady) {
const status = response.data;
GlobalState.authEnabled = !!status.auth_enabled;
GlobalState.authenticated = !!status.authenticated;
GlobalState.demoMode = !!status.demo_mode;
GlobalState.authSessionResolved = true;
if (!status.auth_enabled) {

View file

@ -292,6 +292,7 @@
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.11",
"@tanstack/vue-virtual": "^3.13.34",
"altcha": "3.2.1",
"compressorjs": "^1.3.0",
"dompurify": "^3.4.12",
"electron-prompt": "^1.7.0",

17
pnpm-lock.yaml generated
View file

@ -74,6 +74,9 @@ importers:
'@tanstack/vue-virtual':
specifier: ^3.13.34
version: 3.13.34(vue@3.5.40(typescript@6.0.3))
altcha:
specifier: 3.2.1
version: 3.2.1
compressorjs:
specifier: ^1.3.0
version: 1.3.0
@ -1284,6 +1287,9 @@ packages:
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
altcha@3.2.1:
resolution: {integrity: sha512-MXsnNmgg3BGJja0DXbflv9Bp29uyGv6wUImaaT1yLxCKOj+x9QBMpi/Vdh6XbP4Uf0PykaeQwl8ivJAvIhoo0A==}
amdefine@1.0.1:
resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==}
engines: {node: '>=0.4.2'}
@ -2131,6 +2137,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@ -4124,7 +4133,7 @@ snapshots:
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
string-width-cjs: string-width@4.2.0
strip-ansi: 6.0.1
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
@ -4829,6 +4838,10 @@ snapshots:
alien-signals@3.2.1: {}
altcha@3.2.1:
dependencies:
hash-wasm: 4.12.0
amdefine@1.0.1: {}
ansi-regex@5.0.1: {}
@ -5768,6 +5781,8 @@ snapshots:
dependencies:
has-symbols: 1.0.3
hash-wasm@4.12.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2

61
scripts/ci/docker-demo-smoke.sh Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Smoke-test docker-compose.demo.yml (Coolify-shaped demo stack).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
IMAGE="${MESHCHAT_DOCKER_DEMO_IMAGE:-reticulum-meshchatx:local}"
COMPOSE_FILE="docker-compose.demo.yml"
OVERRIDE_FILE="${TMPDIR:-/tmp}/meshchatx-demo-smoke-ports.yml"
KEY="${MESHCHAT_ALTCHA_HMAC_KEY:-demo-smoke-hmac-key-change-me}"
cleanup() {
MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \
docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" down >/dev/null 2>&1 || true
rm -f "$OVERRIDE_FILE"
}
trap cleanup EXIT
cat >"$OVERRIDE_FILE" <<'EOF'
services:
meshchatx:
ports:
- 127.0.0.1:8000:8000
EOF
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "Building $IMAGE..."
docker build -f Dockerfile -t "$IMAGE" .
fi
MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \
docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" up -d --pull never
deadline=$((SECONDS + 180))
while [ "$SECONDS" -lt "$deadline" ]; do
if curl -fsS "http://127.0.0.1:8000/api/v1/status" 2>/dev/null | python3 -c '
import json,sys
d=json.load(sys.stdin)
sys.exit(0 if d.get("demo_mode") and d.get("altcha_enabled") else 1)
'; then
break
fi
sleep 3
done
curl -fsS "http://127.0.0.1:8000/api/v1/status" | python3 -c '
import json,sys
d=json.load(sys.stdin)
assert d.get("demo_mode") is True
assert d.get("altcha_enabled") is True
print("demo status ok", d.get("status"))
'
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{}" \
"http://127.0.0.1:8000/api/v1/lxmf-messages/send")
test "$code" = "403"
echo "Docker demo smoke passed"

View file

@ -291,6 +291,8 @@ def mock_app(db, tmp_path, temp_db):
if app.rrc_manager is not None:
app.rrc_manager.set_database(app.database)
app.websocket_broadcast = MagicMock(side_effect=lambda data: None)
app.demo_mode = False
app.altcha_enabled = False
yield app
app.teardown_identity()
@ -306,8 +308,8 @@ async def fetch_api_csrf_headers(client):
def extend_meshchat_middlewares(aio_app, middlewares):
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = middlewares
aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = middlewares
aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw])
def pytest_collection_modifyitems(session, config, items):

View file

@ -0,0 +1,25 @@
# SPDX-License-Identifier: 0BSD
from __future__ import annotations
import secrets
from aiohttp import web
from aiohttp_session import setup as setup_session
from tests.backend.conftest import extend_meshchat_middlewares
def build_test_aio_app(app, *, https: bool = False):
app.session_secret_key = secrets.token_urlsafe(32)
app.listen_host = "127.0.0.1"
app.listen_port = 8000
app.use_https = https
app.landlock_active = False
routes = web.RouteTableDef()
middlewares = app._define_routes(routes)
aio_app = web.Application()
setup_session(aio_app, app._encrypted_cookie_storage(https))
extend_meshchat_middlewares(aio_app, middlewares)
aio_app.add_routes(routes)
return aio_app

View file

@ -236,7 +236,7 @@ def test_enforce_untrusted_monotone_hypothesis(mock_app, n):
def _make_aio_app(mock_app, use_https: bool):
mock_app.session_secret_key = secrets.token_urlsafe(32)
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = mock_app._define_routes(routes)
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = mock_app._define_routes(routes)
aio_app = web.Application()
setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])

View file

@ -0,0 +1,104 @@
# SPDX-License-Identifier: 0BSD
"""Oracle: ALTCHA challenge round-trip and login rejection without payload."""
from __future__ import annotations
import base64
import json
import os
import time
from unittest.mock import patch
import altcha
import bcrypt
import pytest
from aiohttp.test_utils import TestClient, TestServer
from meshchatx.src.backend.altcha_auth import (
ALTCHA_INVALID_CODE,
verify_altcha_submission,
)
from tests.backend.demo_http_support import build_test_aio_app
from tests.backend.conftest import fetch_api_csrf_headers
_TEST_SECRET = "test-secret-key-32chars-minimum!!"
def _solved_payload_b64(secret: str) -> str:
challenge = altcha.create_challenge(
"PBKDF2/SHA-256",
200,
hmac_secret=secret,
expires_at=int(time.time()) + 300,
)
solution = altcha.solve_challenge(challenge, counter_step=1)
assert solution is not None
payload = altcha.Payload(challenge, solution)
raw = json.dumps(payload.to_dict())
return base64.b64encode(raw.encode()).decode()
def test_altcha_verify_round_trip():
with patch.dict(
os.environ, {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, clear=False
):
ok, code = verify_altcha_submission(_solved_payload_b64(_TEST_SECRET))
assert ok is True
assert code is None
def test_altcha_rejects_tampered_payload():
with patch.dict(
os.environ, {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, clear=False
):
bad = _solved_payload_b64(_TEST_SECRET)[:-4] + "XXXX"
ok, code = verify_altcha_submission(bad)
assert ok is False
assert code is not None
@pytest.mark.asyncio
async def test_login_without_altcha_when_enabled(mock_app):
mock_app.altcha_enabled = True
mock_app.demo_mode = False
mock_app.current_context.running = True
mock_app.config.auth_enabled.set(True)
password_hash = bcrypt.hashpw(b"secretpass", bcrypt.gensalt()).decode("utf-8")
mock_app.config.auth_password_hash.set(password_hash)
env = {
"MESHCHAT_ALTCHA_ENABLED": "1",
"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET,
}
aio_app = build_test_aio_app(mock_app)
async with TestClient(TestServer(aio_app)) as client:
with patch.dict(os.environ, env, clear=False):
headers = await fetch_api_csrf_headers(client)
response = await client.post(
"/api/v1/auth/login",
json={"password": "secretpass"},
headers=headers,
)
assert response.status == 400
body = await response.json()
assert body.get("code") == ALTCHA_INVALID_CODE
@pytest.mark.asyncio
async def test_altcha_challenge_endpoint(mock_app):
mock_app.altcha_enabled = True
mock_app.current_context.running = True
env = {
"MESHCHAT_ALTCHA_ENABLED": "1",
"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET,
}
aio_app = build_test_aio_app(mock_app)
async with TestClient(TestServer(aio_app)) as client:
with patch.dict(os.environ, env, clear=False):
response = await client.get("/api/v1/auth/altcha/challenge")
assert response.status == 200
data = await response.json()
assert "parameters" in data
assert "signature" in data
assert data["parameters"].get("algorithm") == "PBKDF2/SHA-256"

View file

@ -59,7 +59,7 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
# Call _define_routes to get the security_middleware
routes = web.RouteTableDef()
_, _, security_middleware, _, _ = app_instance._define_routes(routes)
_, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@ -102,7 +102,7 @@ async def test_security_middleware_sets_cors_headers_on_rnode_flasher(
return web.Response(text="// module")
routes = web.RouteTableDef()
_, _, security_middleware, _, _ = app_instance._define_routes(routes)
_, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@ -136,7 +136,7 @@ async def test_security_middleware_does_not_set_cors_on_reticulum_docs(
return web.Response(text="<html></html>")
routes = web.RouteTableDef()
_, _, security_middleware, _, _ = app_instance._define_routes(routes)
_, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@ -223,7 +223,7 @@ async def test_csp_privacy_mode_strips_external_sources(mock_rns_minimal, tmp_pa
return web.Response(text="test")
routes = web.RouteTableDef()
_, _, security_middleware, _, _ = app_instance._define_routes(routes)
_, _, security_middleware, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
csp = response.headers.get("Content-Security-Policy", "")
assert "openstreetmap.org" not in csp
@ -247,7 +247,7 @@ async def _csp_for_path(app_instance, path: str) -> str:
return web.Response(text="test")
routes = web.RouteTableDef()
_, _, security_middleware, _, _ = app_instance._define_routes(routes)
_, _, security_middleware, _, _, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
return response.headers.get("Content-Security-Policy", "")

View file

@ -336,8 +336,10 @@ async def test_auth_middleware_allows_status_and_static_while_starting(
):
app = _make_deferred_app(mock_identity, temp_dir)
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
async with TestClient(TestServer(aio_app)) as client:
@ -362,8 +364,10 @@ async def test_auth_middleware_allows_status_and_static_while_starting(
async def test_auth_middleware_allows_csrf_while_starting(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
with patch(

View file

@ -0,0 +1,28 @@
# SPDX-License-Identifier: 0BSD
"""Oracle: auth bypass env and demo password seeding."""
from __future__ import annotations
import os
from unittest.mock import patch
import bcrypt
def test_auth_bypass_disables_auth_enabled(mock_app):
with patch.dict(os.environ, {"MESHCHAT_AUTH_BYPASS": "1"}, clear=False):
mock_app.auth_enabled_initial = True
mock_app.config.auth_enabled.set(True)
assert mock_app.auth_enabled is False
def test_demo_mode_seeds_password_hash(mock_app):
mock_app.demo_mode = True
mock_app.auth_enabled_initial = True
mock_app.config.auth_password_hash.set(None)
with patch.dict(os.environ, {"MESHCHAT_DEMO_AUTH_PASSWORD": "demo"}, clear=False):
mock_app._apply_demo_mode_runtime()
stored = mock_app.config.auth_password_hash.get()
assert stored is not None
assert bcrypt.checkpw(b"demo", stored.encode("utf-8"))

View file

@ -0,0 +1,70 @@
# SPDX-License-Identifier: 0BSD
"""Oracle: demo mode blocks HTTP mutations outside the allowlist."""
from __future__ import annotations
import pytest
from aiohttp.test_utils import TestClient, TestServer
from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.demo_mode import (
DEMO_HTTP_MUTATION_ALLOWLIST,
DEMO_READONLY_CODE,
demo_http_mutation_allowed,
)
from tests.backend.demo_http_support import build_test_aio_app
@pytest.mark.parametrize(
("method", "path"),
[
("POST", "/api/v1/lxmf-messages/send"),
("POST", "/api/v1/identities/create"),
("DELETE", "/api/v1/lxmf-messages/aa"),
("PATCH", "/api/v1/server/security"),
],
)
@pytest.mark.asyncio
async def test_demo_mode_blocks_disallowed_mutations(mock_app, method, path):
mock_app.demo_mode = True
mock_app.current_context.running = True
mock_app.config.auth_enabled.set(False)
aio_app = build_test_aio_app(mock_app)
async with TestClient(TestServer(aio_app)) as client:
response = await client.request(method, path, json={})
assert response.status == 403
body = await response.json()
assert body.get("code") == DEMO_READONLY_CODE
@pytest.mark.asyncio
async def test_demo_mode_status_includes_flags(mock_app):
mock_app.demo_mode = True
mock_app.altcha_enabled = True
mock_app.current_context.running = True
mock_app._network_ready = True
payload = mock_app._startup_status_payload()
assert payload["demo_mode"] is True
assert payload["altcha_enabled"] is True
@given(
suffix=st.text(
alphabet=st.characters(blacklist_categories=("Cs",)),
min_size=0,
max_size=24,
),
)
@settings(max_examples=40, deadline=None)
def test_demo_http_mutation_fuzz_rejects_unknown_paths(suffix):
path = f"/api/v1/lxmf-messages/send{suffix}"
if path in DEMO_HTTP_MUTATION_ALLOWLIST:
return
assert demo_http_mutation_allowed("POST", path) is False
def test_allowlist_is_explicit_set():
assert "/api/v1/auth/login" in DEMO_HTTP_MUTATION_ALLOWLIST
assert "/api/v1/lxmf-messages/send" not in DEMO_HTTP_MUTATION_ALLOWLIST

View file

@ -0,0 +1,19 @@
# SPDX-License-Identifier: 0BSD
"""Demo mode skips outbound announces."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
@pytest.mark.asyncio
async def test_announce_noop_in_demo_mode(mock_app):
mock_app.demo_mode = True
ctx = mock_app.current_context
ctx.message_router = MagicMock()
ctx.telephone_manager = MagicMock()
await mock_app.announce(context=ctx)
ctx.message_router.announce.assert_not_called()
ctx.telephone_manager.announce.assert_not_called()

View file

@ -0,0 +1,17 @@
# SPDX-License-Identifier: 0BSD
"""Oracle: WebSocket mutators rejected in demo mode."""
from __future__ import annotations
import pytest
from meshchatx.src.backend.demo_mode import demo_mode_active
from meshchatx.src.backend.websocket_config_guard import WEBSOCKET_MUTATOR_TYPES
@pytest.mark.parametrize("msg_type", sorted(WEBSOCKET_MUTATOR_TYPES))
def test_demo_mode_blocks_ws_mutator_types(mock_app, msg_type):
mock_app.demo_mode = True
assert demo_mode_active(mock_app) is True
assert msg_type in WEBSOCKET_MUTATOR_TYPES

View file

@ -14,6 +14,7 @@ import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
_HARDENED_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-hardened-smoke.sh"
_DEMO_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-demo-smoke.sh"
def _docker_available() -> bool:
@ -82,6 +83,30 @@ def test_docker_hardened_compose_and_run_serve_status():
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker demo compose smoke",
)
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
def test_docker_demo_compose_smoke():
env = os.environ.copy()
env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
result = subprocess.run(
["bash", str(_DEMO_SMOKE_SCRIPT)],
cwd=_REPO_ROOT,
env=env,
capture_output=True,
text=True,
timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 600,
check=False,
)
assert result.returncode == 0, (
f"Docker demo smoke failed (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",

View file

@ -15,8 +15,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -16,8 +16,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -13,8 +13,10 @@ from aiohttp.test_utils import TestClient, TestServer
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw],
)
aio_app.add_routes(routes)
return aio_app

View file

@ -17,8 +17,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -442,8 +442,10 @@ pytestmark_integration = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -466,8 +466,10 @@ async def test_auth_middleware_returns_401_for_api_without_session_when_auth_ena
app.config.auth_enabled.set(True)
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
async with TestClient(TestServer(aio_app)) as client:

View file

@ -16,8 +16,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -12,8 +12,10 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw = app._define_routes(routes)
aio_app = web.Application(
middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw, demo_mw]
)
aio_app.add_routes(routes)
return aio_app

View file

@ -2,6 +2,26 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import AuthPage from "../../meshchatx/src/frontend/components/auth/AuthPage.vue";
const authI18n = {
"auth.setup_title": "Initial Setup",
"auth.login_title": "Authentication Required",
"auth.setup_subtitle": "Set an admin password to secure your MeshChatX instance",
"auth.login_subtitle": "Please enter your password to continue",
"auth.password_label": "Password",
"auth.password_placeholder": "Enter password",
"auth.password_min_length": "Password must be at least 8 characters long",
"auth.confirm_password_label": "Confirm Password",
"auth.confirm_password_placeholder": "Confirm password",
"auth.processing": "Processing...",
"auth.set_password": "Set Password",
"auth.login": "Login",
"auth.passwords_mismatch": "Passwords do not match",
"auth.altcha_required": "Complete the verification challenge first",
"auth.status_check_failed": "Failed to check authentication status",
"auth.failed": "Authentication failed",
"app.demo_mode_active": "Demo mode active",
};
describe("AuthPage.vue", () => {
let axiosMock;
let routerMock;
@ -41,6 +61,12 @@ describe("AuthPage.vue", () => {
global: {
mocks: {
$router: routerMock,
$t: (key) => authI18n[key] || key,
},
config: {
compilerOptions: {
isCustomElement: (tag) => tag === "altcha-widget",
},
},
},
});