Files
IKAR/lib/core/plugins/plugin_manifest.dart
T

337 lines
11 KiB
Dart

import 'dart:convert';
const int kPluginApiVersion = 1;
const String kPluginPackageExtension = '.kinet';
enum PluginPermission {
chatWrite('chat.write'),
chatEdit('chat.edit'),
uiNotify('ui.notify'),
contactRead('contact.read'),
replyRead('message.readReply'),
network('network'),
photoWrite('chat.photo'),
fileWrite('chat.file'),
storage('storage');
const PluginPermission(this.id);
final String id;
static PluginPermission? fromId(String id) {
for (final permission in values) {
if (permission.id == id) return permission;
}
return null;
}
}
class PluginCommandManifest {
const PluginCommandManifest({
required this.name,
required this.description,
required this.handler,
this.arguments = const [],
this.hidden = false,
});
final String name;
final String description;
final String handler;
final List<PluginCommandArgumentManifest> arguments;
final bool hidden;
factory PluginCommandManifest.fromJson(Map<String, dynamic> json) {
final rawName = _requiredString(json, 'name');
final name = rawName.startsWith('/') ? rawName : '/$rawName';
if (!RegExp(r'^/[A-Za-z][A-Za-z0-9_-]{0,31}$').hasMatch(name)) {
throw const FormatException('Некорректное имя команды');
}
final handler = _requiredString(json, 'handler');
if (!RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$').hasMatch(handler)) {
throw const FormatException('Некорректное имя обработчика');
}
final rawArguments = json['arguments'];
final arguments = <PluginCommandArgumentManifest>[];
if (rawArguments != null) {
if (rawArguments is! List) {
throw const FormatException('arguments должен быть массивом');
}
for (final raw in rawArguments) {
if (raw is! Map) {
throw const FormatException('Некорректный аргумент команды');
}
arguments.add(
PluginCommandArgumentManifest.fromJson(
Map<String, dynamic>.from(raw),
),
);
}
}
final argumentNames = <String>{};
for (var index = 0; index < arguments.length; index++) {
final argument = arguments[index];
if (!argumentNames.add(argument.name)) {
throw FormatException('Аргумент ${argument.name} объявлен дважды');
}
if (argument.rest && index != arguments.length - 1) {
throw const FormatException('rest-аргумент должен быть последним');
}
}
return PluginCommandManifest(
name: name,
description: _requiredString(json, 'description'),
handler: handler,
arguments: List.unmodifiable(arguments),
hidden: json['hidden'] == true,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'description': description,
'handler': handler,
if (arguments.isNotEmpty)
'arguments': arguments.map((argument) => argument.toJson()).toList(),
if (hidden) 'hidden': true,
};
}
class PluginCommandArgumentManifest {
const PluginCommandArgumentManifest({
required this.name,
required this.description,
required this.required,
required this.rest,
});
final String name;
final String description;
final bool required;
final bool rest;
factory PluginCommandArgumentManifest.fromJson(Map<String, dynamic> json) {
final name = _requiredString(json, 'name');
if (!RegExp(r'^[A-Za-z][A-Za-z0-9_-]{0,31}$').hasMatch(name)) {
throw const FormatException('Некорректное имя аргумента');
}
return PluginCommandArgumentManifest(
name: name,
description: _optionalString(json, 'description'),
required: json['required'] != false,
rest: json['rest'] == true,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'description': description,
'required': required,
if (rest) 'rest': true,
};
}
class PluginManifest {
const PluginManifest({
required this.id,
required this.name,
required this.version,
required this.apiVersion,
required this.description,
required this.author,
required this.main,
required this.permissions,
required this.commands,
this.updateUrl,
this.signature,
});
final String id;
final String name;
final String version;
final int apiVersion;
final String description;
final String author;
final String main;
final Set<PluginPermission> permissions;
final List<PluginCommandManifest> commands;
final Uri? updateUrl;
final PluginSignatureManifest? signature;
factory PluginManifest.fromJson(Map<String, dynamic> json) {
final schemaVersion = json['schemaVersion'];
if (schemaVersion != 1) {
throw const FormatException('Неподдерживаемая версия manifest');
}
final id = _requiredString(json, 'id');
if (!RegExp(r'^[a-z][a-z0-9]*(?:\.[a-z0-9]+)+$').hasMatch(id)) {
throw const FormatException('Некорректный id плагина');
}
final version = _requiredString(json, 'version');
if (!RegExp(r'^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$').hasMatch(version)) {
throw const FormatException('Некорректная версия плагина');
}
final apiVersion = json['apiVersion'];
if (apiVersion is! int || apiVersion < 1) {
throw const FormatException('Некорректная версия API');
}
if (apiVersion > kPluginApiVersion) {
throw FormatException('Плагину требуется API $apiVersion');
}
final main = _safeRelativePath(_requiredString(json, 'main'));
if (!main.endsWith('.js')) {
throw const FormatException(
'Главный модуль должен быть JavaScript-файлом',
);
}
final rawPermissions = json['permissions'];
if (rawPermissions is! List) {
throw const FormatException('permissions должен быть массивом');
}
final permissions = <PluginPermission>{};
for (final raw in rawPermissions) {
if (raw is! String) {
throw const FormatException('Некорректное разрешение');
}
final permission = PluginPermission.fromId(raw);
if (permission == null) {
throw FormatException('Неизвестное разрешение: $raw');
}
permissions.add(permission);
}
final rawCommands = json['commands'];
if (rawCommands is! List || rawCommands.isEmpty) {
throw const FormatException('Плагин должен содержать команды');
}
final commands = rawCommands
.map((raw) {
if (raw is! Map) {
throw const FormatException('Некорректная команда');
}
return PluginCommandManifest.fromJson(Map<String, dynamic>.from(raw));
})
.toList(growable: false);
final commandNames = <String>{};
for (final command in commands) {
if (!commandNames.add(command.name.toLowerCase())) {
throw FormatException('Команда ${command.name} объявлена дважды');
}
}
Uri? updateUrl;
final rawUpdateUrl = json['updateUrl'];
if (rawUpdateUrl != null) {
if (rawUpdateUrl is! String) {
throw const FormatException('Некорректный updateUrl');
}
updateUrl = Uri.tryParse(rawUpdateUrl.trim());
if (updateUrl == null || updateUrl.scheme != 'https') {
throw const FormatException('updateUrl должен использовать HTTPS');
}
}
PluginSignatureManifest? signature;
final rawSignature = json['signature'];
if (rawSignature != null) {
if (rawSignature is! Map) {
throw const FormatException('Некорректная подпись плагина');
}
signature = PluginSignatureManifest.fromJson(
Map<String, dynamic>.from(rawSignature),
);
}
return PluginManifest(
id: id,
name: _requiredString(json, 'name'),
version: version,
apiVersion: apiVersion,
description: _optionalString(json, 'description'),
author: _optionalString(json, 'author'),
main: main,
permissions: Set.unmodifiable(permissions),
commands: List.unmodifiable(commands),
updateUrl: updateUrl,
signature: signature,
);
}
factory PluginManifest.decode(String source) {
final decoded = jsonDecode(source);
if (decoded is! Map) {
throw const FormatException('manifest.json должен быть объектом');
}
return PluginManifest.fromJson(Map<String, dynamic>.from(decoded));
}
Map<String, dynamic> toJson() => {
'schemaVersion': 1,
'id': id,
'name': name,
'version': version,
'apiVersion': apiVersion,
'description': description,
'author': author,
'main': main,
'permissions': permissions.map((permission) => permission.id).toList(),
'commands': commands.map((command) => command.toJson()).toList(),
if (updateUrl != null) 'updateUrl': updateUrl.toString(),
if (signature != null) 'signature': signature!.toJson(),
};
Map<String, dynamic> toUnsignedJson() {
final json = toJson();
json.remove('signature');
return json;
}
}
class PluginSignatureManifest {
const PluginSignatureManifest({
required this.algorithm,
required this.publicKey,
required this.value,
});
final String algorithm;
final String publicKey;
final String value;
factory PluginSignatureManifest.fromJson(Map<String, dynamic> json) {
final algorithm = _requiredString(json, 'algorithm');
if (algorithm != 'ed25519') {
throw FormatException('Неподдерживаемый алгоритм подписи: $algorithm');
}
return PluginSignatureManifest(
algorithm: algorithm,
publicKey: _requiredString(json, 'publicKey'),
value: _requiredString(json, 'value'),
);
}
Map<String, dynamic> toJson() => {
'algorithm': algorithm,
'publicKey': publicKey,
'value': value,
};
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.trim().isEmpty) {
throw FormatException('Поле $key обязательно');
}
return value.trim();
}
String _optionalString(Map<String, dynamic> json, String key) {
final value = json[key];
return value is String ? value.trim() : '';
}
String _safeRelativePath(String path) {
final normalized = path.replaceAll('\\', '/');
if (normalized.startsWith('/') ||
normalized.split('/').any((part) => part.isEmpty || part == '..')) {
throw const FormatException('Некорректный путь в плагине');
}
return normalized;
}