Skip to content
65 changes: 62 additions & 3 deletions lib/core/extensions/models/extension_manifest.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import '../../theme/theme_definition.dart';
import 'extension_type.dart';

class ExtensionManifest {
static const typeTheme = ExtensionType.theme;

final String id;
final String name;
final String version;
Expand All @@ -11,6 +14,13 @@ class ExtensionManifest {
final String? icon;
final String? description;
final String? installPath;
final String? downloadUrl;
final String? sha256Checksum;
final String? author;
final String? homepage;
final String? license;
final String? preview;
final List<String> tags;

const ExtensionManifest({
required this.id,
Expand All @@ -23,20 +33,62 @@ class ExtensionManifest {
this.icon,
this.description,
this.installPath,
this.downloadUrl,
this.sha256Checksum,
this.author,
this.homepage,
this.license,
this.preview,
this.tags = const [],
});

/// Maps a registry [ThemeDefinition] into marketplace field names.
factory ExtensionManifest.fromThemeDefinition(
ThemeDefinition definition, {
String downloadUrl = '',
String sha256Checksum = '',
ExtensionType type = typeTheme,
}) {
final metadata = definition.metadata;
return ExtensionManifest(
id: definition.id,
name: definition.name,
publisher: metadata?.author ?? 'Unknown',
type: type,
version: metadata?.version ?? '0.0.0',
engines: const {'querya_desktop': '^0.4.7'},
downloadUrl: downloadUrl,
sha256Checksum: sha256Checksum.isNotEmpty
? sha256Checksum
: (definition.contentHash ?? ''),
author: metadata?.author,
description: metadata?.description,
homepage: metadata?.homepage,
license: metadata?.license,
preview: metadata?.preview,
tags: metadata?.tags ?? const [],
);
}

factory ExtensionManifest.fromJson(Map<String, dynamic> json, {String? installPath}) {
return ExtensionManifest(
id: json['id'] as String,
name: json['name'] as String,
version: json['version'] as String,
publisher: json['publisher'] as String,
type: ExtensionType.fromString(json['type'] as String),
version: json['version'] as String? ?? '0.0.0',
publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown',
type: ExtensionType.fromString(json['type'] as String? ?? ''),
engines: Map<String, String>.from(json['engines'] as Map? ?? {}),
main: json['main'] as String?,
icon: json['icon'] as String?,
description: json['description'] as String?,
installPath: installPath,
downloadUrl: json['downloadUrl'] as String?,
sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?,
author: json['author'] as String?,
homepage: json['homepage'] as String?,
license: json['license'] as String?,
preview: json['preview'] as String?,
tags: List<String>.from(json['tags'] as List? ?? []),
);
}

Expand All @@ -51,6 +103,13 @@ class ExtensionManifest {
if (main != null) 'main': main,
if (icon != null) 'icon': icon,
if (description != null) 'description': description,
if (downloadUrl != null) 'downloadUrl': downloadUrl,
if (sha256Checksum != null) 'sha256Checksum': sha256Checksum,
if (author != null) 'author': author,
if (homepage != null) 'homepage': homepage,
if (license != null) 'license': license,
if (preview != null) 'preview': preview,
if (tags.isNotEmpty) 'tags': tags,
};
}
}
65 changes: 1 addition & 64 deletions lib/core/market/extension_manifest.dart
Original file line number Diff line number Diff line change
@@ -1,65 +1,2 @@
import '../theme/theme_definition.dart';
export 'package:querya_desktop/core/extensions/models/extension_manifest.dart';

/// Marketplace listing model (future `MarketplaceClient` response shape).
///
/// See [docs/market-tech.md](https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/market-tech.md).
class ExtensionManifest {
const ExtensionManifest({
required this.id,
required this.name,
required this.type,
required this.version,
required this.downloadUrl,
required this.sha256Checksum,
this.author,
this.description,
this.homepage,
this.license,
this.preview,
this.tags = const [],
});

static const typeTheme = 'theme';

final String id;
final String name;
final String type;
final String version;
final String downloadUrl;
final String sha256Checksum;
final String? author;
final String? description;
final String? homepage;
final String? license;
final String? preview;
final List<String> tags;

/// Maps a registry [ThemeDefinition] into marketplace field names.
///
/// [downloadUrl] and [sha256Checksum] are required for remote install (TP-F4);
/// pass empty strings when building a local-only listing stub.
factory ExtensionManifest.fromThemeDefinition(
ThemeDefinition definition, {
String downloadUrl = '',
String sha256Checksum = '',
String type = typeTheme,
}) {
final metadata = definition.metadata;
return ExtensionManifest(
id: definition.id,
name: definition.name,
type: type,
version: metadata?.version ?? '0.0.0',
downloadUrl: downloadUrl,
sha256Checksum: sha256Checksum.isNotEmpty
? sha256Checksum
: (definition.contentHash ?? ''),
author: metadata?.author,
description: metadata?.description,
homepage: metadata?.homepage,
license: metadata?.license,
preview: metadata?.preview,
tags: metadata?.tags ?? const [],
);
}
}
204 changes: 204 additions & 0 deletions lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/extensions/extension_paths.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'marketplace_repository.dart';

class MarketplaceException implements Exception {
MarketplaceException(this.message);
final String message;
@override
String toString() => 'MarketplaceException: $message';
}

/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend.
///
/// Implements secure downloading with SHA256 checksum verification and safe
/// archive extraction preventing Path Traversal and Zip Bomb vulnerabilities (Issue #242).
class HttpMarketplaceRepository implements MarketplaceRepository {
HttpMarketplaceRepository({
this.baseUrl = 'http://localhost:8000/api/v1',
http.Client? client,
}) : _client = client ?? http.Client();

final String baseUrl;
final http.Client _client;

@override
Future<List<ExtensionManifest>> getTrending({ExtensionType? type}) async {
final uri = Uri.parse('$baseUrl/extensions/trending').replace(
queryParameters: type != null ? {'type': type.value} : null,
);
final response = await _client.get(uri).timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})');
}
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
return data.map((json) => ExtensionManifest.fromJson(json as Map<String, dynamic>)).toList();
}

