diff --git a/README.md b/README.md index 71d190c..c024402 100644 --- a/README.md +++ b/README.md @@ -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//` 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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b21c949..94fafd8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 1715aef..8a936d6 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -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) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index 4181331..103757f 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -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() diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt index 5181f1c..942020b 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt @@ -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) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt index 6f787e5..6e720ef 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -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)) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 683abb6..aa3dc3e 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -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 = emptyList(), val contacts: List = 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 { diff --git a/deploy/.env.example b/deploy/.env.example index b669ba2..a0dd013 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7f2bb60..354c26a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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} diff --git a/deploy/releases/android/latest.json b/deploy/releases/android/latest.json index ad7ca3d..aabb370 100644 --- a/deploy/releases/android/latest.json +++ b/deploy/releases/android/latest.json @@ -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" +} \ No newline at end of file diff --git a/deploy/releases/android/qmax-0.1.54-stable.apk b/deploy/releases/android/qmax-0.1.54-stable.apk new file mode 100644 index 0000000..85417d2 Binary files /dev/null and b/deploy/releases/android/qmax-0.1.54-stable.apk differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6eb2a93..d5f1f37 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,10 +4,10 @@ flowchart LR A["Android app
Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API
ASP.NET Core"] A -->|SignalR planned/available| B - B --> C["SQLite cache
chats/messages/sessions"] + B --> C["SQLite cache
tenant-scoped chats/messages/sessions"] B --> D["Local storage
attachments/releases"] B -->|HTTP internal| E["MAX worker
Python + PyMax"] - E -->|persistent PyMax session| F["MAX mobile API"] + E -->|isolated session per user| F["MAX mobile API"] B --> G["Caddy TLS
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. diff --git a/pymax-worker/src/__pycache__/server.cpython-313.pyc b/pymax-worker/src/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..e9fa7c3 Binary files /dev/null and b/pymax-worker/src/__pycache__/server.cpython-313.pyc differ diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 7770551..4bd9b23 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -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__": diff --git a/scripts/__pycache__/publish-qmax-argus.cpython-313.pyc b/scripts/__pycache__/publish-qmax-argus.cpython-313.pyc new file mode 100644 index 0000000..3d0dcb0 Binary files /dev/null and b/scripts/__pycache__/publish-qmax-argus.cpython-313.pyc differ diff --git a/scripts/migrate-legacy-pymax.sh b/scripts/migrate-legacy-pymax.sh new file mode 100644 index 0000000..7a5e94b --- /dev/null +++ b/scripts/migrate-legacy-pymax.sh @@ -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 diff --git a/scripts/publish-qmax-argus.py b/scripts/publish-qmax-argus.py new file mode 100644 index 0000000..29eb2ca --- /dev/null +++ b/scripts/publish-qmax-argus.py @@ -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() diff --git a/server/QMax.Api/Contracts/AuthContracts.cs b/server/QMax.Api/Contracts/AuthContracts.cs index 5d48bea..c4385b3 100644 --- a/server/QMax.Api/Contracts/AuthContracts.cs +++ b/server/QMax.Api/Contracts/AuthContracts.cs @@ -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); diff --git a/server/QMax.Api/Controllers/AuthController.cs b/server/QMax.Api/Controllers/AuthController.cs index 30631db..6c35670 100644 --- a/server/QMax.Api/Controllers/AuthController.cs +++ b/server/QMax.Api/Controllers/AuthController.cs @@ -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 options) : ControllerBase + IOptions options, + IMaxBridgeClient maxBridge, + ICurrentUserAccessor currentUser) : ControllerBase { private readonly QMaxOptions _options = options.Value; + [AllowAnonymous] + [HttpPost("phone/start")] + public async Task> 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> 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> 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); } diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index e242369..07b4d44 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -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; } diff --git a/server/QMax.Api/Controllers/MaxController.cs b/server/QMax.Api/Controllers/MaxController.cs index a6f49ee..0f8d4d0 100644 --- a/server/QMax.Api/Controllers/MaxController.cs +++ b/server/QMax.Api/Controllers/MaxController.cs @@ -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 hubContext, - ChatProjectionService projection, - IOptions options) : ControllerBase + ChatProjectionService projection) : ControllerBase { - private readonly QMaxOptions _options = options.Value; - [HttpGet("status")] public async Task> Status(CancellationToken cancellationToken) { @@ -37,7 +35,12 @@ public sealed class MaxController( [HttpPost("login/start")] public async Task> 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) diff --git a/server/QMax.Api/Data/Entities/Chat.cs b/server/QMax.Api/Data/Entities/Chat.cs index c721c8c..cf7cb49 100644 --- a/server/QMax.Api/Data/Entities/Chat.cs +++ b/server/QMax.Api/Data/Entities/Chat.cs @@ -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"; diff --git a/server/QMax.Api/Data/Entities/MaxAccountState.cs b/server/QMax.Api/Data/Entities/MaxAccountState.cs index 65544a8..5ad8539 100644 --- a/server/QMax.Api/Data/Entities/MaxAccountState.cs +++ b/server/QMax.Api/Data/Entities/MaxAccountState.cs @@ -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; } diff --git a/server/QMax.Api/Data/Entities/MaxLoginChallenge.cs b/server/QMax.Api/Data/Entities/MaxLoginChallenge.cs new file mode 100644 index 0000000..7be8ea8 --- /dev/null +++ b/server/QMax.Api/Data/Entities/MaxLoginChallenge.cs @@ -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; } +} diff --git a/server/QMax.Api/Data/QMaxDbContext.cs b/server/QMax.Api/Data/QMaxDbContext.cs index b12354c..b83ad16 100644 --- a/server/QMax.Api/Data/QMaxDbContext.cs +++ b/server/QMax.Api/Data/QMaxDbContext.cs @@ -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 options) : DbContext(options) +public sealed class QMaxDbContext(DbContextOptions 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 Users => Set(); public DbSet UserSessions => Set(); public DbSet Chats => Set(); @@ -13,12 +16,20 @@ public sealed class QMaxDbContext(DbContextOptions options) : DbC public DbSet MessageReactions => Set(); public DbSet PushDevices => Set(); public DbSet MaxAccountStates => Set(); + public DbSet MaxLoginChallenges => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity().HasIndex(x => x.PhoneNumber); + modelBuilder.Entity().HasIndex(x => x.PhoneNumber).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.SecretHash).IsUnique(); + modelBuilder.Entity().HasKey(x => x.UserId); + modelBuilder.Entity() + .HasOne(x => x.User) + .WithOne() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity().HasIndex(x => x.RefreshTokenHash).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.ExternalId).IsUnique(); + modelBuilder.Entity().HasIndex(x => new { x.UserId, x.ExternalId }).IsUnique(); modelBuilder.Entity().HasIndex(x => x.DeletedAt); modelBuilder.Entity().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt }); modelBuilder.Entity().HasIndex(x => new { x.ChatId, x.SentAt }); @@ -28,6 +39,21 @@ public sealed class QMaxDbContext(DbContextOptions options) : DbC modelBuilder.Entity().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique(); modelBuilder.Entity().HasIndex(x => x.FirebaseToken).IsUnique(); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || x.UserId == TenantUserId); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || (x.Chat != null && x.Chat.UserId == TenantUserId)); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId)); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId)); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || x.UserId == TenantUserId); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || x.UserId == TenantUserId); + modelBuilder.Entity().HasQueryFilter(x => + TenantBypass || x.UserId == TenantUserId); + modelBuilder.Entity() .Property(x => x.Kind) .HasConversion(); @@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions options) : DbC .HasForeignKey(x => x.MessageId) .OnDelete(DeleteBehavior.Cascade); } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var newChats = ChangeTracker.Entries() + .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); + } } diff --git a/server/QMax.Api/Infrastructure/Auth/CurrentUserAccessor.cs b/server/QMax.Api/Infrastructure/Auth/CurrentUserAccessor.cs new file mode 100644 index 0000000..93f9358 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Auth/CurrentUserAccessor.cs @@ -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 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; + } + } +} diff --git a/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs b/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs index 30bd794..303402d 100644 --- a/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs +++ b/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs @@ -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) diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 3f5f017..47869cf 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -4,6 +4,8 @@ public interface IMaxBridgeClient { Task GetStatusAsync(CancellationToken cancellationToken); Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken); + Task BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) => + BeginPhoneLoginAsync(phoneNumber, cancellationToken); Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken); Task GetSnapshotAsync(CancellationToken cancellationToken); Task> FetchUpdatesAsync(CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 25bf91d..803ad78 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -45,6 +45,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult>(_updates); } + public Task BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) => + BeginPhoneLoginAsync(phoneNumber, cancellationToken); + public Task> FetchContactsAsync(CancellationToken cancellationToken) { return Task.FromResult>([ diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 7c0570f..7a9dc5d 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -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 options, + ICurrentUserAccessor currentUser, ILogger logger) : IMaxBridgeClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -21,7 +23,17 @@ public sealed class WorkerMaxBridgeClient( public async Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { - return await SendAsync(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken) + return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: false, cancellationToken); + } + + public async Task BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: true, cancellationToken); + } + + private async Task BeginPhoneLoginCoreAsync(string phoneNumber, bool forceNewSession, CancellationToken cancellationToken) + { + return await SendAsync(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); diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index 23bed3a..91302a3 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -90,6 +90,7 @@ builder.Services.AddControllers().AddJsonOptions(options => }); builder.Services.AddSignalR(); builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -117,6 +118,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseCors(); app.UseAuthentication(); +app.Use(async (context, next) => +{ + var currentUser = context.RequestServices.GetRequiredService(); + 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("/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(); - await db.Database.EnsureCreatedAsync(); - await EnsureCompatibilitySchemaAsync(db); + var currentUser = scope.ServiceProvider.GetRequiredService(); + 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(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); diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 3f72215..8b1c06c 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -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 hubContext, + ICurrentUserAccessor currentUser, ILogger 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(); - var options = scope.ServiceProvider.GetRequiredService>().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; } diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index 693ad16..af32294 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -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 hubContext, + ICurrentUserAccessor currentUser, ILogger 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); } } diff --git a/server/QMax.Api/Services/MaxOutboxWorker.cs b/server/QMax.Api/Services/MaxOutboxWorker.cs index 2f39efe..b4e1c66 100644 --- a/server/QMax.Api/Services/MaxOutboxWorker.cs +++ b/server/QMax.Api/Services/MaxOutboxWorker.cs @@ -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 options, + ICurrentUserAccessor currentUser, ILogger 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(); - await process(outbox, stoppingToken); + Guid[] userIds; + await using (var discoveryScope = scopeFactory.CreateAsyncScope()) + using (currentUser.Push(null, bypassTenantFilter: true)) + { + var db = discoveryScope.ServiceProvider.GetRequiredService(); + 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(); + await process(outbox, stoppingToken); + } + } } catch (Exception ex) { diff --git a/server/QMax.Api/Services/MaxSyncWorker.cs b/server/QMax.Api/Services/MaxSyncWorker.cs index 3d8fe93..ef5ba97 100644 --- a/server/QMax.Api/Services/MaxSyncWorker.cs +++ b/server/QMax.Api/Services/MaxSyncWorker.cs @@ -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 options, MaxBridgeSyncService syncService, + IServiceScopeFactory scopeFactory, + ICurrentUserAccessor currentUser, ILogger 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(); + 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) { diff --git a/tests/QMax.Tests/TenantIsolationTests.cs b/tests/QMax.Tests/TenantIsolationTests.cs new file mode 100644 index 0000000..38da9e1 --- /dev/null +++ b/tests/QMax.Tests/TenantIsolationTests.cs @@ -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().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(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(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().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)); + } + } +}