Add multi-user MAX authentication and tenant isolation

This commit is contained in:
Курнат Андрей
2026-07-14 07:35:04 +03:00
parent 582f99ed0e
commit 440de7325f
36 changed files with 904 additions and 118 deletions
+13 -16
View File
@@ -1,11 +1,11 @@
# QMAX
QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server uses PyMax as the only MAX bridge.
QMAX is a multi-user Android messenger client backed by a self-hosted bridge server. Each Android user verifies a separate MAX account, while one QMAX server and one PyMax worker can serve all accounts.
Current shape:
- `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog.
- `pymax-worker` - Python/PyMax worker with a persistent MAX mobile API session.
- `pymax-worker` - Python/PyMax worker with an isolated persistent MAX mobile API session per QMAX user.
- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
@@ -44,22 +44,15 @@ qmax.kusoft.xyz {
Set strong values in `.env`:
- `QMAX_JWT_SECRET` - at least 32 random characters.
- `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`.
- `QMAX_MAX_PHONE_NUMBER` - the phone number linked to the MAX account used by PyMax.
- `QMAX_PAIRING_CODE` - optional server registration code shared with allowed users; leave it empty for open registration.
Secrets must stay in `.env`, not in git.
## MAX Login
Open:
On the Android login screen enter the QMAX server URL, the user's MAX phone number and, when configured, `QMAX_PAIRING_CODE`. QMAX asks PyMax to start MAX authorization. Enter the code delivered by MAX in the app; only after PyMax reports an authorized session does QMAX issue that user a JWT and refresh token.
```text
https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE
```
Use **Start phone login**. When MAX sends the confirmation code, submit it on the same page or from the Android app settings.
The worker stores PyMax session state in the `qmax-pymax-session` Docker volume, so the MAX session should survive restarts until MAX expires the login token.
The worker stores sessions under `accounts/<QMAX user id>/` in the `qmax-pymax-session` Docker volume. Session files and imported-contact mappings are not shared between users.
## Android Pairing
@@ -73,7 +66,9 @@ cd android
On first launch:
- Server: `https://qmax.kusoft.xyz`
- Code: `QMAX_PAIRING_CODE` from `.env`
- Phone: the user's MAX phone number
- Registration code: `QMAX_PAIRING_CODE` from `.env`, if the server owner configured one
- MAX code: the confirmation code sent by MAX after the first step
The first screen after pairing is the chat list.
@@ -128,13 +123,15 @@ https://argus.kusoft.xyz/api/apps/qmax/download/latest?platform=android&channel=
The Android app checks this manifest, compares semantic versions, downloads the APK to a temporary file, verifies SHA-256, and only then opens Android's package installer.
## Current MAX Mapping Status
## Implemented MAX Mapping
The deployed worker is authorized through PyMax and currently maps:
The worker code maps:
- chat list and message history through PyMax;
- text sending through PyMax;
- attachment upload through PyMax file/photo/video models;
- image, video, file and voice attachment projection through the API;
- Android image attachment caching with `.part` downloads before a file is shown from local storage;
- explicit session status and phone-code re-login from Android settings.
- explicit per-user session status and phone-code re-login from Android settings.
An end-to-end login against the live MAX service was not run in this workspace; it must be verified on the target Raspberry Pi with real accounts.
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 54
versionName = "0.1.53"
versionCode = 55
versionName = "0.1.54"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -275,11 +275,6 @@ private fun QMaxApp(vm: QMaxViewModel) {
Box(Modifier.fillMaxSize().background(QMaxBackground)) {
when {
state.session == null && state.pairingCode.isNotBlank() -> AutoLoginScreen(
loading = state.loading,
error = state.error,
onRetry = vm::login
)
state.session == null -> LoginScreen(vm)
state.selectedChat != null -> ChatScreen(vm)
else -> ChatListScreen(vm)
@@ -359,6 +354,17 @@ private fun LoginScreen(vm: QMaxViewModel) {
value = state.serverUrl,
onValueChange = vm::updateServerUrl,
label = { Text("Сервер") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.phoneNumber,
onValueChange = vm::updatePhoneNumber,
label = { Text("Номер телефона MAX") },
placeholder = { Text("+7 900 000-00-00") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -366,15 +372,29 @@ private fun LoginScreen(vm: QMaxViewModel) {
OutlinedTextField(
value = state.pairingCode,
onValueChange = vm::updatePairingCode,
label = { Text("Код подключения") },
placeholder = { Text("qmax-...") },
supportingText = { Text("Код выдаёт сервер QMAX для привязки телефона") },
label = { Text("Код регистрации сервера (если задан)") },
enabled = state.phoneAuthChallenge == null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(18.dp))
Button(onClick = vm::login, modifier = Modifier.fillMaxWidth()) {
Text("Войти")
if (state.phoneAuthChallenge == null) {
Spacer(Modifier.height(18.dp))
Button(onClick = vm::login, enabled = state.phoneNumber.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text("Получить код MAX")
}
} else {
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = vm::updateMaxCode,
label = { Text("Код из MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(18.dp))
Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text("Войти в QMAX")
}
}
ErrorLine(state.error)
}
@@ -28,6 +28,7 @@ import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
@@ -68,6 +69,18 @@ class QMaxRepository(
return response
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String): PhoneAuthChallengeResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
return api.beginPhoneAuth(resolvedServer, phoneNumber, registrationCode.ifBlank { null }, android.os.Build.MODEL)
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
val response = api.completePhoneAuth(resolvedServer, challengeId, challengeToken, code)
tokenStore.save(QMaxSession(resolvedServer, response.accessToken, response.refreshToken, response.user.displayName))
return response
}
suspend fun logout() {
tokenStore.clear()
draftStore.clearAll()
@@ -24,6 +24,20 @@ data class DeviceLoginRequest(
val deviceName: String
)
@Serializable
data class BeginPhoneAuthRequest(val phoneNumber: String, val deviceName: String, val registrationCode: String? = null)
@Serializable
data class CompletePhoneAuthRequest(val challengeId: String, val challengeToken: String, val code: String)
@Serializable
data class PhoneAuthChallengeResponse(
val challengeId: String,
val challengeToken: String,
val maxStatus: MaxBridgeStatusDto,
val expiresAt: String
)
@Serializable
data class RefreshTokenRequest(val refreshToken: String)
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
import xyz.kusoft.qmax.core.model.BeginPhoneAuthRequest
import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
@@ -29,6 +30,8 @@ import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.CompletePhoneAuthRequest
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
@@ -73,6 +76,14 @@ class QMaxApi {
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String?, deviceName: String): PhoneAuthChallengeResponse {
return post(serverUrl, "/api/auth/phone/start", null, BeginPhoneAuthRequest(phoneNumber, deviceName, registrationCode))
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
return post(serverUrl, "/api/auth/phone/code", null, CompletePhoneAuthRequest(challengeId, challengeToken, code))
}
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
}
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -32,6 +33,8 @@ import java.util.UUID
data class QMaxUiState(
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val phoneNumber: String = "",
val phoneAuthChallenge: PhoneAuthChallengeResponse? = null,
val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(),
@@ -133,7 +136,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
pushRegistrationInFlightForToken = null
pushRegisteredForToken = null
realtime.disconnect()
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
if (!autoLoginAttempted && state.value.phoneNumber.isNotBlank()) {
autoLoginAttempted = true
autoLoginJob?.cancel()
autoLoginJob = viewModelScope.launch {
@@ -162,6 +165,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(pairingCode = value)
}
fun updatePhoneNumber(value: String) {
state.value = state.value.copy(phoneNumber = value)
}
fun updateComposer(value: String) {
state.value = state.value.copy(composerText = value)
if (state.value.editTarget != null) {
@@ -263,7 +270,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun login() = launchLoading {
val current = state.value
repository.login(current.serverUrl, current.pairingCode)
val challenge = repository.beginPhoneAuth(current.serverUrl, current.phoneNumber, current.pairingCode)
state.value = state.value.copy(phoneAuthChallenge = challenge, maxStatus = challenge.maxStatus)
}
fun completePhoneLogin() = launchLoading {
val current = state.value
val challenge = current.phoneAuthChallenge ?: return@launchLoading
val code = current.maxCode.trim()
if (code.isBlank()) return@launchLoading
repository.completePhoneAuth(current.serverUrl, challenge.challengeId, challenge.challengeToken, code)
state.value = state.value.copy(phoneAuthChallenge = null, maxCode = "")
}
fun logout() = viewModelScope.launch {
+2 -4
View File
@@ -5,11 +5,9 @@ QMAX_TLS_EMAIL=admin@kusoft.xyz
# Generate with: openssl rand -base64 48
QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
# Enter this once on the Android login screen to pair the device with your private bridge.
# Optional registration code shared with people allowed to create a QMAX account.
# Leave empty for open phone registration. MAX still verifies every phone with its own code.
QMAX_PAIRING_CODE=change-me-pairing-code
# Use the phone number linked to the MAX account used by PyMax.
QMAX_MAX_PHONE_NUMBER=79000000000
QMAX_CORS_ALLOWED_ORIGINS=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
-2
View File
@@ -13,7 +13,6 @@ services:
QMax__ReleasesPath: /data/releases
QMax__JwtSecret: ${QMAX_JWT_SECRET}
QMax__PairingCode: ${QMAX_PAIRING_CODE}
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
QMax__MaxMode: Worker
QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
@@ -40,7 +39,6 @@ services:
restart: unless-stopped
environment:
PORT: 3002
PYMAX_PHONE_NUMBER: ${QMAX_MAX_PHONE_NUMBER}
PYMAX_SESSION_DIR: /data/pymax-session
PYMAX_SEND_ROOTS: /qmax-data:/tmp
PYMAX_CHAT_FETCH_LIMIT: ${QMAX_PYMAX_CHAT_FETCH_LIMIT:-350}
+7 -7
View File
@@ -1,14 +1,14 @@
{
"slug": "qmax",
"name": "QMAX",
"version": "0.1.39",
"androidVersionCode": 40,
"version": "0.1.54",
"androidVersionCode": 55,
"channel": "stable",
"platform": "android",
"packageKind": "apk",
"downloadPath": "/api/app-updates/android/download/qmax-0.1.39-stable.apk",
"packageSizeBytes": 17730369,
"sha256": "fb1ba02daf4a5e99972794bd69965bef8219388c96344902b9d8a67ed2635803",
"notes": "Chat composer now follows the Android keyboard height like Telegram, and downloaded video, audio, and document attachments keep correct local cache extensions.",
"publishedAt": "2026-07-02T18:38:00.3130417Z"
"downloadPath": "/api/app-updates/android/download/qmax-0.1.54-stable.apk",
"packageSizeBytes": 17862069,
"sha256": "69a65b8c8d00c16f2ece211f1e993b0a2da46776f8afa436db384fb818c2f147",
"notes": "QMAX 0.1.54",
"publishedAt": "2026-07-13T19:13:39.6141836Z"
}
Binary file not shown.
+10 -6
View File
@@ -4,10 +4,10 @@
flowchart LR
A["Android app<br/>Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API<br/>ASP.NET Core"]
A -->|SignalR planned/available| B
B --> C["SQLite cache<br/>chats/messages/sessions"]
B --> C["SQLite cache<br/>tenant-scoped chats/messages/sessions"]
B --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"]
E -->|persistent PyMax session| F["MAX mobile API"]
E -->|isolated session per user| F["MAX mobile API"]
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
```
@@ -15,20 +15,24 @@ The server is the only public backend surface. The PyMax worker stays inside the
## Security Rules
- Android pairs to the private server with `QMAX_PAIRING_CODE`.
- Android starts a short-lived phone challenge; JWT credentials are issued only after PyMax confirms the MAX code.
- `QMAX_PAIRING_CODE` is an optional registration gate, not a substitute for MAX phone verification.
- API access uses JWT access tokens and refresh tokens.
- MAX session files live only on the Pi in Docker volumes.
- Chats, messages, push devices, realtime notifications and MAX state are scoped by QMAX user id.
- MAX session files live only on the Pi in per-user Docker-volume directories.
- `.env`, service-account files, keystores and runtime data are ignored by git.
- Attachments are written as `.part` first and moved into place only after full upload.
- Android image attachments are downloaded to a local `.part` cache and exposed to the UI only after size validation.
## Current Verified Status
## Implemented Status
- PyMax login is authorized on the Raspberry Pi worker.
- Phone challenge routing and per-user PyMax session directories are implemented in `pymax-worker/src/server.py`.
- Chat list, message history, text sending and attachment sending are mapped through `pymax-worker/src/server.py`.
- Image, video, file and voice attachments are projected through the API and rendered by the Android client.
- Firebase initialization and Android push token registration are enabled for `xyz.kusoft.qmax`.
Live multi-account authorization against MAX and Raspberry Pi capacity were not verified in this workspace.
## Remaining Production Checks
- Keep PyMax session-expiration monitoring active.
Binary file not shown.
+74 -21
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import base64
import contextvars
import json
import mimetypes
import os
@@ -24,9 +25,8 @@ from pymax.types import ContactInfo
PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or ""
SESSION_DIR = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_ROOT = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_NAME = os.environ.get("PYMAX_SESSION_NAME", "session.db")
PHONE_CONTACT_IDS_FILE = SESSION_DIR / "phone-contact-ids.json"
CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350"))
HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80"))
SEND_ROOTS = [pathlib.Path(p) for p in os.environ.get("PYMAX_SEND_ROOTS", "/qmax-data:/tmp").split(":") if p]
@@ -80,16 +80,16 @@ def is_expired_session_error(error: str | None) -> bool:
)
def archive_session_dir() -> str | None:
if not SESSION_DIR.exists():
def archive_session_dir(session_dir: pathlib.Path) -> str | None:
if not session_dir.exists():
return None
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}")
target = session_dir.with_name(f"{session_dir.name}.expired-{stamp}")
suffix = 1
while target.exists():
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}-{suffix}")
target = session_dir.with_name(f"{session_dir.name}.expired-{stamp}-{suffix}")
suffix += 1
shutil.move(str(SESSION_DIR), str(target))
shutil.move(str(session_dir), str(target))
return str(target)
@@ -372,7 +372,9 @@ class DeferredPasswordProvider:
class PyMaxRuntime:
def __init__(self) -> None:
def __init__(self, session_dir: pathlib.Path, default_phone: str = "") -> None:
self.session_dir = session_dir
self.phone = default_phone
self.client: Client | None = None
self.task: asyncio.Task[None] | None = None
self.ready = asyncio.Event()
@@ -409,15 +411,22 @@ class PyMaxRuntime:
return
self.ready.clear()
self.last_error = None
use_phone = normalize_phone(phone or PHONE_NUMBER)
stored_phone_file = self.session_dir / "phone.txt"
stored_phone = ""
with suppress(OSError):
stored_phone = stored_phone_file.read_text(encoding="utf-8").strip()
use_phone = normalize_phone(phone or self.phone or stored_phone or PHONE_NUMBER)
if not use_phone:
self.last_error = "PYMAX_PHONE_NUMBER is not configured."
return
SESSION_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.chmod(0o700)
self.phone = use_phone
self.session_dir.mkdir(parents=True, exist_ok=True)
self.session_dir.chmod(0o700)
stored_phone_file.write_text(use_phone, encoding="utf-8")
stored_phone_file.chmod(0o600)
client = Client(
phone=use_phone,
work_dir=str(SESSION_DIR),
work_dir=str(self.session_dir),
session_name=SESSION_NAME,
sms_code_provider=self.code_provider,
password_provider=self.password_provider,
@@ -474,7 +483,7 @@ class PyMaxRuntime:
async with self.lock:
await self._stop_locked()
self.last_error = None
archive_session_dir()
archive_session_dir(self.session_dir)
async def _stop_locked(self) -> None:
client = self.client
@@ -493,7 +502,35 @@ class PyMaxRuntime:
await task
runtime = PyMaxRuntime()
class RuntimeRegistry:
def __init__(self) -> None:
self._runtimes: dict[str, PyMaxRuntime] = {}
def get(self, account_id: str | None) -> PyMaxRuntime:
key = (account_id or "legacy").strip().lower()
if key != "legacy" and (len(key) != 32 or any(ch not in "0123456789abcdef" for ch in key)):
raise ValueError("X-QMax-Account-Id must be a 32-character hexadecimal UUID.")
runtime = self._runtimes.get(key)
if runtime is None:
session_dir = SESSION_ROOT if key == "legacy" else SESSION_ROOT / "accounts" / key
runtime = PyMaxRuntime(session_dir)
self._runtimes[key] = runtime
return runtime
async def stop(self) -> None:
await asyncio.gather(*(runtime.stop() for runtime in self._runtimes.values()), return_exceptions=True)
registry = RuntimeRegistry()
runtime_context: contextvars.ContextVar[PyMaxRuntime] = contextvars.ContextVar("qmax_runtime")
class RuntimeProxy:
def __getattr__(self, name: str) -> Any:
return getattr(runtime_context.get(), name)
runtime = RuntimeProxy()
def json_response(data: Any, status: int = 200) -> web.Response:
@@ -890,7 +927,8 @@ async def status(_request: web.Request) -> web.Response:
async def login_start(request: web.Request) -> web.Response:
data = await read_json(request)
phone = str(data.get("phoneNumber") or PHONE_NUMBER).strip()
if not runtime.is_authorized and runtime.login_stage() not in {"Code", "Password"}:
force = data.get("force") is True
if force or (not runtime.is_authorized and runtime.login_stage() not in {"Code", "Password"}):
await runtime.reset_session_for_login()
await runtime.ensure_started(phone)
return json_response(status_payload())
@@ -969,18 +1007,20 @@ async def contacts(_request: web.Request) -> web.Response:
def load_phone_contact_ids() -> set[int]:
path = runtime.session_dir / "phone-contact-ids.json"
try:
values = json.loads(PHONE_CONTACT_IDS_FILE.read_text(encoding="utf-8"))
values = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return set()
return {value for item in values if (value := coerce_int(item)) is not None and value > 0}
def save_phone_contact_ids(contact_ids: set[int]) -> None:
SESSION_DIR.mkdir(parents=True, exist_ok=True)
part_path = PHONE_CONTACT_IDS_FILE.with_suffix(".json.part")
runtime.session_dir.mkdir(parents=True, exist_ok=True)
path = runtime.session_dir / "phone-contact-ids.json"
part_path = path.with_suffix(".json.part")
part_path.write_text(json.dumps(sorted(contact_ids)), encoding="utf-8")
part_path.replace(PHONE_CONTACT_IDS_FILE)
part_path.replace(path)
def append_saved_contacts(client: Client, contacts: list[Any]) -> None:
@@ -1223,8 +1263,21 @@ async def media_fetch(request: web.Request) -> web.StreamResponse:
)
@web.middleware
async def account_runtime_middleware(request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]) -> web.StreamResponse:
try:
selected = registry.get(request.headers.get("X-QMax-Account-Id"))
except ValueError as exc:
return json_response({"success": False, "error": str(exc)}, status=400)
token = runtime_context.set(selected)
try:
return await handler(request)
finally:
runtime_context.reset(token)
def create_app() -> web.Application:
app = web.Application(client_max_size=1024 * 1024)
app = web.Application(client_max_size=1024 * 1024, middlewares=[account_runtime_middleware])
app.router.add_get("/health", health)
app.router.add_get("/status", status)
app.router.add_post("/login/start", login_start)
@@ -1253,7 +1306,7 @@ def create_app() -> web.Application:
async def on_cleanup(_app: web.Application) -> None:
await runtime.stop()
await registry.stop()
if __name__ == "__main__":
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
cd "${1:-/home/sevenhill/qmax}/deploy"
docker compose stop
trap 'docker compose start >/dev/null 2>&1 || true' EXIT
legacy_phone="$(sed -n 's/^QMAX_MAX_PHONE_NUMBER=//p' .env | tail -n 1)"
docker run --rm -i --user 0 --entrypoint python \
-e LEGACY_PHONE="$legacy_phone" \
--mount type=volume,source=deploy_qmax-data,target=/qmax \
--mount type=volume,source=deploy_qmax-pymax-session,target=/sessions \
deploy-qmax-pymax-worker - <<'PY'
import os
import pathlib
import shutil
import sqlite3
db = sqlite3.connect("/qmax/qmax.db")
row = db.execute("""
select u.Id, coalesce(nullif(u.PhoneNumber, ''), nullif(s.PhoneNumber, ''))
from Users u
left join MaxAccountStates s on s.UserId = u.Id
order by u.CreatedAt
limit 1
""").fetchone()
if not row:
raise SystemExit("No legacy QMAX user found")
user_id = str(row[0]).replace("-", "").lower()
phone = str(row[1] or os.environ.get("LEGACY_PHONE") or "").strip()
if not phone:
raise SystemExit("Legacy QMAX user has no phone number")
root = pathlib.Path("/sessions/pymax-session")
source = root / "session.db"
target = root / "accounts" / user_id
if not source.is_file():
raise SystemExit(f"Legacy PyMax session is missing: {source}")
target.mkdir(parents=True, exist_ok=True)
os.chmod(target, 0o700)
if not (target / "session.db").exists():
shutil.copy2(source, target / "session.db")
contacts = root / "phone-contact-ids.json"
if contacts.is_file() and not (target / contacts.name).exists():
shutil.copy2(contacts, target / contacts.name)
(target / "phone.txt").write_text(phone, encoding="utf-8")
os.chmod(target / "phone.txt", 0o600)
print(f"MIGRATED_ACCOUNT={user_id}")
print(f"SESSION_BYTES={(target / 'session.db').stat().st_size}")
PY
docker compose start
trap - EXIT
test "$(docker compose ps --status running --services | wc -l)" -eq 2
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import uuid
from datetime import UTC, datetime
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Publish a QMAX Android APK to the local Argus data store.")
parser.add_argument("source", type=Path)
parser.add_argument("version")
parser.add_argument("notes")
args = parser.parse_args()
source = args.source.resolve(strict=True)
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve(strict=True)
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = "qmax"
channel = "stable"
platform = "android"
release_id = str(uuid.uuid4()).upper()
now = datetime.now(UTC).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", args.version).strip("-._")
stored_name = f"{datetime.now(UTC):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}.apk"
stored_relative_path = f"{slug}/{stored_name}"
target_path = packages_root / stored_relative_path
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/vnd.android.package-archive"
connection = sqlite3.connect(db_path)
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("BEGIN")
app_id = connection.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()[0]
connection.execute(
'''
UPDATE "Apps"
SET "Name" = ?, "Summary" = ?, "Description" = ?, "RepositoryUrl" = ?,
"HomepageUrl" = ?, "IsListed" = 1, "UpdatedAt" = ?
WHERE "Id" = ?
''',
(
"QMAX",
"Multi-user Android client for MAX through a self-hosted PyMax bridge.",
"QMAX connects multiple Android users to isolated MAX accounts through one self-hosted server and per-user PyMax sessions.",
"https://git.kusoft.xyz/sevenhill/QMAX",
"https://qmax.kusoft.xyz",
now,
app_id,
),
)
duplicate = connection.execute(
'SELECT 1 FROM "Releases" WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?',
(app_id, args.version, channel, platform),
).fetchone()
if duplicate:
raise RuntimeError(f"QMAX {args.version} is already published")
connection.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, 'apk', ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
args.version,
channel,
platform,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
args.notes,
now,
),
)
connection.commit()
except Exception:
connection.rollback()
target_path.unlink(missing_ok=True)
raise
finally:
connection.close()
print(f"PUBLISHED={args.version}")
print(f"SIZE={size_bytes}")
print(f"SHA256={sha256}")
if __name__ == "__main__":
main()
@@ -1,6 +1,9 @@
namespace QMax.Api.Contracts;
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
public sealed record BeginPhoneAuthRequest(string PhoneNumber, string DeviceName, string? RegistrationCode = null);
public sealed record CompletePhoneAuthRequest(Guid ChallengeId, string ChallengeToken, string Code);
public sealed record PhoneAuthChallengeResponse(Guid ChallengeId, string ChallengeToken, MaxBridgeStatusDto MaxStatus, DateTimeOffset ExpiresAt);
public sealed record RefreshTokenRequest(string RefreshToken);
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
+143 -1
View File
@@ -7,6 +7,7 @@ using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers;
@@ -15,10 +16,117 @@ namespace QMax.Api.Controllers;
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase
IOptions<QMaxOptions> options,
IMaxBridgeClient maxBridge,
ICurrentUserAccessor currentUser) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[AllowAnonymous]
[HttpPost("phone/start")]
public async Task<ActionResult<PhoneAuthChallengeResponse>> BeginPhoneAuth(
BeginPhoneAuthRequest request,
CancellationToken cancellationToken)
{
if (!RegistrationCodeIsValid(request.RegistrationCode))
{
return Unauthorized();
}
var phone = NormalizePhone(request.PhoneNumber);
if (phone is null)
{
return BadRequest("Phone number must contain 10 to 15 digits.");
}
var user = await db.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phone, cancellationToken);
if (user is null)
{
user = new User { DisplayName = phone, PhoneNumber = phone };
db.Users.Add(user);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.Entry(user).State = EntityState.Detached;
user = await db.Users.FirstAsync(x => x.PhoneNumber == phone, cancellationToken);
}
}
var retryAfter = DateTimeOffset.UtcNow.AddSeconds(-60);
var recentChallengeTimes = await db.MaxLoginChallenges.IgnoreQueryFilters()
.Where(x => x.UserId == user.Id && x.CompletedAt == null)
.Select(x => x.CreatedAt)
.ToArrayAsync(cancellationToken);
if (recentChallengeTimes.Any(x => x >= retryAfter))
{
return StatusCode(StatusCodes.Status429TooManyRequests, "Wait 60 seconds before requesting another MAX code.");
}
var challengeToken = tokenService.CreateRefreshToken();
var challenge = new MaxLoginChallenge
{
UserId = user.Id,
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
SecretHash = tokenService.HashRefreshToken(challengeToken)
};
using var tenant = currentUser.Push(user.Id);
db.MaxLoginChallenges.Add(challenge);
await db.SaveChangesAsync(cancellationToken);
var status = await maxBridge.BeginNewPhoneLoginAsync(phone, cancellationToken);
await SaveMaxStateAsync(user.Id, phone, status, cancellationToken);
return new PhoneAuthChallengeResponse(
challenge.Id,
challengeToken,
ToDto(status),
challenge.ExpiresAt);
}
[AllowAnonymous]
[HttpPost("phone/code")]
public async Task<ActionResult<AuthResponse>> CompletePhoneAuth(
CompletePhoneAuthRequest request,
CancellationToken cancellationToken)
{
var hash = tokenService.HashRefreshToken(request.ChallengeToken ?? "");
var challenge = await db.MaxLoginChallenges
.IgnoreQueryFilters()
.Include(x => x.User)
.FirstOrDefaultAsync(x => x.Id == request.ChallengeId && x.SecretHash == hash, cancellationToken);
if (challenge?.User is null || challenge.CompletedAt is not null ||
challenge.ExpiresAt <= DateTimeOffset.UtcNow || challenge.FailedAttempts >= 5)
{
return Unauthorized();
}
using var tenant = currentUser.Push(challenge.UserId);
var status = await maxBridge.SubmitLoginCodeAsync(request.Code?.Trim() ?? "", cancellationToken);
await SaveMaxStateAsync(challenge.UserId, challenge.User.PhoneNumber ?? "", status, cancellationToken);
if (!status.IsAuthorized)
{
challenge.FailedAttempts++;
await db.SaveChangesAsync(cancellationToken);
return Conflict(ToDto(status));
}
challenge.CompletedAt = DateTimeOffset.UtcNow;
challenge.User.UpdatedAt = DateTimeOffset.UtcNow;
var refreshToken = tokenService.CreateRefreshToken();
var session = new UserSession
{
User = challenge.User,
DeviceName = challenge.DeviceName,
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
};
db.UserSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
return CreateAuthResponse(challenge.User, session, refreshToken);
}
[AllowAnonymous]
[HttpPost("device/login")]
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
@@ -127,4 +235,38 @@ public sealed class AuthController(
return expectedBytes.Length == actualBytes.Length &&
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
private bool RegistrationCodeIsValid(string? supplied)
{
return string.IsNullOrWhiteSpace(_options.PairingCode) ||
FixedTimeEquals(_options.PairingCode, supplied ?? "");
}
private static string? NormalizePhone(string? value)
{
var digits = new string((value ?? "").Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length == 10) digits = "7" + digits;
return digits.Length is >= 10 and <= 15 ? "+" + digits : null;
}
private async Task SaveMaxStateAsync(Guid userId, string phone, MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = phone;
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) =>
new(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
+12 -11
View File
@@ -8,6 +8,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Services;
namespace QMax.Api.Controllers;
@@ -64,7 +65,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
@@ -102,7 +103,7 @@ public sealed class ChatsController(
{
chat.UnreadCount = 0;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
var query = db.Messages
@@ -240,7 +241,7 @@ public sealed class ChatsController(
chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
return NoContent();
@@ -279,7 +280,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -327,7 +328,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -450,7 +451,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -504,7 +505,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -541,7 +542,7 @@ public sealed class ChatsController(
}
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -717,7 +718,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -785,7 +786,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -929,7 +930,7 @@ public sealed class ChatsController(
chat.WebUrl = nextWebUrl;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return chat.WebUrl;
}
+14 -10
View File
@@ -10,6 +10,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Services;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers;
@@ -21,11 +22,8 @@ public sealed class MaxController(
MaxBridgeSyncService syncService,
QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
ChatProjectionService projection,
IOptions<QMaxOptions> options) : ControllerBase
ChatProjectionService projection) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -37,7 +35,12 @@ public sealed class MaxController(
[HttpPost("login/start")]
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
{
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
var phone = request.PhoneNumber;
if (string.IsNullOrWhiteSpace(phone))
{
var userId = User.GetUserId();
phone = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken);
}
if (string.IsNullOrWhiteSpace(phone))
{
return BadRequest("Phone number is required.");
@@ -111,27 +114,28 @@ public sealed class MaxController(
return BadRequest("Channel was joined but was not saved in QMAX.");
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
var userId = User.GetUserId();
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = _options.MaxPhoneNumber;
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
+2
View File
@@ -3,6 +3,8 @@ namespace QMax.Api.Data.Entities;
public sealed class Chat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public User? User { get; set; }
public string? ExternalId { get; set; }
public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat";
@@ -2,7 +2,8 @@ namespace QMax.Api.Data.Entities;
public sealed class MaxAccountState
{
public int Id { get; set; } = 1;
public Guid UserId { get; set; }
public User? User { get; set; }
public string PhoneNumber { get; set; } = "";
public string Status { get; set; } = "NotStarted";
public bool IsAuthorized { get; set; }
@@ -0,0 +1,14 @@
namespace QMax.Api.Data.Entities;
public sealed class MaxLoginChallenge
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User? User { get; set; }
public string SecretHash { get; set; } = "";
public string DeviceName { get; set; } = "Android";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(10);
public DateTimeOffset? CompletedAt { get; set; }
public int FailedAttempts { get; set; }
}
+51 -3
View File
@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Data;
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbContext(options)
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options, ICurrentUserAccessor? currentUser = null) : DbContext(options)
{
private Guid? TenantUserId => currentUser?.UserId;
private bool TenantBypass => currentUser is null || currentUser.BypassTenantFilter || currentUser.UserId is null;
public DbSet<User> Users => Set<User>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<Chat> Chats => Set<Chat>();
@@ -13,12 +16,20 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>();
public DbSet<MaxLoginChallenge> MaxLoginChallenges => Set<MaxLoginChallenge>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber);
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber).IsUnique();
modelBuilder.Entity<MaxLoginChallenge>().HasIndex(x => x.SecretHash).IsUnique();
modelBuilder.Entity<MaxAccountState>().HasKey(x => x.UserId);
modelBuilder.Entity<MaxAccountState>()
.HasOne(x => x.User)
.WithOne()
.HasForeignKey<MaxAccountState>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<UserSession>().HasIndex(x => x.RefreshTokenHash).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.ExternalId).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => new { x.UserId, x.ExternalId }).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.DeletedAt);
modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt });
modelBuilder.Entity<Message>().HasIndex(x => new { x.ChatId, x.SentAt });
@@ -28,6 +39,21 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
modelBuilder.Entity<MessageReaction>().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique();
modelBuilder.Entity<PushDevice>().HasIndex(x => x.FirebaseToken).IsUnique();
modelBuilder.Entity<Chat>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Message>().HasQueryFilter(x =>
TenantBypass || (x.Chat != null && x.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageAttachment>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageReaction>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<PushDevice>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxAccountState>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxLoginChallenge>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Chat>()
.Property(x => x.Kind)
.HasConversion<string>();
@@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
.HasForeignKey(x => x.MessageId)
.OnDelete(DeleteBehavior.Cascade);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var newChats = ChangeTracker.Entries<Chat>()
.Where(x => x.State == EntityState.Added && x.Entity.UserId is null)
.ToArray();
var userId = currentUser?.UserId;
if (userId is null && currentUser is not null && newChats.Length > 0)
{
var existingUsers = await Users.Select(x => x.Id).Take(2).ToArrayAsync(cancellationToken);
if (existingUsers.Length == 1) userId = existingUsers[0];
}
if (userId is not null)
{
foreach (var entry in newChats)
{
entry.Entity.UserId = userId;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,37 @@
namespace QMax.Api.Infrastructure.Auth;
public interface ICurrentUserAccessor
{
Guid? UserId { get; }
bool BypassTenantFilter { get; }
IDisposable Push(Guid? userId, bool bypassTenantFilter = false);
}
public sealed class CurrentUserAccessor : ICurrentUserAccessor
{
private static readonly AsyncLocal<State?> Current = new();
public Guid? UserId => Current.Value?.UserId;
public bool BypassTenantFilter => Current.Value?.BypassTenantFilter == true;
public IDisposable Push(Guid? userId, bool bypassTenantFilter = false)
{
var previous = Current.Value;
Current.Value = new State(userId, bypassTenantFilter);
return new PopScope(previous);
}
private sealed record State(Guid? UserId, bool BypassTenantFilter);
private sealed class PopScope(State? previous) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
Current.Value = previous;
_disposed = true;
}
}
}
+10 -3
View File
@@ -1,14 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Hubs;
[Authorize]
public sealed class QMaxHub : Hub
public sealed class QMaxHub(QMaxDbContext db) : Hub
{
public Task JoinChat(string chatId)
public async Task JoinChat(string chatId)
{
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
if (!Guid.TryParse(chatId, out var id) || !await db.Chats.AnyAsync(x => x.Id == id && x.UserId == Context.User!.GetUserId()))
{
throw new HubException("Chat not found.");
}
await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{id}");
}
public Task LeaveChat(string chatId)
@@ -4,6 +4,8 @@ public interface IMaxBridgeClient
{
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
@@ -45,6 +45,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
}
public Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>([
@@ -2,12 +2,14 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Max;
public sealed class WorkerMaxBridgeClient(
HttpClient httpClient,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -21,7 +23,17 @@ public sealed class WorkerMaxBridgeClient(
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: false, cancellationToken);
}
public async Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: true, cancellationToken);
}
private async Task<MaxBridgeStatus> BeginPhoneLoginCoreAsync(string phoneNumber, bool forceNewSession, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber, force = forceNewSession }, cancellationToken)
?? ErrorStatus("Worker returned an empty login status.");
}
@@ -171,7 +183,9 @@ public sealed class WorkerMaxBridgeClient(
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, path);
AddAccountHeader(request);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -217,6 +231,7 @@ public sealed class WorkerMaxBridgeClient(
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
AddAccountHeader(request);
if (body is not null)
{
request.Content = JsonContent.Create(body, options: JsonOptions);
@@ -239,6 +254,15 @@ public sealed class WorkerMaxBridgeClient(
}
}
private void AddAccountHeader(HttpRequestMessage request)
{
if (currentUser.UserId is not { } userId)
{
throw new InvalidOperationException("A QMAX user context is required for a PyMax request.");
}
request.Headers.Add("X-QMax-Account-Id", userId.ToString("N"));
}
private static MaxBridgeStatus ErrorStatus(string error)
{
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
+64 -2
View File
@@ -90,6 +90,7 @@ builder.Services.AddControllers().AddJsonOptions(options =>
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
@@ -117,6 +118,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
app.UseCors();
app.UseAuthentication();
app.Use(async (context, next) =>
{
var currentUser = context.RequestServices.GetRequiredService<ICurrentUserAccessor>();
var userId = context.User.Identity?.IsAuthenticated == true ? context.User.GetUserId() : (Guid?)null;
using (currentUser.Push(userId))
{
await next(context);
}
});
app.UseAuthorization();
app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax");
@@ -127,8 +137,12 @@ using (var scope = app.Services.CreateScope())
Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
using (currentUser.Push(null, bypassTenantFilter: true))
{
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
}
}
app.Run();
@@ -152,6 +166,34 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
}
}
if (!chatColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN UserId TEXT NULL;");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE Chats SET UserId = {legacyUserId} WHERE UserId IS NULL;");
}
}
var maxStateColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MaxAccountStates);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync()) maxStateColumns.Add(reader.GetString(1));
}
if (maxStateColumns.Count > 0 && !maxStateColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MaxAccountStates ADD COLUMN UserId TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE MaxAccountStates SET UserId = {legacyUserId} WHERE UserId = '00000000-0000-0000-0000-000000000000';");
}
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxAccountStates_UserId ON MaxAccountStates (UserId);");
}
if (!chatColumns.Contains("WebUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;");
@@ -218,6 +260,10 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db);
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Chats_ExternalId;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Chats_UserId_ExternalId ON Chats (UserId, ExternalId);");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Users_PhoneNumber;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_PhoneNumber ON Users (PhoneNumber) WHERE PhoneNumber IS NOT NULL AND PhoneNumber <> '';");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId)
@@ -255,6 +301,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MaxLoginChallenges (
Id TEXT NOT NULL CONSTRAINT PK_MaxLoginChallenges PRIMARY KEY,
UserId TEXT NOT NULL,
SecretHash TEXT NOT NULL,
DeviceName TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
ExpiresAt TEXT NOT NULL,
CompletedAt TEXT NULL,
FailedAttempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT FK_MaxLoginChallenges_Users_UserId FOREIGN KEY (UserId) REFERENCES Users (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxLoginChallenges_SecretHash ON MaxLoginChallenges (SecretHash);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_MaxLoginChallenges_UserId ON MaxLoginChallenges (UserId);");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey);
@@ -10,6 +10,7 @@ using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using System.Collections.Concurrent;
using System.Text;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -17,6 +18,7 @@ public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxBridgeSyncService> logger)
{
private const string PreviewExternalIdPrefix = "preview:";
@@ -44,22 +46,22 @@ public sealed class MaxBridgeSyncService(
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
var userId = currentUser.UserId ?? throw new InvalidOperationException("MAX sync requires a user context.");
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = options.MaxPhoneNumber;
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
catch (Exception statusError)
{
@@ -465,7 +467,7 @@ public sealed class MaxBridgeSyncService(
}
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
foreach (var request in genericMediaHistoryRequests
@@ -1401,10 +1403,10 @@ public sealed class MaxBridgeSyncService(
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
}
var titleMatches = await deletedChats
var titleMatches = (await deletedChats.ToListAsync(cancellationToken))
.OrderByDescending(x => x.DeletedAt)
.Take(2)
.ToListAsync(cancellationToken);
.ToList();
return titleMatches.Count == 1 ? titleMatches[0] : null;
}
+3 -1
View File
@@ -5,6 +5,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -14,6 +15,7 @@ public sealed class MaxOutboxService(
IAttachmentStorageService storage,
ChatProjectionService projection,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxService> logger)
{
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
@@ -338,6 +340,6 @@ public sealed class MaxOutboxService(
"MessageUpdated",
projection.ToDto(updated),
cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
}
+27 -3
View File
@@ -1,11 +1,15 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxOutboxWorker(
IServiceScopeFactory scopeFactory,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -48,9 +52,29 @@ public sealed class MaxOutboxWorker(
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken);
Guid[] userIds;
await using (var discoveryScope = scopeFactory.CreateAsyncScope())
using (currentUser.Push(null, bypassTenantFilter: true))
{
var db = discoveryScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
await using (var scope = scopeFactory.CreateAsyncScope())
{
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken);
}
}
}
catch (Exception ex)
{
+26 -1
View File
@@ -1,11 +1,16 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxSyncWorker(
IOptions<QMaxOptions> options,
MaxBridgeSyncService syncService,
IServiceScopeFactory scopeFactory,
ICurrentUserAccessor currentUser,
ILogger<MaxSyncWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -19,7 +24,27 @@ public sealed class MaxSyncWorker(
{
try
{
await syncService.SyncOnceAsync(stoppingToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Guid[] userIds;
using (currentUser.Push(null, bypassTenantFilter: true))
{
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
{
await syncService.SyncOnceAsync(stoppingToken);
}
}
}
catch (Exception ex)
{
+91
View File
@@ -0,0 +1,91 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Controllers;
using QMax.Api.Infrastructure.Max;
using Microsoft.Extensions.Options;
namespace QMax.Tests;
public sealed class TenantIsolationTests
{
[Fact]
public async Task Phone_challenge_issues_tokens_only_after_max_code_completion()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<QMaxDbContext>().UseSqlite(connection).Options;
var currentUser = new CurrentUserAccessor();
await using var db = new QMaxDbContext(options, currentUser);
using (currentUser.Push(null, bypassTenantFilter: true)) await db.Database.EnsureCreatedAsync();
var qmax = Options.Create(new QMaxOptions
{
PairingCode = "invite",
JwtSecret = "tenant-test-secret-that-is-at-least-32-characters"
});
var controller = new AuthController(db, new TokenService(qmax), qmax, new MockMaxBridgeClient(), currentUser);
var started = await controller.BeginPhoneAuth(
new BeginPhoneAuthRequest("8 (900) 000-00-01", "test phone", "invite"),
CancellationToken.None);
var challenge = Assert.IsType<PhoneAuthChallengeResponse>(started.Value);
Assert.NotEqual(Guid.Empty, challenge.ChallengeId);
Assert.Empty(await db.UserSessions.IgnoreQueryFilters().ToArrayAsync());
var completed = await controller.CompletePhoneAuth(
new CompletePhoneAuthRequest(challenge.ChallengeId, challenge.ChallengeToken, "123456"),
CancellationToken.None);
var auth = Assert.IsType<AuthResponse>(completed.Value);
Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken));
Assert.Single(await db.UserSessions.IgnoreQueryFilters().ToArrayAsync());
Assert.Equal("+79000000001", auth.User.PhoneNumber);
}
[Fact]
public async Task Chats_and_messages_are_visible_only_to_the_current_user()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<QMaxDbContext>().UseSqlite(connection).Options;
var currentUser = new CurrentUserAccessor();
var firstUser = new User { PhoneNumber = "+79000000001" };
var secondUser = new User { PhoneNumber = "+79000000002" };
await using (var setup = new QMaxDbContext(options, currentUser))
using (currentUser.Push(null, bypassTenantFilter: true))
{
await setup.Database.EnsureCreatedAsync();
setup.Users.AddRange(firstUser, secondUser);
await setup.SaveChangesAsync();
}
Guid firstChatId;
await using (var db = new QMaxDbContext(options, currentUser))
using (currentUser.Push(firstUser.Id))
{
var chat = new Chat { ExternalId = "same-max-chat", Title = "First" };
chat.Messages.Add(new Message { Text = "first secret", Direction = MessageDirection.Incoming });
db.Chats.Add(chat);
await db.SaveChangesAsync();
firstChatId = chat.Id;
}
await using (var db = new QMaxDbContext(options, currentUser))
using (currentUser.Push(secondUser.Id))
{
var chat = new Chat { ExternalId = "same-max-chat", Title = "Second" };
chat.Messages.Add(new Message { Text = "second secret", Direction = MessageDirection.Incoming });
db.Chats.Add(chat);
await db.SaveChangesAsync();
Assert.Single(await db.Chats.ToArrayAsync());
Assert.Equal("Second", (await db.Chats.SingleAsync()).Title);
Assert.Single(await db.Messages.ToArrayAsync());
Assert.False(await db.Chats.AnyAsync(x => x.Id == firstChatId));
}
}
}