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
+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()