"""Publish the verified first IKAR APK without restarting Argus or other services.""" from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import shutil import sqlite3 import subprocess import uuid from datetime import datetime, timezone DATA = Path('/mnt/data/argus') DB = DATA / 'argus.db' SOURCE = Path('/mnt/data/ikar_server/shared/data/AppUpdates/Android/ikar-android-1.apk') REPORT = Path('/mnt/data/ikar_server/argus-publication.json') EXPECTED_SHA256 = '37388bf8a8b85413c9c31030def07c4377c67259d46594f900699d9f56f38212' EXPECTED_SIZE = 89832793 SLUG = 'ikar' def digest(path: Path) -> str: with path.open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def integrity(connection: sqlite3.Connection) -> str: checks = connection.execute('PRAGMA quick_check').fetchall() if checks != [('ok',)]: raise RuntimeError(f'Database integrity failed: {checks}') if connection.execute('PRAGMA foreign_key_check').fetchall(): raise RuntimeError('Foreign key integrity failed') return 'ok' def protected_rows(connection: sqlite3.Connection) -> str: apps = connection.execute('SELECT * FROM Apps WHERE Slug != ? ORDER BY Id', (SLUG,)).fetchall() releases = connection.execute( 'SELECT r.* FROM Releases r JOIN Apps a ON a.Id = r.CatalogAppId ' 'WHERE a.Slug != ? ORDER BY r.Id', (SLUG,) ).fetchall() return hashlib.sha256(json.dumps([apps, releases], ensure_ascii=False).encode()).hexdigest() def service_snapshot() -> dict: identifiers = subprocess.check_output(['docker', 'ps', '-q'], text=True).split() inspected = json.loads(subprocess.check_output(['docker', 'inspect', *identifiers], text=True)) containers = { item['Name']: {'id': item['Id'], 'startedAt': item['State']['StartedAt']} for item in inspected } pid = subprocess.check_output(['systemctl', 'show', 'caddy', '-p', 'MainPID', '--value'], text=True).strip() return {'containers': containers, 'caddyPid': pid} def save_report(report: dict) -> None: staged = REPORT.with_suffix('.json.next') staged.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') os.replace(staged, REPORT) def inspect() -> None: with sqlite3.connect(f'file:{DB}?mode=ro', uri=True) as connection: apps = connection.execute('SELECT Id, Slug, Name, IsListed, AndroidPackageName FROM Apps ORDER BY Slug').fetchall() matches = [row for row in apps if 'ikar' in row[1].lower() or 'икар' in row[2].lower() or row[4] == 'ru.ikar.app'] print(json.dumps({ 'databaseIntegrity': integrity(connection), 'appColumns': [row[1] for row in connection.execute('PRAGMA table_info(Apps)')], 'releaseColumns': [row[1] for row in connection.execute('PRAGMA table_info(Releases)')], 'existingIkarApps': matches, 'sourceSize': SOURCE.stat().st_size, 'sourceSha256': digest(SOURCE), }, ensure_ascii=False, indent=2)) def publish() -> None: if not os.path.ismount('/mnt/data') or DATA.resolve() != DATA: raise RuntimeError('Expected external disk or Argus data root is missing') if SOURCE.stat().st_size != EXPECTED_SIZE or digest(SOURCE) != EXPECTED_SHA256: raise RuntimeError('Source APK differs from the verified release') connection = sqlite3.connect(DB, timeout=30) target = None staged = None committed = False try: connection.execute('PRAGMA foreign_keys = ON') integrity(connection) existing = connection.execute( 'SELECT r.Id, r.Sha256, r.AndroidVersionCode FROM Releases r ' 'JOIN Apps a ON a.Id = r.CatalogAppId ' 'WHERE a.Slug = ? AND r.Version = ? AND r.Channel = ? AND r.Platform = ?', (SLUG, '1.0.0', 'stable', 'android') ).fetchall() if existing: if len(existing) != 1 or existing[0][1:] != (EXPECTED_SHA256, 1): raise RuntimeError('A different release already occupies IKAR 1.0.0') print(json.dumps({'alreadyPublished': True, 'releaseId': existing[0][0]})) return if connection.execute('SELECT Id FROM Apps WHERE Slug = ?', (SLUG,)).fetchone(): raise RuntimeError('Existing IKAR card needs review before publication') services_before = service_snapshot() stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') backup = DATA / 'backups' / f'argus-before-ikar-{stamp}-{uuid.uuid4().hex[:8]}.db' with sqlite3.connect(backup) as backup_connection: connection.backup(backup_connection) integrity(backup_connection) backup.chmod(0o600) app_id = str(uuid.uuid4()).upper() release_id = str(uuid.uuid4()).upper() target_dir = DATA / 'Packages' / SLUG target_dir.mkdir(exist_ok=True) if not target_dir.resolve().is_relative_to(DATA / 'Packages'): raise RuntimeError('Package directory is outside Argus storage') relative = f'{SLUG}/{stamp}-1.0.0-{release_id.replace("-", "")}.apk' target = DATA / 'Packages' / relative staged = target.with_suffix('.apk.next') shutil.copyfile(SOURCE, staged) if staged.stat().st_size != EXPECTED_SIZE or digest(staged) != EXPECTED_SHA256: raise RuntimeError('Staged APK checksum mismatch') staged.chmod(0o644) with staged.open('rb') as stream: os.fsync(stream.fileno()) os.replace(staged, target) now = datetime.now(timezone.utc).isoformat(timespec='microseconds') summary = 'Самостоятельный Android-мессенджер с собственным сервером ИКАР.' description = ( 'ИКАР — самостоятельный мессенджер для Android. Личные сообщения, группы и каналы, ' 'фото, видео, файлы и голосовые сообщения, реакции, опросы, стикеры и отложенная отправка. ' 'Сервер ИКАР сохраняет историю и вложения. Аккаунты MAX автоматически не переносятся. ' 'Регистрация привязана к ключу устройства, подтверждение номера по SMS не используется. ' 'Звонки между разными сетями пока требуют настройки ретрансляции на сервере.' ) notes = ( 'Первый выпуск ИКАР с собственным сервером ikar_server. ' 'Сообщения, группы, каналы, файлы, голосовые сообщения и их расшифровка, ' 'стикеры, реакции, опросы и отложенная отправка. ' 'Русский интерфейс и иконка ИКАР. Пакет ru.ikar.app, версия Android 1.0.0 (1).' ) connection.execute('BEGIN IMMEDIATE') untouched_before = protected_rows(connection) connection.execute( 'INSERT INTO Apps (Id, Slug, Name, Summary, Description, RepositoryUrl, HomepageUrl, ' 'AndroidPackageName, IsListed, CreatedAt, UpdatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (app_id, SLUG, 'ИКАР', summary, description, None, None, 'ru.ikar.app', 1, now, now) ) connection.execute( 'INSERT INTO Releases (Id, CatalogAppId, Version, Channel, Platform, PackageKind, ' 'AndroidVersionCode, OriginalFileName, StoredRelativePath, ContentType, PackageSizeBytes, ' 'Sha256, Notes, PublishedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (release_id, app_id, '1.0.0', 'stable', 'android', 'apk', 1, 'ikar-1.0.0+1.apk', relative, 'application/vnd.android.package-archive', EXPECTED_SIZE, EXPECTED_SHA256, notes, now) ) integrity(connection) if protected_rows(connection) != untouched_before: raise RuntimeError('Unrelated catalog rows changed') connection.commit() committed = True services_after = service_snapshot() report = { 'slug': SLUG, 'name': 'ИКАР', 'version': '1.0.0', 'androidVersionCode': 1, 'package': 'ru.ikar.app', 'releaseId': release_id, 'sha256': EXPECTED_SHA256, 'bytes': EXPECTED_SIZE, 'publishedAt': now, 'databaseBackup': str(backup), 'databaseIntegrity': integrity(connection), 'backupIntegrity': 'ok', 'unrelatedCatalogRowsUnchanged': protected_rows(connection) == untouched_before, 'servicesUnchanged': services_after == services_before, 'servicesBefore': services_before, 'servicesAfter': services_after, 'cardUrl': 'https://argus.kusoft.xyz/#app=ikar', 'manifestUrl': 'https://argus.kusoft.xyz/api/apps/ikar/manifest?platform=android&channel=stable', 'downloadUrl': f'https://argus.kusoft.xyz/api/apps/ikar/releases/{release_id.lower()}/download', } save_report(report) print(json.dumps(report, ensure_ascii=False, indent=2)) except Exception: if not committed: connection.rollback() if target is not None: target.unlink(missing_ok=True) if staged is not None: staged.unlink(missing_ok=True) raise finally: connection.close() if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('mode', choices=['inspect', 'publish']) args = parser.parse_args() inspect() if args.mode == 'inspect' else publish()