Switch MAX bridge to PyMax only

This commit is contained in:
sevenhill
2026-07-07 14:18:49 +03:00
parent 8efeff334a
commit af84343e19
19 changed files with 314 additions and 8088 deletions
+12 -17
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 keeps a browser session for `https://web.max.ru`. 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.
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.
- `worker` - Node/Playwright worker with a persistent MAX Web browser profile. - `pymax-worker` - Python/PyMax worker with a persistent MAX mobile API session.
- `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.
@@ -13,11 +13,9 @@ Current shape:
```powershell ```powershell
dotnet test QMax.slnx dotnet test QMax.slnx
python -m py_compile pymax-worker/src/server.py
cd android cd android
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon .\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
cd ..\worker
npm.cmd install
node --check src/server.js
``` ```
## Raspberry Pi Deployment ## Raspberry Pi Deployment
@@ -47,7 +45,7 @@ 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` - one-time-ish pairing password for your Android client and `/admin/max`.
- `QMAX_MAX_PHONE_NUMBER` - the phone number used for MAX Web login. - `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.
@@ -59,9 +57,9 @@ Open:
https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE
``` ```
Use **Start phone login**. If MAX shows a robot check, solve it manually on this page using the screenshot plus click/type forms. When MAX sends the confirmation code, submit it on the same page or from the Android app MAX panel. 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 browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts. 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
@@ -132,14 +130,11 @@ The Android app checks this manifest, compares semantic versions, downloads the
## Current MAX Mapping Status ## Current MAX Mapping Status
The deployed worker is authorized in MAX Web and currently maps: The deployed worker is authorized through PyMax and currently maps:
- chat list extraction from the left MAX Web dialog list; - chat list and message history through PyMax;
- visible chat history extraction when Android opens a dialog; - text sending through PyMax;
- text sending through the MAX Web composer; - attachment upload through PyMax file/photo/video models;
- attachment upload through the MAX Web file chooser; - image, video, file and voice attachment projection through the API;
- image, video, file and voice attachment projection from MAX Web;
- 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;
- manual browser inspection endpoints for future selector changes. - explicit session status and phone-code re-login from Android settings.
The bridge uses DOM selectors in `worker/src/server.js`, so MAX Web UI changes may require a worker redeploy without changing the Android app contract.
@@ -164,6 +164,7 @@ import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState import xyz.kusoft.qmax.ui.QMaxUiState
@@ -296,7 +297,7 @@ private fun LoginScreen(vm: QMaxViewModel) {
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue)
Text("MAX Web в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
OutlinedTextField( OutlinedTextField(
value = state.serverUrl, value = state.serverUrl,
@@ -370,6 +371,10 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onMenuOpenChange = { menuOpen = it }, onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats, onRefresh = vm::loadChats,
onCheckUpdates = vm::checkArgusUpdate, onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
onStartMaxLogin = vm::startMaxLogin,
onMaxCode = vm::updateMaxCode,
onSubmitMaxCode = vm::submitMaxCode,
onLogout = vm::logout, onLogout = vm::logout,
onSearch = vm::updateSearchQuery, onSearch = vm::updateSearchQuery,
onTabSelected = { tab -> onTabSelected = { tab ->
@@ -596,6 +601,10 @@ private fun TelegramChatListContent(
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCheckUpdates: () -> Unit, onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onTabSelected: (MainTab) -> Unit, onTabSelected: (MainTab) -> Unit,
@@ -648,10 +657,17 @@ private fun TelegramChatListContent(
) )
} }
ErrorLine(state.error) ErrorLine(state.error)
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
}
if (activeTab == MainTab.Settings) { if (activeTab == MainTab.Settings) {
SettingsTabContent( SettingsTabContent(
state = state, state = state,
onCheckUpdates = onCheckUpdates, onCheckUpdates = onCheckUpdates,
onRefreshMaxStatus = onRefreshMaxStatus,
onStartMaxLogin = onStartMaxLogin,
onMaxCode = onMaxCode,
onSubmitMaxCode = onSubmitMaxCode,
onRefresh = onRefresh, onRefresh = onRefresh,
onLogout = onLogout onLogout = onLogout
) )
@@ -1039,10 +1055,29 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: ()
} }
} }
@Composable
private fun MaxSessionExpiredBanner(onClick: () -> Unit) {
Surface(
color = Color(0xFFFFF3E0),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) {
Text("Сессия MAX закончилась", fontWeight = FontWeight.SemiBold, color = Color(0xFF9A4D00))
Text("Откройте настройки, получите новый код и отдайте его QMAX.", color = QMaxMuted, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable @Composable
private fun SettingsTabContent( private fun SettingsTabContent(
state: QMaxUiState, state: QMaxUiState,
onCheckUpdates: () -> Unit, onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
@@ -1066,19 +1101,67 @@ private fun SettingsTabContent(
HorizontalDivider(color = Color(0xFFE7EEF3)) HorizontalDivider(color = Color(0xFFE7EEF3))
} }
item { item {
val status = state.maxStatus
val loginAvailable = status?.isAuthorized == false
val waitingForCode = isMaxLoginWaitingForCode(status)
val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading
val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading
Spacer(Modifier.height(22.dp)) Spacer(Modifier.height(22.dp))
Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText) Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
state.maxStatus?.status ?: "Статус неизвестен", maxStatusTitle(status),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = QMaxMuted color = if (loginAvailable) Color(0xFFC62828) else QMaxMuted
) )
state.maxStatus?.lastError?.takeIf { it.isNotBlank() }?.let { Text(
maxStatusHint(status),
style = MaterialTheme.typography.bodyMedium,
color = QMaxMuted,
modifier = Modifier.padding(top = 4.dp)
)
status?.lastError?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828)) Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828))
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Column {
Button(
onClick = onRefreshMaxStatus,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth()
) {
Text("Обновить статус")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = onStartMaxLogin,
enabled = requestCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text(if (waitingForCode) "Код запрошен" else "Получить новый код")
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = onMaxCode,
enabled = loginAvailable && !state.loading,
label = { Text("Новый код MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmitMaxCode,
enabled = submitCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Отдать код")
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
Spacer(Modifier.height(22.dp))
Button(onClick = onRefresh, enabled = !state.loading) { Button(onClick = onRefresh, enabled = !state.loading) {
Text("Обновить чаты") Text("Обновить чаты")
} }
@@ -1090,6 +1173,31 @@ private fun SettingsTabContent(
} }
} }
private fun isMaxLoginWaitingForCode(status: MaxBridgeStatusDto?): Boolean {
return status?.loginStage.equals("Code", ignoreCase = true) ||
status?.status.equals("Code", ignoreCase = true)
}
private fun maxStatusTitle(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Статус неизвестен"
status.isAuthorized -> "Сессия MAX активна"
status.loginStage.equals("Expired", ignoreCase = true) ||
status.status.equals("SessionExpired", ignoreCase = true) -> "Сессия MAX закончилась"
isMaxLoginWaitingForCode(status) -> "MAX ждёт код подтверждения"
else -> "MAX требует вход"
}
}
private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Проверка статуса выполняется автоматически. Можно обновить статус вручную."
status.isAuthorized -> "Получение нового кода будет доступно только после окончания сессии."
isMaxLoginWaitingForCode(status) -> "Введите код из MAX и нажмите «Отдать код»."
else -> "Нажмите «Получить новый код», затем введите код подтверждения."
}
}
@Composable @Composable
private fun TelegramBottomNavigation( private fun TelegramBottomNavigation(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -8,6 +8,7 @@ import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
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.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
@@ -26,7 +27,8 @@ class QMaxRealtimeClient {
onChatListInvalidated: () -> Unit, onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit, onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit, onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit onMessageDeleted: (MessageDeletedDto) -> Unit,
onMaxStatusChanged: (MaxBridgeStatusDto) -> Unit
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
disconnect() disconnect()
@@ -36,6 +38,15 @@ class QMaxRealtimeClient {
.build() .build()
connection.on("ChatListInvalidated", onChatListInvalidated) connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MaxStatusChanged",
{ payload: JsonElement ->
runCatching {
onMaxStatusChanged(json.decodeFromString<MaxBridgeStatusDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on( connection.on(
"MessageCreated", "MessageCreated",
{ payload: JsonElement -> { payload: JsonElement ->
@@ -72,6 +72,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var chatRefreshJob: Job? = null private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null private var chatSearchJob: Job? = null
private var maxStatusPollJob: Job? = null
private var autoLoginJob: Job? = null private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null private var pushRegisteredForToken: String? = null
private var pushRegistrationJob: Job? = null private var pushRegistrationJob: Job? = null
@@ -107,11 +108,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats() loadChats()
connectRealtime(session) connectRealtime(session)
registerPushDevice(session) registerPushDevice(session)
loadMaxStatus() startMaxStatusPolling(session)
} else { } else {
realtimeConnectJob?.cancel() realtimeConnectJob?.cancel()
messagePollJob?.cancel() messagePollJob?.cancel()
presencePollJob?.cancel() presencePollJob?.cancel()
maxStatusPollJob?.cancel()
pushRegistrationJob?.cancel() pushRegistrationJob?.cancel()
pushRegistrationInFlightForToken = null pushRegistrationInFlightForToken = null
pushRegisteredForToken = null pushRegisteredForToken = null
@@ -687,19 +689,20 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadMaxStatus() = launchLoading(false) { fun loadMaxStatus() = launchLoading(false) {
val session = requireSession() val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session)) refreshMaxStatus(session)
} }
fun startMaxLogin() = launchLoading { fun startMaxLogin() = launchLoading {
val session = requireSession() val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null)) applyMaxStatus(repository.startMaxLogin(session, null))
} }
fun submitMaxCode() = launchLoading { fun submitMaxCode() = launchLoading {
val session = requireSession() val session = requireSession()
val code = state.value.maxCode.trim() val code = state.value.maxCode.trim()
if (code.isBlank()) return@launchLoading if (code.isBlank()) return@launchLoading
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "") applyMaxStatus(repository.submitMaxCode(session, code))
state.value = state.value.copy(maxCode = "")
} }
fun checkArgusUpdate() = launchLoading { fun checkArgusUpdate() = launchLoading {
@@ -1044,6 +1047,28 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
} }
} }
private fun startMaxStatusPolling(session: QMaxSession) {
maxStatusPollJob?.cancel()
maxStatusPollJob = viewModelScope.launch {
while (true) {
runCatching {
refreshMaxStatus(session)
}.onFailure {
Log.w(LogTag, "MAX status refresh failed", it)
}
delay(MaxStatusPollIntervalMs)
}
}
}
private suspend fun refreshMaxStatus(session: QMaxSession) {
applyMaxStatus(repository.maxStatus(session))
}
private fun applyMaxStatus(status: MaxBridgeStatusDto) {
state.value = state.value.copy(maxStatus = status)
}
private fun connectRealtime(session: QMaxSession) { private fun connectRealtime(session: QMaxSession) {
realtimeConnectJob?.cancel() realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch { realtimeConnectJob = viewModelScope.launch {
@@ -1053,7 +1078,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
onChatListInvalidated = ::scheduleChatListRefresh, onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage, onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated, onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted onMessageDeleted = ::handleRealtimeMessageDeleted,
onMaxStatusChanged = ::applyMaxStatus
) )
realtime.joinChat(state.value.selectedChat?.id) realtime.joinChat(state.value.selectedChat?.id)
} }
@@ -1245,6 +1271,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val ChatListTimeoutMs = 45_000L const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L const val MessageHydrationIntervalMs = 120_000L
const val MaxStatusPollIntervalMs = 60_000L
const val AutoLoginDelayMs = 1_000L const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6 const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L const val InitialPushRegistrationRetryMs = 15_000L
+1 -4
View File
@@ -8,11 +8,8 @@ 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. # Enter this once on the Android login screen to pair the device with your private bridge.
QMAX_PAIRING_CODE=change-me-pairing-code QMAX_PAIRING_CODE=change-me-pairing-code
# Use international format if MAX accepts it in your flow, otherwise the local phone format you use in MAX Web. # Use the phone number linked to the MAX account used by PyMax.
QMAX_MAX_PHONE_NUMBER=79000000000 QMAX_MAX_PHONE_NUMBER=79000000000
# Set false temporarily if you attach a visible browser/VNC workflow for CAPTCHA/debug.
QMAX_MAX_HEADLESS=true
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
-18
View File
@@ -32,23 +32,6 @@ services:
networks: networks:
- qmax - qmax
qmax-max-worker:
build:
context: ../worker
dockerfile: Dockerfile
container_name: qmax-max-worker
restart: unless-stopped
environment:
PORT: 3001
MAX_BASE_URL: https://web.max.ru/
MAX_USER_DATA_DIR: /data/max-profile
MAX_HEADLESS: ${QMAX_MAX_HEADLESS:-true}
volumes:
- qmax-max-profile:/data
- qmax-data:/qmax-data
networks:
- qmax
qmax-pymax-worker: qmax-pymax-worker:
build: build:
context: ../pymax-worker context: ../pymax-worker
@@ -70,7 +53,6 @@ services:
volumes: volumes:
qmax-data: qmax-data:
qmax-max-profile:
qmax-pymax-session: qmax-pymax-session:
networks: networks:
+7 -7
View File
@@ -6,31 +6,31 @@ flowchart LR
A -->|SignalR planned/available| B A -->|SignalR planned/available| B
B --> C["SQLite cache<br/>chats/messages/sessions"] B --> C["SQLite cache<br/>chats/messages/sessions"]
B --> D["Local storage<br/>attachments/releases"] B --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Node + Playwright"] B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"]
E -->|persistent browser profile| F["web.max.ru"] E -->|persistent PyMax session| F["MAX mobile API"]
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"] B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
``` ```
The server is the only public backend surface. The MAX worker stays inside the Docker network and is controlled through the API/admin page. The server is the only public backend surface. The PyMax worker stays inside the Docker network and is controlled through the API/admin page.
## Security Rules ## Security Rules
- Android pairs to the private server with `QMAX_PAIRING_CODE`. - Android pairs to the private server with `QMAX_PAIRING_CODE`.
- API access uses JWT access tokens and refresh tokens. - API access uses JWT access tokens and refresh tokens.
- MAX credentials and sessions live only on the Pi in Docker volumes. - MAX session files live only on the Pi in Docker volumes.
- `.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 ## Current Verified Status
- MAX Web login is authorized on the Raspberry Pi worker. - PyMax login is authorized on the Raspberry Pi worker.
- Chat list, message history, text sending and attachment sending are mapped through `worker/src/server.js`. - 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`.
## Remaining Production Checks ## Remaining Production Checks
- Keep selector drift monitoring for future MAX Web UI changes. - Keep PyMax session-expiration monitoring active.
- Run an unlocked-device visual pass on the connected phone for every major UI change. - Run an unlocked-device visual pass on the connected phone for every major UI change.
- Replace debug fallback signing with a real release key in `android/key.properties`. - Replace debug fallback signing with a real release key in `android/key.properties`.
+83 -7
View File
@@ -5,11 +5,13 @@ import base64
import mimetypes import mimetypes
import os import os
import pathlib import pathlib
import shutil
import stat import stat
import time import time
import traceback import traceback
import urllib.parse import urllib.parse
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -54,6 +56,35 @@ def normalize_phone(phone: str) -> str:
return phone.strip() return phone.strip()
def is_expired_session_error(error: str | None) -> bool:
text = (error or "").lower()
return any(
marker in text
for marker in (
"fail_login_token",
"login.token",
"login token",
"authorize again",
"authorise again",
"please authorize",
"please authorise",
)
)
def archive_session_dir() -> 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}")
suffix = 1
while target.exists():
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}-{suffix}")
suffix += 1
shutil.move(str(SESSION_DIR), str(target))
return str(target)
def dump_model(obj: Any) -> dict[str, Any]: def dump_model(obj: Any) -> dict[str, Any]:
if obj is None: if obj is None:
return {} return {}
@@ -271,6 +302,13 @@ class DeferredCodeProvider:
self._future.set_result(code) self._future.set_result(code)
return True return True
def reset(self) -> None:
if self._future is not None and not self._future.done():
self._future.cancel()
self._future = None
self.waiting_for_code = False
self.requested_phone = None
class DeferredPasswordProvider: class DeferredPasswordProvider:
def __init__(self) -> None: def __init__(self) -> None:
@@ -295,6 +333,13 @@ class DeferredPasswordProvider:
self._future.set_result(password) self._future.set_result(password)
return True return True
def reset(self) -> None:
if self._future is not None and not self._future.done():
self._future.cancel()
self._future = None
self.waiting_for_password = False
self.hint = None
class PyMaxRuntime: class PyMaxRuntime:
def __init__(self) -> None: def __init__(self) -> None:
@@ -319,10 +364,15 @@ class PyMaxRuntime:
return "Password" return "Password"
if self.code_provider.waiting_for_code: if self.code_provider.waiting_for_code:
return "Code" return "Code"
if self.has_expired_session():
return "Expired"
if self.task is not None and not self.task.done(): if self.task is not None and not self.task.done():
return "Starting" return "Starting"
return "Idle" return "Idle"
def has_expired_session(self) -> bool:
return is_expired_session_error(self.last_error)
async def ensure_started(self, phone: str | None = None) -> None: async def ensure_started(self, phone: str | None = None) -> None:
async with self.lock: async with self.lock:
if self.task is not None and not self.task.done(): if self.task is not None and not self.task.done():
@@ -375,6 +425,8 @@ class PyMaxRuntime:
return False return False
async def get_client(self) -> Client: async def get_client(self) -> Client:
if self.has_expired_session():
raise RuntimeError(self.last_error)
await self.ensure_started() await self.ensure_started()
if not await self.wait_ready(15): if not await self.wait_ready(15):
raise RuntimeError(self.last_error or f"PyMax is not ready; stage={self.login_stage()}") raise RuntimeError(self.last_error or f"PyMax is not ready; stage={self.login_stage()}")
@@ -382,10 +434,30 @@ class PyMaxRuntime:
return self.client return self.client
async def stop(self) -> None: async def stop(self) -> None:
if self.client is not None: async with self.lock:
await self.client.stop() await self._stop_locked()
if self.task is not None:
self.task.cancel() async def reset_session_for_login(self) -> None:
async with self.lock:
await self._stop_locked()
self.last_error = None
archive_session_dir()
async def _stop_locked(self) -> None:
client = self.client
task = self.task
self.client = None
self.task = None
self.ready.clear()
self.code_provider.reset()
self.password_provider.reset()
if client is not None:
with suppress(Exception):
await client.stop()
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
runtime = PyMaxRuntime() runtime = PyMaxRuntime()
@@ -396,11 +468,12 @@ def json_response(data: Any, status: int = 200) -> web.Response:
def status_payload() -> dict[str, Any]: def status_payload() -> dict[str, Any]:
stage = runtime.login_stage()
return { return {
"mode": "PyMax", "mode": "PyMax",
"isAuthorized": runtime.is_authorized, "isAuthorized": runtime.is_authorized,
"loginStage": runtime.login_stage(), "loginStage": stage,
"status": "Ready" if runtime.is_authorized else runtime.login_stage(), "status": "Ready" if runtime.is_authorized else ("SessionExpired" if stage == "Expired" else stage),
"url": None, "url": None,
"title": runtime.last_title, "title": runtime.last_title,
"lastError": runtime.last_error, "lastError": runtime.last_error,
@@ -728,7 +801,8 @@ async def health(_request: web.Request) -> web.Response:
@route_errors @route_errors
async def status(_request: web.Request) -> web.Response: async def status(_request: web.Request) -> web.Response:
await runtime.ensure_started() if not runtime.has_expired_session():
await runtime.ensure_started()
return json_response(status_payload()) return json_response(status_payload())
@@ -736,6 +810,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"}:
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())
+3 -3
View File
@@ -23,14 +23,14 @@ if (Test-Path $staging) {
} }
New-Item -ItemType Directory -Force -Path $staging | Out-Null New-Item -ItemType Directory -Force -Path $staging | Out-Null
foreach ($item in @("server", "worker", "deploy", "Dockerfile.server", "QMax.slnx")) { foreach ($item in @("server", "pymax-worker", "deploy", "Dockerfile.server", "QMax.slnx")) {
Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force
} }
foreach ($path in @( foreach ($path in @(
"server/QMax.Api/bin", "server/QMax.Api/bin",
"server/QMax.Api/obj", "server/QMax.Api/obj",
"worker/node_modules" "pymax-worker/src/__pycache__"
)) { )) {
$target = Join-Path $staging $path $target = Join-Path $staging $path
if (Test-Path $target) { if (Test-Path $target) {
@@ -40,7 +40,7 @@ foreach ($path in @(
Push-Location $staging Push-Location $staging
try { try {
tar -czf $archive server worker deploy Dockerfile.server QMax.slnx tar -czf $archive server pymax-worker deploy Dockerfile.server QMax.slnx
} }
finally { finally {
Pop-Location Pop-Location
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -6,6 +7,7 @@ using QMax.Api.Configuration;
using QMax.Api.Contracts; 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.Hubs;
using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Max;
using QMax.Api.Services; using QMax.Api.Services;
@@ -18,6 +20,7 @@ public sealed class MaxController(
IMaxBridgeClient maxBridge, IMaxBridgeClient maxBridge,
MaxBridgeSyncService syncService, MaxBridgeSyncService syncService,
QMaxDbContext db, QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
IOptions<QMaxOptions> options) : ControllerBase IOptions<QMaxOptions> options) : ControllerBase
{ {
private readonly QMaxOptions _options = options.Value; private readonly QMaxOptions _options = options.Value;
@@ -82,6 +85,7 @@ public sealed class MaxController(
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);
} }
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
@@ -15,7 +15,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
"max", "max",
"MAX", "MAX",
false, false,
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.", "Mock mode is active. Switch QMax:MaxMode to Worker and use qmax-pymax-worker for real MAX traffic.",
DateTimeOffset.UtcNow) DateTimeOffset.UtcNow)
]) ])
]; ];
@@ -110,6 +110,6 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
private static string MockChatUrl(string externalChatId) private static string MockChatUrl(string externalChatId)
{ {
return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}"; return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
} }
} }
@@ -40,7 +40,7 @@ public sealed class WorkerMaxBridgeClient(
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{ {
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken) return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
?? Array.Empty<MaxChatUpdate>(); ?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
} }
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
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.Hubs; using QMax.Api.Infrastructure.Hubs;
@@ -29,10 +32,41 @@ public sealed class MaxBridgeSyncService(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "MAX sync failed."); logger.LogWarning(ex, "MAX sync failed.");
await PublishCurrentMaxStatusAsync(cancellationToken);
return 0; return 0;
} }
} }
private async Task PublishCurrentMaxStatusAsync(CancellationToken cancellationToken)
{
try
{
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = options.MaxPhoneNumber;
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);
}
catch (Exception statusError)
{
logger.LogDebug(statusError, "Unable to publish current MAX status after sync failure.");
}
}
public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(externalChatId)) if (string.IsNullOrWhiteSpace(externalChatId))
@@ -1387,6 +1421,11 @@ public sealed class MaxBridgeSyncService(
}; };
} }
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
{
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
private sealed record LastKnownMessageSnapshot( private sealed record LastKnownMessageSnapshot(
MessageDirection Direction, MessageDirection Direction,
string? Text, string? Text,
+6 -3
View File
@@ -112,7 +112,7 @@ public sealed class ApiSmokeTests : IDisposable
using var scope = _factory.Services.CreateScope(); using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id); var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id);
Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl); Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl);
} }
[Fact] [Fact]
@@ -1104,8 +1104,11 @@ public sealed class ApiSmokeTests : IDisposable
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, messageResponse); await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory)); Assert.True(await ProcessOutboxAsync(factory) >= 2);
Assert.Equal(["send:text", "clear"], bridge.OperationOrder); var sendIndex = bridge.OperationOrder.IndexOf("send:text");
var clearIndex = bridge.OperationOrder.IndexOf("clear");
Assert.True(sendIndex >= 0, "Expected a text send operation.");
Assert.True(clearIndex > sendIndex, "Expected text send before pending clear-history action.");
} }
[Fact] [Fact]
-4
View File
@@ -1,4 +0,0 @@
node_modules
npm-debug.log
.env
data
-12
View File
@@ -1,12 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.0-noble
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["npm", "start"]
-933
View File
@@ -1,933 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qmax-max-worker",
"version": "0.1.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
}
-7052
View File
File diff suppressed because it is too large Load Diff