195 lines
9.7 KiB
Python
195 lines
9.7 KiB
Python
"""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()
|