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
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user