mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: add demo mode and ALTCHA integration for better security for internet deployments and showcase functionality
This commit is contained in:
parent
5745d53202
commit
22b4fcce09
51 changed files with 1201 additions and 110 deletions
63
docker-compose.demo.yml
Normal file
63
docker-compose.demo.yml
Normal 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:
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
95
meshchatx/src/backend/altcha_auth.py
Normal file
95
meshchatx/src/backend/altcha_auth.py
Normal 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
|
||||
12
meshchatx/src/backend/auth_page_hint.py
Normal file
12
meshchatx/src/backend/auth_page_hint.py
Normal 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
|
||||
100
meshchatx/src/backend/demo_mode.py
Normal file
100
meshchatx/src/backend/demo_mode.py
Normal 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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
9
meshchatx/src/frontend/js/demoMode.js
Normal file
9
meshchatx/src/frontend/js/demoMode.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 d’affichage 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é.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 не захватывали окно. Позже можно изменить в Настройки -> Конфиденциальность.",
|
||||
|
|
|
|||
|
|
@ -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 无法捕获应用窗口。之后可在 设置 -> 隐私 中更改。",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
17
pnpm-lock.yaml
generated
|
|
@ -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
61
scripts/ci/docker-demo-smoke.sh
Executable 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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
25
tests/backend/demo_http_support.py
Normal file
25
tests/backend/demo_http_support.py
Normal 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
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
104
tests/backend/test_altcha_oracle.py
Normal file
104
tests/backend/test_altcha_oracle.py
Normal 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"
|
||||
|
|
@ -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", "")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
28
tests/backend/test_demo_auth_oracle.py
Normal file
28
tests/backend/test_demo_auth_oracle.py
Normal 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"))
|
||||
70
tests/backend/test_demo_mode_http_oracle.py
Normal file
70
tests/backend/test_demo_mode_http_oracle.py
Normal 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
|
||||
19
tests/backend/test_demo_mode_mesh.py
Normal file
19
tests/backend/test_demo_mode_mesh.py
Normal 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()
|
||||
17
tests/backend/test_demo_mode_websocket_oracle.py
Normal file
17
tests/backend/test_demo_mode_websocket_oracle.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue