Fix avatar URLs, photo IDs and profile update notifications
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-07 22:34:40 +03:00
parent 5423355d83
commit 12b692f4a9
6 changed files with 338 additions and 20 deletions
+16 -9
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
@@ -13,11 +14,17 @@ import time
import urllib.request
root = Path('/mnt/data/ikar_server')
release = root / 'releases/20261007-komet'
release_name = sys.argv[2] if len(sys.argv) > 2 else '20261007-komet'
previous_name = sys.argv[3] if len(sys.argv) > 3 else '20261007-video'
archive_name = sys.argv[4] if len(sys.argv) > 4 else 'server-fixes.tar.gz'
if not all(re.fullmatch(r'[a-zA-Z0-9_.-]+', name) and name not in {'.', '..'}
for name in (release_name, previous_name, archive_name)):
raise ValueError('Invalid release or archive name')
release = root / 'releases' / release_name
current = root / 'current'
previous = current.resolve(strict=True)
expected_sha = sys.argv[1].lower()
if not os.path.ismount('/mnt/data') or previous != root / 'releases/20261007-video' or release.exists():
if not os.path.ismount('/mnt/data') or previous != root / 'releases' / previous_name or release.exists():
raise RuntimeError('Unexpected release or external disk state')
def snapshot():
@@ -38,20 +45,20 @@ with tempfile.TemporaryDirectory() as temp:
raise RuntimeError('Backup failed integrity verification')
shutil.copytree(previous, release, ignore=shutil.ignore_patterns('publish', 'release.tar.gz'))
with tarfile.open(root / 'server-fixes.tar.gz') as archive:
with tarfile.open(root / archive_name) as archive:
archive.extractall(release, filter='data')
actual_sha = hashlib.sha256((release / 'publish/Ikar.Server.dll').read_bytes()).hexdigest()
if actual_sha != expected_sha:
raise RuntimeError('Uploaded server differs from verified local build')
compose = release / 'compose.yaml'
text = compose.read_text()
old_image = 'image: ikar_server:20261007-video'
old_image = f'image: ikar_server:{previous_name}'
if text.count(old_image) != 1:
raise RuntimeError('Unexpected API image in compose')
compose.write_text(text.replace(old_image, 'image: ikar_server:20261007-komet', 1))
compose.write_text(text.replace(old_image, f'image: ikar_server:{release_name}', 1))
print('Building IKAR API image', flush=True)
with (root / 'build-komet.log').open('w') as log:
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', 'ikar_server:20261007-komet', '.'],
with (root / f'build-{release_name}.log').open('w') as log:
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'],
cwd=release, stdout=log, stderr=subprocess.STDOUT, check=True)
def update(path):
@@ -82,7 +89,7 @@ try:
if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns:
raise RuntimeError('Database or presence schema verification failed')
subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True)
pending = root / 'current.next-komet'
pending = root / f'current.next-{release_name}'
pending.symlink_to(release, target_is_directory=True)
os.replace(pending, current)
except Exception:
@@ -98,5 +105,5 @@ report = {
'protectedServicesUnchanged': snapshot() == protected_before,
'protectedServices': protected_before,
}
(root / 'deployment-komet.json').write_text(json.dumps(report, indent=2))
(root / f'deployment-{release_name}.json').write_text(json.dumps(report, indent=2))
print(json.dumps(report), flush=True)
+183
View File
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:image/image.dart' as img;
import 'package:ikar/core/transport/ikar_network.dart';
import 'live_ikar_smoke.dart' as live;
Future<void> main(List<String> args) async {
final label = args.isEmpty ? 'after' : args.first;
final base = Uri.parse('https://ikar.kusoft.xyz');
final client = IkarNetwork.client();
final checks = <String>[];
live.Device? owner;
final report = <String, dynamic>{'passed': false, 'checks': checks};
try {
final stamp = DateTime.now().microsecondsSinceEpoch;
owner = await live.Device.create(
base,
'node',
'+999$stamp',
'Avatar verification',
);
final input = img.decodePng(
await File('assets/ikar_icon.png').readAsBytes(),
)!;
final jpeg = img.encodeJpg(img.copyResize(input, width: 128, height: 128));
Future<Map> upload(List<int> bytes) async {
final slot = await owner!.ok(80, {'count': 1, 'profile': true});
final request = await client.postUrl(Uri.parse(slot['url']));
request.headers.contentType = ContentType('image', 'jpeg');
request.contentLength = bytes.length;
request.add(bytes);
final response = await request.close();
live.check(response.statusCode == 200, 'Avatar upload failed');
final token = jsonDecode(await utf8.decodeStream(response))['photoToken'];
final pushed = owner.transport.events
.firstWhere((event) => event['opcode'] == 159)
.timeout(const Duration(seconds: 10));
final result = await owner.ok(16, {
'photoToken': token,
'avatarType': 'USER_AVATAR',
});
final push = await pushed;
report['profilePushWrapped'] = push['payload']?['profile'] is Map;
return result['profile']['contact'] as Map;
}
final first = await upload(jpeg);
final url = Uri.parse(first['baseUrl'] as String);
report['absoluteAvatarUrl'] = url.isAbsolute;
report['photoIdReturned'] = first['photoId'] is int;
live.check(
url.isAbsolute && url.scheme == 'https',
'Server returned a relative avatar URL',
);
live.check(first['photoId'] is int, 'Server omitted avatar photo ID');
live.check(
report['profilePushWrapped'] == true,
'Profile push has incompatible structure',
);
checks.add(
'upload commits absolute HTTPS URL, photo ID and profile update push',
);
final image = await (await client.getUrl(url)).close();
final fetched = await image.fold<List<int>>(
[],
(bytes, chunk) => bytes..addAll(chunk),
);
live.check(
image.statusCode == 200 &&
sha256.convert(fetched) == sha256.convert(jpeg),
'Avatar download differs from uploaded photo',
);
checks.add('avatar image renders through a normal HTTP image loader');
final history = await owner.ok(39, {'contactId': owner.id});
live.check(
history['ids'][0] == first['photoId'] &&
Uri.parse(history['urls'][0]).isAbsolute,
'Avatar gallery differs from profile',
);
final login = await owner.ok(19, {});
live.check(
login['profile']['contact']['photoId'] == first['photoId'],
'Avatar did not survive profile reload',
);
checks.add('avatar gallery and profile reload preserve current photo ID');
final privacyRequest = await client.putUrl(
base.resolve('/api/users/me/privacy'),
);
privacyRequest.headers.set(
'Authorization',
'Bearer ${owner.transport.token}',
);
privacyRequest.headers.contentType = ContentType.json;
privacyRequest.write(
jsonEncode({
'phoneNumberVisibility': 'nobody',
'lastSeenVisibility': 'nobody',
'profilePhotoVisibility': 'nobody',
}),
);
final privacyResponse = await privacyRequest.close();
await privacyResponse.drain<void>();
live.check(
privacyResponse.statusCode == 200,
'Could not restrict avatar privacy',
);
final privateImage = await (await client.getUrl(url)).close();
await privateImage.drain<void>();
live.check(
privateImage.statusCode == 200,
'Owner cannot display private avatar',
);
final anonymousImage = await (await client.getUrl(
url.replace(query: ''),
)).close();
await anonymousImage.drain<void>();
live.check(
anonymousImage.statusCode == 404,
'Private avatar exposed anonymously',
);
final scopedRequest = await client.getUrl(base.resolve('/api/users/me'));
scopedRequest.headers.set(
'Authorization',
'Bearer ${url.queryParameters['access_token']}',
);
final scopedResponse = await scopedRequest.close();
await scopedResponse.drain<void>();
live.check(
scopedResponse.statusCode == 401,
'Avatar token granted broader API access',
);
checks.add(
'private avatar renders for owner, stays hidden anonymously, and token is resource scoped',
);
final nextJpeg = img.encodeJpg(img.copyRotate(input, angle: 90));
final replacement = await upload(nextJpeg);
live.check(
replacement['photoId'] != first['photoId'] &&
replacement['baseUrl'] != first['baseUrl'],
'Replacing avatar reused the old image cache key',
);
final oldImage = await (await client.getUrl(url)).close();
await oldImage.drain<void>();
live.check(
oldImage.statusCode == 404,
'Replaced avatar is still available',
);
checks.add(
'replacement changes photo ID and URL and removes previous image',
);
final removed = await owner.ok(43, {'photoId': replacement['photoId']});
live.check(
removed['profile']['contact']['baseUrl'] == null &&
removed['profile']['contact']['photoId'] == null,
'Avatar deletion failed',
);
live.check(
(await owner.ok(39, {'contactId': owner.id}))['urls'].isEmpty,
'Deleted avatar remains in gallery',
);
checks.add('avatar deletion clears profile and gallery');
report['passed'] = true;
} catch (error) {
report['error'] = error.toString();
rethrow;
} finally {
report['timestamp'] = DateTime.now().toUtc().toIso8601String();
await File(
'artifacts/avatar-$label.json',
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
stdout.writeln(jsonEncode(report));
if (owner != null) {
try {
await owner.ok(199, {});
} finally {
await owner.close();
}
}
client.close(force: true);
}
}