@override
Future<List<ExtensionManifest>> search(String query, {ExtensionType? type}) async {
final uri = Uri.parse('$baseUrl/extensions/search').replace(
queryParameters: {
'q': query.trim(),
if (type != null) 'type': type.value,
},
);
final response = await _client.get(uri).timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw MarketplaceException('Search failed (HTTP ${response.statusCode})');
}
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
return data.map((json) => ExtensionManifest.fromJson(json as Map<String, dynamic>)).toList();
}

@override
Future<File> download(String url, {void Function(double)? onProgress}) async {
final uri = Uri.tryParse(url);
if (uri == null) {
throw MarketplaceException('Invalid download URL: $url');
}

final request = http.Request('GET', uri);
final response = await _client.send(request).timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
throw MarketplaceException('Download failed with HTTP status ${response.statusCode}');
}

final contentLength = response.contentLength ?? 0;
int received = 0;

final tmpDir = Directory.systemTemp;
final file = File(p.join(tmpDir.path, 'querya_ext_${DateTime.now().millisecondsSinceEpoch}.zip'));
final sink = file.openWrite();

try {
await response.stream.forEach((chunk) {
sink.add(chunk);
received += chunk.length;
if (contentLength > 0 && onProgress != null) {
onProgress(received / contentLength);
}
});
} finally {
await sink.close();
}

if (onProgress != null && contentLength == 0) {
onProgress(1.0);
}

return file;
}

@override
Future<void> install(
ExtensionManifest manifest, {
void Function(double)? onProgress,
}) async {
final downloadUrl = manifest.downloadUrl;
if (downloadUrl == null || downloadUrl.trim().isEmpty) {
throw MarketplaceException('Extension manifest is missing downloadUrl');
}

// Step 1: Download archive with progress reporting (up to 80% of total progress)
final archiveFile = await download(
downloadUrl,
onProgress: (p) => onProgress?.call(p * 0.8),
);

try {
// Step 2: SHA-256 Integrity Verification (Critical Security Check)
if (manifest.sha256Checksum != null && manifest.sha256Checksum!.trim().isNotEmpty) {
final bytes = await archiveFile.readAsBytes();
final actualSha256 = sha256.convert(bytes).toString().toLowerCase();
final expectedSha256 = manifest.sha256Checksum!.trim().toLowerCase();
if (actualSha256 != expectedSha256) {
throw MarketplaceException(
'SHA256 checksum mismatch for "${manifest.id}". Expected: $expectedSha256, Actual: $actualSha256. Installation aborted.',
);
}
}

onProgress?.call(0.85);

// Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242)
final bytes = await archiveFile.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);

final dir = await ExtensionPaths.extensionsDirectory();
final extDir = Directory(p.join(dir.path, manifest.id));
if (!await extDir.exists()) {
await extDir.create(recursive: true);
}

final extDirPath = p.normalize(extDir.path);

for (final file in archive) {
final filename = file.name;
// Check for Path Traversal attempts
if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) {
throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"');
}

final targetPath = p.normalize(p.join(extDirPath, filename));
if (!targetPath.startsWith(extDirPath)) {
throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"');
}

if (file.isFile) {
final outFile = File(targetPath);
await outFile.create(recursive: true);
await outFile.writeAsBytes(file.content as List<int>);
} else {
await Directory(targetPath).create(recursive: true);
}
}

onProgress?.call(0.95);

// Step 4: Write/Update manifest.json in the extension directory
final manifestFile = File(p.join(extDir.path, 'manifest.json'));
const encoder = JsonEncoder.withIndent(' ');
await manifestFile.writeAsString(encoder.convert(manifest.toJson()));

// Step 5: Reload local extension registry
await LocalExtensionRegistry.instance.reload();
onProgress?.call(1.0);
} finally {
if (await archiveFile.exists()) {
await archiveFile.delete();
}
}
}

@override
Future<void> uninstall(String extensionId) async {
final manifest = LocalExtensionRegistry.instance.manifests
.where((e) => e.id == extensionId)
.firstOrNull;

if (manifest != null && manifest.installPath != null) {
final extDir = Directory(manifest.installPath!);
if (await extDir.exists()) {
await extDir.delete(recursive: true);
}
} else {
final dir = await ExtensionPaths.extensionsDirectory();
final extDir = Directory(p.join(dir.path, extensionId));
if (await extDir.exists()) {
await extDir.delete(recursive: true);
}
}

await LocalExtensionRegistry.instance.reload();
}
}
Loading
Loading