Add multi-user MAX authentication and tenant isolation
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user