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
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: Current shape:
- `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog. - `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`. - `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5. - `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
@@ -44,22 +44,15 @@ qmax.kusoft.xyz {
Set strong values in `.env`: Set strong values in `.env`:
- `QMAX_JWT_SECRET` - at least 32 random characters. - `QMAX_JWT_SECRET` - at least 32 random characters.
- `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`. - `QMAX_PAIRING_CODE` - optional server registration code shared with allowed users; leave it empty for open registration.
- `QMAX_MAX_PHONE_NUMBER` - the phone number linked to the MAX account used by PyMax.
Secrets must stay in `.env`, not in git. Secrets must stay in `.env`, not in git.
## MAX Login ## 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 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.
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.
## Android Pairing ## Android Pairing
@@ -73,7 +66,9 @@ cd android
On first launch: On first launch:
- Server: `https://qmax.kusoft.xyz` - 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. 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. 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; - chat list and message history through PyMax;
- text sending through PyMax; - text sending through PyMax;
- attachment upload through PyMax file/photo/video models; - attachment upload through PyMax file/photo/video models;
- image, video, file and voice attachment projection through the API; - 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; - 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" applicationId = "xyz.kusoft.qmax"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 54 versionCode = 55
versionName = "0.1.53" versionName = "0.1.54"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -275,11 +275,6 @@ private fun QMaxApp(vm: QMaxViewModel) {
Box(Modifier.fillMaxSize().background(QMaxBackground)) { Box(Modifier.fillMaxSize().background(QMaxBackground)) {
when { when {
state.session == null && state.pairingCode.isNotBlank() -> AutoLoginScreen(
loading = state.loading,
error = state.error,
onRetry = vm::login
)
state.session == null -> LoginScreen(vm) state.session == null -> LoginScreen(vm)
state.selectedChat != null -> ChatScreen(vm) state.selectedChat != null -> ChatScreen(vm)
else -> ChatListScreen(vm) else -> ChatListScreen(vm)
@@ -359,6 +354,17 @@ private fun LoginScreen(vm: QMaxViewModel) {
value = state.serverUrl, value = state.serverUrl,
onValueChange = vm::updateServerUrl, onValueChange = vm::updateServerUrl,
label = { Text("Сервер") }, 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, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -366,15 +372,29 @@ private fun LoginScreen(vm: QMaxViewModel) {
OutlinedTextField( OutlinedTextField(
value = state.pairingCode, value = state.pairingCode,
onValueChange = vm::updatePairingCode, onValueChange = vm::updatePairingCode,
label = { Text("Код подключения") }, label = { Text("Код регистрации сервера (если задан)") },
placeholder = { Text("qmax-...") }, enabled = state.phoneAuthChallenge == null,
supportingText = { Text("Код выдаёт сервер QMAX для привязки телефона") }, singleLine = true,
modifier = Modifier.fillMaxWidth()
)
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, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
Button(onClick = vm::login, modifier = Modifier.fillMaxWidth()) { Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text("Войти") Text("Войти в QMAX")
}
} }
ErrorLine(state.error) 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.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto 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.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
@@ -68,6 +69,18 @@ class QMaxRepository(
return response 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() { suspend fun logout() {
tokenStore.clear() tokenStore.clear()
draftStore.clearAll() draftStore.clearAll()
@@ -24,6 +24,20 @@ data class DeviceLoginRequest(
val deviceName: String 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 @Serializable
data class RefreshTokenRequest(val refreshToken: String) 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.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest 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.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto 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.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest 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.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest import xyz.kusoft.qmax.core.model.MarkChatReadRequest
@@ -73,6 +76,14 @@ class QMaxApi {
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName)) 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 { suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken)) 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.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.PhoneContactDto 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.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -32,6 +33,8 @@ import java.util.UUID
data class QMaxUiState( data class QMaxUiState(
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL, val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE, val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val phoneNumber: String = "",
val phoneAuthChallenge: PhoneAuthChallengeResponse? = null,
val session: QMaxSession? = null, val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(), val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(), val contacts: List<ContactDto> = emptyList(),
@@ -133,7 +136,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
pushRegistrationInFlightForToken = null pushRegistrationInFlightForToken = null
pushRegisteredForToken = null pushRegisteredForToken = null
realtime.disconnect() realtime.disconnect()
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) { if (!autoLoginAttempted && state.value.phoneNumber.isNotBlank()) {
autoLoginAttempted = true autoLoginAttempted = true
autoLoginJob?.cancel() autoLoginJob?.cancel()
autoLoginJob = viewModelScope.launch { autoLoginJob = viewModelScope.launch {
@@ -162,6 +165,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(pairingCode = value) state.value = state.value.copy(pairingCode = value)
} }
fun updatePhoneNumber(value: String) {
state.value = state.value.copy(phoneNumber = value)
}
fun updateComposer(value: String) { fun updateComposer(value: String) {
state.value = state.value.copy(composerText = value) state.value = state.value.copy(composerText = value)
if (state.value.editTarget != null) { if (state.value.editTarget != null) {
@@ -263,7 +270,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun login() = launchLoading { fun login() = launchLoading {
val current = state.value 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 { fun logout() = viewModelScope.launch {
+2 -4
View File
@@ -5,11 +5,9 @@ QMAX_TLS_EMAIL=admin@kusoft.xyz
# Generate with: openssl rand -base64 48 # Generate with: openssl rand -base64 48
QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters 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 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= QMAX_CORS_ALLOWED_ORIGINS=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container # 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__ReleasesPath: /data/releases
QMax__JwtSecret: ${QMAX_JWT_SECRET} QMax__JwtSecret: ${QMAX_JWT_SECRET}
QMax__PairingCode: ${QMAX_PAIRING_CODE} QMax__PairingCode: ${QMAX_PAIRING_CODE}
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
QMax__MaxMode: Worker QMax__MaxMode: Worker
QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002 QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-} QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
@@ -40,7 +39,6 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
PORT: 3002 PORT: 3002
PYMAX_PHONE_NUMBER: ${QMAX_MAX_PHONE_NUMBER}
PYMAX_SESSION_DIR: /data/pymax-session PYMAX_SESSION_DIR: /data/pymax-session
PYMAX_SEND_ROOTS: /qmax-data:/tmp PYMAX_SEND_ROOTS: /qmax-data:/tmp
PYMAX_CHAT_FETCH_LIMIT: ${QMAX_PYMAX_CHAT_FETCH_LIMIT:-350} PYMAX_CHAT_FETCH_LIMIT: ${QMAX_PYMAX_CHAT_FETCH_LIMIT:-350}
+7 -7
View File
@@ -1,14 +1,14 @@
{ {
"slug": "qmax", "slug": "qmax",
"name": "QMAX", "name": "QMAX",
"version": "0.1.39", "version": "0.1.54",
"androidVersionCode": 40, "androidVersionCode": 55,
"channel": "stable", "channel": "stable",
"platform": "android", "platform": "android",
"packageKind": "apk", "packageKind": "apk",
"downloadPath": "/api/app-updates/android/download/qmax-0.1.39-stable.apk", "downloadPath": "/api/app-updates/android/download/qmax-0.1.54-stable.apk",
"packageSizeBytes": 17730369, "packageSizeBytes": 17862069,
"sha256": "fb1ba02daf4a5e99972794bd69965bef8219388c96344902b9d8a67ed2635803", "sha256": "69a65b8c8d00c16f2ece211f1e993b0a2da46776f8afa436db384fb818c2f147",
"notes": "Chat composer now follows the Android keyboard height like Telegram, and downloaded video, audio, and document attachments keep correct local cache extensions.", "notes": "QMAX 0.1.54",
"publishedAt": "2026-07-02T18:38:00.3130417Z" "publishedAt": "2026-07-13T19:13:39.6141836Z"
} }
Binary file not shown.
+10 -6
View File
@@ -4,10 +4,10 @@
flowchart LR flowchart LR
A["Android app<br/>Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API<br/>ASP.NET Core"] A["Android app<br/>Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API<br/>ASP.NET Core"]
A -->|SignalR planned/available| B 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 --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"] 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"] 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 ## 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. - 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. - `.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. - 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. - 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`. - 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. - 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`. - 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 ## Remaining Production Checks
- Keep PyMax session-expiration monitoring active. - 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 asyncio
import base64 import base64
import contextvars
import json import json
import mimetypes import mimetypes
import os import os
@@ -24,9 +25,8 @@ from pymax.types import ContactInfo
PORT = int(os.environ.get("PORT", "3002")) PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or "" 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") 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")) CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350"))
HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80")) 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] 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: def archive_session_dir(session_dir: pathlib.Path) -> str | None:
if not SESSION_DIR.exists(): if not session_dir.exists():
return None return None
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") 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 suffix = 1
while target.exists(): 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 suffix += 1
shutil.move(str(SESSION_DIR), str(target)) shutil.move(str(session_dir), str(target))
return str(target) return str(target)
@@ -372,7 +372,9 @@ class DeferredPasswordProvider:
class PyMaxRuntime: 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.client: Client | None = None
self.task: asyncio.Task[None] | None = None self.task: asyncio.Task[None] | None = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -409,15 +411,22 @@ class PyMaxRuntime:
return return
self.ready.clear() self.ready.clear()
self.last_error = None 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: if not use_phone:
self.last_error = "PYMAX_PHONE_NUMBER is not configured." self.last_error = "PYMAX_PHONE_NUMBER is not configured."
return return
SESSION_DIR.mkdir(parents=True, exist_ok=True) self.phone = use_phone
SESSION_DIR.chmod(0o700) 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( client = Client(
phone=use_phone, phone=use_phone,
work_dir=str(SESSION_DIR), work_dir=str(self.session_dir),
session_name=SESSION_NAME, session_name=SESSION_NAME,
sms_code_provider=self.code_provider, sms_code_provider=self.code_provider,
password_provider=self.password_provider, password_provider=self.password_provider,
@@ -474,7 +483,7 @@ class PyMaxRuntime:
async with self.lock: async with self.lock:
await self._stop_locked() await self._stop_locked()
self.last_error = None self.last_error = None
archive_session_dir() archive_session_dir(self.session_dir)
async def _stop_locked(self) -> None: async def _stop_locked(self) -> None:
client = self.client client = self.client
@@ -493,7 +502,35 @@ class PyMaxRuntime:
await task 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: 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: async def login_start(request: web.Request) -> web.Response:
data = await read_json(request) data = await read_json(request)
phone = str(data.get("phoneNumber") or PHONE_NUMBER).strip() 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.reset_session_for_login()
await runtime.ensure_started(phone) await runtime.ensure_started(phone)
return json_response(status_payload()) return json_response(status_payload())
@@ -969,18 +1007,20 @@ async def contacts(_request: web.Request) -> web.Response:
def load_phone_contact_ids() -> set[int]: def load_phone_contact_ids() -> set[int]:
path = runtime.session_dir / "phone-contact-ids.json"
try: 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): except (OSError, ValueError, TypeError):
return set() return set()
return {value for item in values if (value := coerce_int(item)) is not None and value > 0} 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: def save_phone_contact_ids(contact_ids: set[int]) -> None:
SESSION_DIR.mkdir(parents=True, exist_ok=True) runtime.session_dir.mkdir(parents=True, exist_ok=True)
part_path = PHONE_CONTACT_IDS_FILE.with_suffix(".json.part") 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.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: 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: 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("/health", health)
app.router.add_get("/status", status) app.router.add_get("/status", status)
app.router.add_post("/login/start", login_start) 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: async def on_cleanup(_app: web.Application) -> None:
await runtime.stop() await registry.stop()
if __name__ == "__main__": 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; namespace QMax.Api.Contracts;
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName); 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 RefreshTokenRequest(string RefreshToken);
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User); public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath); 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;
using QMax.Api.Data.Entities; using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth; using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers; namespace QMax.Api.Controllers;
@@ -15,10 +16,117 @@ namespace QMax.Api.Controllers;
public sealed class AuthController( public sealed class AuthController(
QMaxDbContext db, QMaxDbContext db,
ITokenService tokenService, ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase IOptions<QMaxOptions> options,
IMaxBridgeClient maxBridge,
ICurrentUserAccessor currentUser) : ControllerBase
{ {
private readonly QMaxOptions _options = options.Value; 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] [AllowAnonymous]
[HttpPost("device/login")] [HttpPost("device/login")]
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken) public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
@@ -127,4 +235,38 @@ public sealed class AuthController(
return expectedBytes.Length == actualBytes.Length && return expectedBytes.Length == actualBytes.Length &&
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); 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.Hubs;
using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage; using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Services; using QMax.Api.Services;
namespace QMax.Api.Controllers; namespace QMax.Api.Controllers;
@@ -64,7 +65,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken); 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); return projection.ToDto(chat);
} }
@@ -102,7 +103,7 @@ public sealed class ChatsController(
{ {
chat.UnreadCount = 0; chat.UnreadCount = 0;
await db.SaveChangesAsync(cancellationToken); 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 var query = db.Messages
@@ -240,7 +241,7 @@ public sealed class ChatsController(
chat.UnreadCount = 0; chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
} }
return NoContent(); return NoContent();
@@ -279,7 +280,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files); DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent(); return NoContent();
} }
@@ -327,7 +328,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files); DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent(); return NoContent();
} }
@@ -450,7 +451,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); 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; return dto;
} }
@@ -504,7 +505,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); 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; 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.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(); return NoContent();
} }
@@ -717,7 +718,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken); 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; return dto;
} }
@@ -785,7 +786,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); 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; return dto;
} }
@@ -929,7 +930,7 @@ public sealed class ChatsController(
chat.WebUrl = nextWebUrl; chat.WebUrl = nextWebUrl;
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return chat.WebUrl; 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.Hubs;
using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Max;
using QMax.Api.Services; using QMax.Api.Services;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers; namespace QMax.Api.Controllers;
@@ -21,11 +22,8 @@ public sealed class MaxController(
MaxBridgeSyncService syncService, MaxBridgeSyncService syncService,
QMaxDbContext db, QMaxDbContext db,
IHubContext<QMaxHub> hubContext, IHubContext<QMaxHub> hubContext,
ChatProjectionService projection, ChatProjectionService projection) : ControllerBase
IOptions<QMaxOptions> options) : ControllerBase
{ {
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")] [HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken) public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{ {
@@ -37,7 +35,12 @@ public sealed class MaxController(
[HttpPost("login/start")] [HttpPost("login/start")]
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken) 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)) if (string.IsNullOrWhiteSpace(phone))
{ {
return BadRequest("Phone number is required."); 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."); 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); return projection.ToDto(chat);
} }
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken) 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) if (state is null)
{ {
state = new MaxAccountState { Id = 1 }; state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state); 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.Status = status.Status;
state.IsAuthorized = status.IsAuthorized; state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url; state.LastUrl = status.Url;
state.LastError = status.LastError; state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken); 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) private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
+2
View File
@@ -3,6 +3,8 @@ namespace QMax.Api.Data.Entities;
public sealed class Chat public sealed class Chat
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public User? User { get; set; }
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
public ChatKind Kind { get; set; } = ChatKind.MaxDialog; public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat"; public string Title { get; set; } = "MAX chat";
@@ -2,7 +2,8 @@ namespace QMax.Api.Data.Entities;
public sealed class MaxAccountState 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 PhoneNumber { get; set; } = "";
public string Status { get; set; } = "NotStarted"; public string Status { get; set; } = "NotStarted";
public bool IsAuthorized { get; set; } 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 Microsoft.EntityFrameworkCore;
using QMax.Api.Data.Entities; using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Data; 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<User> Users => Set<User>();
public DbSet<UserSession> UserSessions => Set<UserSession>(); public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<Chat> Chats => Set<Chat>(); 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<MessageReaction> MessageReactions => Set<MessageReaction>();
public DbSet<PushDevice> PushDevices => Set<PushDevice>(); public DbSet<PushDevice> PushDevices => Set<PushDevice>();
public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>(); public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>();
public DbSet<MaxLoginChallenge> MaxLoginChallenges => Set<MaxLoginChallenge>();
protected override void OnModelCreating(ModelBuilder modelBuilder) 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<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 => x.DeletedAt);
modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt }); modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt });
modelBuilder.Entity<Message>().HasIndex(x => new { x.ChatId, x.SentAt }); 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<MessageReaction>().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique();
modelBuilder.Entity<PushDevice>().HasIndex(x => x.FirebaseToken).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>() modelBuilder.Entity<Chat>()
.Property(x => x.Kind) .Property(x => x.Kind)
.HasConversion<string>(); .HasConversion<string>();
@@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
.HasForeignKey(x => x.MessageId) .HasForeignKey(x => x.MessageId)
.OnDelete(DeleteBehavior.Cascade); .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.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Hubs; namespace QMax.Api.Infrastructure.Hubs;
[Authorize] [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) public Task LeaveChat(string chatId)
@@ -4,6 +4,8 @@ public interface IMaxBridgeClient
{ {
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken); Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, 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<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken); Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
@@ -45,6 +45,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
} }
public Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken) public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<MaxContact>>([ return Task.FromResult<IReadOnlyList<MaxContact>>([
@@ -2,12 +2,14 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using QMax.Api.Configuration; using QMax.Api.Configuration;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Max; namespace QMax.Api.Infrastructure.Max;
public sealed class WorkerMaxBridgeClient( public sealed class WorkerMaxBridgeClient(
HttpClient httpClient, HttpClient httpClient,
IOptions<QMaxOptions> options, IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); 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) 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."); ?? ErrorStatus("Worker returned an empty login status.");
} }
@@ -171,7 +183,9 @@ public sealed class WorkerMaxBridgeClient(
{ {
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}"; 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) if (!response.IsSuccessStatusCode)
{ {
var content = await response.Content.ReadAsStringAsync(cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -217,6 +231,7 @@ public sealed class WorkerMaxBridgeClient(
{ {
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
using var request = new HttpRequestMessage(method, path.TrimStart('/')); using var request = new HttpRequestMessage(method, path.TrimStart('/'));
AddAccountHeader(request);
if (body is not null) if (body is not null)
{ {
request.Content = JsonContent.Create(body, options: JsonOptions); 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) private static MaxBridgeStatus ErrorStatus(string error)
{ {
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow); return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
+62
View File
@@ -90,6 +90,7 @@ builder.Services.AddControllers().AddJsonOptions(options =>
}); });
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
builder.Services.AddSingleton<ITokenService, TokenService>(); builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>(); builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>(); builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
@@ -117,6 +118,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
app.UseCors(); app.UseCors();
app.UseAuthentication(); 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.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax"); app.MapHub<QMaxHub>("/hubs/qmax");
@@ -127,9 +137,13 @@ using (var scope = app.Services.CreateScope())
Directory.CreateDirectory(options.StoragePath); Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath); Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
using (currentUser.Push(null, bypassTenantFilter: true))
{
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db); await EnsureCompatibilitySchemaAsync(db);
} }
}
app.Run(); 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")) if (!chatColumns.Contains("WebUrl"))
{ {
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;"); 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.MergeOutgoingRemoteAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(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_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(""" await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON 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 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(""" await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey); ON MessageReactions (MessageId, ActorKey);
@@ -10,6 +10,7 @@ using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage; using QMax.Api.Infrastructure.Storage;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text; using System.Text;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services; namespace QMax.Api.Services;
@@ -17,6 +18,7 @@ public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient, IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext, IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxBridgeSyncService> logger) ILogger<MaxBridgeSyncService> logger)
{ {
private const string PreviewExternalIdPrefix = "preview:"; private const string PreviewExternalIdPrefix = "preview:";
@@ -44,22 +46,22 @@ public sealed class MaxBridgeSyncService(
var status = await maxBridgeClient.GetStatusAsync(cancellationToken); var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value; var userId = currentUser.UserId ?? throw new InvalidOperationException("MAX sync requires a user context.");
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null) if (state is null)
{ {
state = new MaxAccountState { Id = 1 }; state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state); 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.Status = status.Status;
state.IsAuthorized = status.IsAuthorized; state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url; state.LastUrl = status.Url;
state.LastError = status.LastError; state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken); 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) 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 foreach (var request in genericMediaHistoryRequests
@@ -1401,10 +1403,10 @@ public sealed class MaxBridgeSyncService(
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken); return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
} }
var titleMatches = await deletedChats var titleMatches = (await deletedChats.ToListAsync(cancellationToken))
.OrderByDescending(x => x.DeletedAt) .OrderByDescending(x => x.DeletedAt)
.Take(2) .Take(2)
.ToListAsync(cancellationToken); .ToList();
return titleMatches.Count == 1 ? titleMatches[0] : null; 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.Hubs;
using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage; using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services; namespace QMax.Api.Services;
@@ -14,6 +15,7 @@ public sealed class MaxOutboxService(
IAttachmentStorageService storage, IAttachmentStorageService storage,
ChatProjectionService projection, ChatProjectionService projection,
IHubContext<QMaxHub> hubContext, IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxService> logger) ILogger<MaxOutboxService> logger)
{ {
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10); private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
@@ -338,6 +340,6 @@ public sealed class MaxOutboxService(
"MessageUpdated", "MessageUpdated",
projection.ToDto(updated), projection.ToDto(updated),
cancellationToken); cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
} }
} }
+25 -1
View File
@@ -1,11 +1,15 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using QMax.Api.Configuration; using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services; namespace QMax.Api.Services;
public sealed class MaxOutboxWorker( public sealed class MaxOutboxWorker(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<QMaxOptions> options, IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxWorker> logger) : BackgroundService ILogger<MaxOutboxWorker> logger) : BackgroundService
{ {
private readonly QMaxOptions _options = options.Value; private readonly QMaxOptions _options = options.Value;
@@ -48,10 +52,30 @@ public sealed class MaxOutboxWorker(
try try
{ {
await using var scope = scopeFactory.CreateAsyncScope(); 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>(); var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken); await process(outbox, stoppingToken);
} }
}
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "QMAX {WorkerName} tick failed.", workerName); logger.LogWarning(ex, "QMAX {WorkerName} tick failed.", workerName);
+25
View File
@@ -1,11 +1,16 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using QMax.Api.Configuration; using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services; namespace QMax.Api.Services;
public sealed class MaxSyncWorker( public sealed class MaxSyncWorker(
IOptions<QMaxOptions> options, IOptions<QMaxOptions> options,
MaxBridgeSyncService syncService, MaxBridgeSyncService syncService,
IServiceScopeFactory scopeFactory,
ICurrentUserAccessor currentUser,
ILogger<MaxSyncWorker> logger) : BackgroundService ILogger<MaxSyncWorker> logger) : BackgroundService
{ {
private readonly QMaxOptions _options = options.Value; private readonly QMaxOptions _options = options.Value;
@@ -18,9 +23,29 @@ public sealed class MaxSyncWorker(
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{
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); await syncService.SyncOnceAsync(stoppingToken);
} }
}
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "QMAX background sync tick failed."); logger.LogWarning(ex, "QMAX background sync tick failed.");
+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));
}
}
}