mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
Disable automatic deletion across clients sharing Android secure storage and enable protected migration. Reject replacement keys when encrypted settings exist or the saved key is malformed. Surface preparation failures through extension state and preserve retry behavior. Validation: targeted Flutter tests, analyzer and Android secure-storage compatibility test passed. The original device key-loss trigger is not established.
786 lines
25 KiB
Dart
786 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:dart_smb2/dart_smb2.dart' show Smb2DirEntry;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:html/parser.dart' as html;
|
|
import 'package:spotiflac_android/services/network_certificate.dart';
|
|
import 'package:spotiflac_android/services/network_smb_client.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/services/secure_storage_options.dart';
|
|
import 'package:xml/xml.dart';
|
|
|
|
part 'network_storage_upload.dart';
|
|
|
|
typedef _SmbListingInput = ({
|
|
List<({String name, bool directory, int size})> files,
|
|
String path,
|
|
});
|
|
|
|
List<NetworkEntry> _prepareSmbListingInBackground(_SmbListingInput input) =>
|
|
NetworkStorageService._sorted([
|
|
for (final file in input.files)
|
|
if (file.name != '.' && file.name != '..')
|
|
NetworkEntry(
|
|
'${input.path}${file.name}${file.directory ? '/' : ''}',
|
|
file.name,
|
|
directory: file.directory,
|
|
size: file.size,
|
|
),
|
|
]);
|
|
|
|
List<NetworkEntry> _parseNetworkListingInBackground(
|
|
Map<String, dynamic> request,
|
|
) {
|
|
final connection = NetworkConnection(
|
|
id: 'listing',
|
|
name: 'listing',
|
|
protocol: NetworkProtocol.values.byName(request['protocol'] as String),
|
|
address: request['address'] as String,
|
|
);
|
|
return NetworkStorageService.parseListing(
|
|
connection,
|
|
request['path'] as String,
|
|
utf8.decode(request['bytes'] as Uint8List, allowMalformed: true),
|
|
);
|
|
}
|
|
|
|
List<NetworkEntry> _hydrateNetworkEntriesInBackground(List<dynamic> entries) =>
|
|
[
|
|
for (final raw in entries)
|
|
NetworkEntry(
|
|
(raw as Map)['path'] as String,
|
|
raw['name'] as String,
|
|
directory: raw['directory'] as bool,
|
|
size: (raw['size'] as num?)?.toInt(),
|
|
),
|
|
];
|
|
|
|
enum NetworkProtocol { smb, webdav, http }
|
|
|
|
class NetworkConnection {
|
|
const NetworkConnection({
|
|
required this.id,
|
|
required this.name,
|
|
required this.protocol,
|
|
required this.address,
|
|
this.username = '',
|
|
this.password = '',
|
|
this.domain = '',
|
|
this.trustedCertificateSha256,
|
|
});
|
|
final String id, name, address, username, password, domain;
|
|
final NetworkProtocol protocol;
|
|
final String? trustedCertificateSha256;
|
|
Uri get root => Uri.parse(address);
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'protocol': protocol.name,
|
|
'address': address,
|
|
'username': username,
|
|
'password': password,
|
|
'domain': domain,
|
|
if (trustedCertificateSha256 != null)
|
|
'trustedCertificateSha256': trustedCertificateSha256,
|
|
};
|
|
factory NetworkConnection.fromJson(Map<String, dynamic> json) =>
|
|
NetworkConnection(
|
|
id: json['id'] as String,
|
|
name: json['name'] as String,
|
|
protocol: NetworkProtocol.values.byName(json['protocol'] as String),
|
|
address: json['address'] as String,
|
|
username: json['username'] as String? ?? '',
|
|
password: json['password'] as String? ?? '',
|
|
domain: json['domain'] as String? ?? '',
|
|
trustedCertificateSha256: json['trustedCertificateSha256'] as String?,
|
|
);
|
|
|
|
void validate() {
|
|
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(id) ||
|
|
name.trim().isEmpty ||
|
|
root.host.isEmpty ||
|
|
root.userInfo.isNotEmpty ||
|
|
root.hasFragment ||
|
|
root.pathSegments.any((s) => s == '..') ||
|
|
(root.hasQuery && protocol != NetworkProtocol.http) ||
|
|
(protocol == NetworkProtocol.smb
|
|
? root.scheme != 'smb' ||
|
|
(root.hasPort && (root.port < 1 || root.port > 65535))
|
|
: !['http', 'https'].contains(root.scheme))) {
|
|
throw const FormatException('Invalid network address');
|
|
}
|
|
}
|
|
}
|
|
|
|
class NetworkEntry {
|
|
const NetworkEntry(this.path, this.name, {this.directory = false, this.size});
|
|
final String path, name;
|
|
final bool directory;
|
|
final int? size;
|
|
String get extension => name.split('.').last.toLowerCase();
|
|
bool get audio =>
|
|
!directory &&
|
|
const {
|
|
'flac',
|
|
'mp3',
|
|
'm4a',
|
|
'mp4',
|
|
'aac',
|
|
'ogg',
|
|
'opus',
|
|
'wav',
|
|
'aiff',
|
|
'aif',
|
|
'alac',
|
|
}.contains(extension);
|
|
}
|
|
|
|
/// Paths remain relative to the selected root, never credential-bearing URLs.
|
|
String networkPath(String path) {
|
|
if (path.startsWith('/') ||
|
|
path.contains('\\') ||
|
|
path.split('/').any((part) => part == '..' || part == '.') ||
|
|
path.contains('\u0000')) {
|
|
throw const FormatException('Invalid network path');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
Uri networkTarget(NetworkConnection connection, String path) {
|
|
networkPath(path);
|
|
final root = connection.root;
|
|
if (path.isEmpty) return root;
|
|
final segments = [...root.pathSegments];
|
|
if (segments.isNotEmpty && segments.last.isEmpty) segments.removeLast();
|
|
return Uri(
|
|
scheme: root.scheme,
|
|
host: root.host,
|
|
port: root.hasPort ? root.port : null,
|
|
pathSegments: [...segments, ...path.split('/')],
|
|
);
|
|
}
|
|
|
|
/// Inclusive byte range; a null header means the entire representation.
|
|
({int start, int end}) networkByteRange(String? header, int size) {
|
|
if (header == null) return (start: 0, end: size - 1);
|
|
final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(header);
|
|
if (match == null || size <= 0 || (match[1]!.isEmpty && match[2]!.isEmpty)) {
|
|
throw const FormatException('Invalid range');
|
|
}
|
|
final suffix = match[1]!.isEmpty;
|
|
final start = suffix
|
|
? max(0, size - int.parse(match[2]!))
|
|
: int.parse(match[1]!);
|
|
final end = suffix || match[2]!.isEmpty
|
|
? size - 1
|
|
: min(size - 1, int.parse(match[2]!));
|
|
if (start >= size || start > end) {
|
|
throw const FormatException('Invalid range');
|
|
}
|
|
return (start: start, end: end);
|
|
}
|
|
|
|
class NetworkStorageService {
|
|
NetworkStorageService({
|
|
Future<String?> Function()? read,
|
|
Future<void> Function(String)? write,
|
|
}) : _read = read ?? (() => _storage.read(key: _key)),
|
|
_write = write ?? ((value) => _storage.write(key: _key, value: value));
|
|
static final instance = NetworkStorageService();
|
|
static const _storage = FlutterSecureStorage(
|
|
aOptions: secureStorageAndroidOptions,
|
|
);
|
|
static const _key = 'network_storage_connections_v1';
|
|
final Future<String?> Function() _read;
|
|
final Future<void> Function(String) _write;
|
|
static const timeout = Duration(seconds: 20);
|
|
Future<List<NetworkConnection>>? _loading;
|
|
List<NetworkConnection>? _connections;
|
|
Future<HttpServer>? _server;
|
|
Future<void> _mutation = Future.value();
|
|
final _routes = <String, ({String id, String path})>{};
|
|
|
|
static String newId() {
|
|
final random = Random.secure();
|
|
return List.generate(
|
|
24,
|
|
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
|
).join();
|
|
}
|
|
|
|
Future<void> _mutate(Future<void> Function() action) {
|
|
final result = _mutation.then((_) => action());
|
|
_mutation = result.catchError((Object _) {});
|
|
return result;
|
|
}
|
|
|
|
Future<List<NetworkConnection>> connections() async {
|
|
if (_connections != null) return _connections!;
|
|
return _loading ??= () async {
|
|
try {
|
|
final raw = await _read();
|
|
final result = raw == null
|
|
? <NetworkConnection>[]
|
|
: [
|
|
for (final row in jsonDecode(raw) as List)
|
|
NetworkConnection.fromJson(
|
|
Map<String, dynamic>.from(row as Map),
|
|
),
|
|
];
|
|
_connections = List<NetworkConnection>.unmodifiable(result);
|
|
return _connections!;
|
|
} finally {
|
|
_loading = null;
|
|
}
|
|
}();
|
|
}
|
|
|
|
Future<void> save(NetworkConnection connection) => _mutate(() async {
|
|
connection.validate();
|
|
final next = [...await connections()]
|
|
..removeWhere((c) => c.id == connection.id);
|
|
next.add(connection);
|
|
await _write(jsonEncode(next.map((c) => c.toJson()).toList()));
|
|
_connections = List.unmodifiable(next);
|
|
});
|
|
|
|
Future<void> remove(String id) => _mutate(() async {
|
|
final next = [...await connections()]..removeWhere((c) => c.id == id);
|
|
await _write(jsonEncode(next.map((c) => c.toJson()).toList()));
|
|
_connections = List.unmodifiable(next);
|
|
_routes.removeWhere((_, route) => route.id == id);
|
|
});
|
|
|
|
Future<NetworkConnection> connection(String id) async =>
|
|
(await connections()).firstWhere(
|
|
(c) => c.id == id,
|
|
orElse: () => throw StateError('Network connection unavailable'),
|
|
);
|
|
|
|
Future<void> checkUploadAccess(String folder) => _checkUploadAccess(folder);
|
|
|
|
static String source(String id, String path) => Uri(
|
|
scheme: 'network',
|
|
host: id,
|
|
pathSegments: networkPath(path).split('/'),
|
|
).toString();
|
|
|
|
Future<String> resolve(String source) async {
|
|
final uri = Uri.parse(source);
|
|
if (uri.scheme != 'network' ||
|
|
uri.userInfo.isNotEmpty ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment) {
|
|
throw const FormatException('Invalid network source');
|
|
}
|
|
final path = networkPath(uri.pathSegments.join('/'));
|
|
await connection(uri.host);
|
|
_server ??= HttpServer.bind(InternetAddress.loopbackIPv4, 0).then((server) {
|
|
server.listen((request) => unawaited(_serve(request)));
|
|
return server;
|
|
});
|
|
final HttpServer server;
|
|
try {
|
|
server = await _server!;
|
|
} catch (_) {
|
|
_server = null;
|
|
rethrow;
|
|
}
|
|
final token = newId();
|
|
while (_routes.length >= 256) {
|
|
_routes.remove(_routes.keys.first);
|
|
}
|
|
_routes[token] = (id: uri.host, path: path);
|
|
return 'http://127.0.0.1:${server.port}/$token';
|
|
}
|
|
|
|
Future<NetworkSmbClient> _smb(NetworkConnection c, Uri target) async {
|
|
var expired = false;
|
|
final pending = NetworkSmbClient.connect(
|
|
target,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: c.domain,
|
|
timeout: timeout,
|
|
);
|
|
unawaited(
|
|
pending
|
|
.then((smb) async {
|
|
if (expired) await smb.close();
|
|
})
|
|
.catchError((Object _) {}),
|
|
);
|
|
return pending.timeout(
|
|
timeout,
|
|
onTimeout: () {
|
|
expired = true;
|
|
throw TimeoutException('Network connection timed out');
|
|
},
|
|
);
|
|
}
|
|
|
|
HttpClient _client() => HttpClient()
|
|
..connectionTimeout = timeout
|
|
..idleTimeout = timeout
|
|
..autoUncompress = false;
|
|
|
|
Future<HttpClientResponse> _request(
|
|
HttpClient client,
|
|
NetworkConnection c,
|
|
String method,
|
|
Uri uri, {
|
|
String? range,
|
|
bool dav = false,
|
|
bool directoryListing = false,
|
|
int redirects = 0,
|
|
Map<String, String> headers = const {},
|
|
File? upload,
|
|
void Function()? checkpoint,
|
|
void Function(int)? progress,
|
|
}) async {
|
|
NetworkCertificateException? rejectedCertificate;
|
|
client.badCertificateCallback = (certificate, host, port) {
|
|
if (uri.scheme != 'https' ||
|
|
uri.origin != c.root.origin ||
|
|
host != c.root.host ||
|
|
port != c.root.port) {
|
|
return false;
|
|
}
|
|
final presented = NetworkCertificateException.fromCertificate(
|
|
uri,
|
|
certificate,
|
|
);
|
|
if (c.trustedCertificateSha256 == presented.fingerprint) return true;
|
|
rejectedCertificate = presented;
|
|
return false;
|
|
};
|
|
final HttpClientRequest request;
|
|
try {
|
|
request = await client.openUrl(method, uri).timeout(timeout);
|
|
} on HandshakeException {
|
|
if (rejectedCertificate != null) throw rejectedCertificate!;
|
|
rethrow;
|
|
}
|
|
request.followRedirects = false;
|
|
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
|
|
if (c.username.isNotEmpty || c.password.isNotEmpty) {
|
|
request.headers.set(
|
|
HttpHeaders.authorizationHeader,
|
|
'Basic ${base64Encode(utf8.encode('${c.username}:${c.password}'))}',
|
|
);
|
|
}
|
|
if (range != null) request.headers.set(HttpHeaders.rangeHeader, range);
|
|
headers.forEach(request.headers.set);
|
|
if (upload != null) {
|
|
request.contentLength = await upload.length();
|
|
var sent = 0;
|
|
try {
|
|
await for (final chunk in upload.openRead()) {
|
|
checkpoint?.call();
|
|
request.add(chunk);
|
|
await request.flush().timeout(timeout);
|
|
sent += chunk.length;
|
|
progress?.call(sent);
|
|
}
|
|
} catch (error) {
|
|
request.abort();
|
|
rethrow;
|
|
}
|
|
}
|
|
if (dav) {
|
|
request.headers.set('Depth', '1');
|
|
request.headers.contentType = ContentType(
|
|
'application',
|
|
'xml',
|
|
charset: 'utf-8',
|
|
);
|
|
request.write(
|
|
'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop>'
|
|
'<d:resourcetype/><d:getcontentlength/></d:prop></d:propfind>',
|
|
);
|
|
}
|
|
final HttpClientResponse response;
|
|
try {
|
|
response = await request.close().timeout(timeout);
|
|
} on HandshakeException {
|
|
if (rejectedCertificate != null) throw rejectedCertificate!;
|
|
rethrow;
|
|
}
|
|
if ([301, 302, 307, 308].contains(response.statusCode)) {
|
|
// Never replay a mutating request to an unselected path.
|
|
if (!['GET', 'HEAD', 'PROPFIND'].contains(method)) {
|
|
throw const HttpException('Write destination redirected');
|
|
}
|
|
final location = response.headers.value(HttpHeaders.locationHeader);
|
|
final next = location == null ? null : uri.resolve(location);
|
|
// Keep credentials on the configured server; never forward them to a CDN
|
|
// or follow a TLS downgrade, even when requested by a redirect.
|
|
if (next == null ||
|
|
next.origin != uri.origin ||
|
|
next.userInfo.isNotEmpty ||
|
|
(directoryListing &&
|
|
next.path != uri.path &&
|
|
next.path != '${uri.path}/') ||
|
|
redirects >= 3) {
|
|
throw const HttpException('Unsupported server redirect');
|
|
}
|
|
await response.drain<void>().timeout(timeout);
|
|
return _request(
|
|
client,
|
|
c,
|
|
method,
|
|
next,
|
|
range: range,
|
|
dav: dav,
|
|
directoryListing: directoryListing,
|
|
redirects: redirects + 1,
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
Future<List<NetworkEntry>> list(
|
|
NetworkConnection c, [
|
|
String path = '',
|
|
]) async {
|
|
c.validate();
|
|
final target = networkTarget(c, path);
|
|
if (c.protocol == NetworkProtocol.smb) {
|
|
if (target.pathSegments.every((s) => s.isEmpty)) {
|
|
final shares = await NetworkSmbClient.shares(
|
|
target,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: c.domain,
|
|
timeout: timeout,
|
|
).timeout(timeout);
|
|
return _sorted([
|
|
for (final share in shares)
|
|
if (share.isDisk)
|
|
NetworkEntry('$path${share.name}/', share.name, directory: true),
|
|
]);
|
|
}
|
|
final smb = await _smb(c, target);
|
|
final List<Smb2DirEntry> files;
|
|
try {
|
|
files = await smb.list().timeout(timeout);
|
|
} finally {
|
|
// A failed disconnect must not hide the original share/login error.
|
|
try {
|
|
await smb.close();
|
|
} catch (_) {}
|
|
}
|
|
return prepareSmbEntries(files, path);
|
|
}
|
|
final client = _client();
|
|
try {
|
|
if (c.protocol == NetworkProtocol.http &&
|
|
NetworkEntry('', target.pathSegments.lastOrNull ?? '').audio) {
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
'GET',
|
|
target,
|
|
range: 'bytes=0-0',
|
|
);
|
|
if (response.statusCode != 200 && response.statusCode != 206) {
|
|
throw HttpException('Server returned ${response.statusCode}');
|
|
}
|
|
return [NetworkEntry(path, target.pathSegments.last)];
|
|
}
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
c.protocol == NetworkProtocol.webdav ? 'PROPFIND' : 'GET',
|
|
target,
|
|
dav: c.protocol == NetworkProtocol.webdav,
|
|
directoryListing: true,
|
|
);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw HttpException('Server returned ${response.statusCode}');
|
|
}
|
|
final bytes = BytesBuilder();
|
|
await for (final chunk in response.timeout(timeout)) {
|
|
if (bytes.length + chunk.length > 4 * 1024 * 1024) {
|
|
throw const HttpException('Directory listing is too large');
|
|
}
|
|
bytes.add(chunk);
|
|
}
|
|
return await parseListingInBackground(c, path, bytes.takeBytes());
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
}
|
|
|
|
/// Large SMB listings arrive from native I/O as plain stat objects. Transfer
|
|
/// only fields used by the browser, avoiding copies of unused timestamps.
|
|
static Future<List<NetworkEntry>> prepareSmbEntries(
|
|
List<Smb2DirEntry> files,
|
|
String path,
|
|
) async {
|
|
final input = (
|
|
path: path,
|
|
files: [
|
|
for (final file in files)
|
|
(
|
|
name: file.name,
|
|
directory: file.stat.isDirectory,
|
|
size: file.stat.size,
|
|
),
|
|
],
|
|
);
|
|
// Small lists are cheaper inline; large sorts must leave the UI isolate.
|
|
if (files.length < 4096) return _prepareSmbListingInBackground(input);
|
|
return compute(_prepareSmbListingInBackground, input);
|
|
}
|
|
|
|
static List<NetworkEntry> _sorted(List<NetworkEntry> entries) {
|
|
final keyed =
|
|
[
|
|
for (final entry in entries)
|
|
(entry: entry, key: entry.name.toLowerCase()),
|
|
]..sort(
|
|
(a, b) => a.entry.directory != b.entry.directory
|
|
? (a.entry.directory ? -1 : 1)
|
|
: a.key.compareTo(b.key),
|
|
);
|
|
for (var index = 0; index < entries.length; index++) {
|
|
entries[index] = keyed[index].entry;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
static List<NetworkEntry> parseListing(
|
|
NetworkConnection c,
|
|
String path,
|
|
String body,
|
|
) {
|
|
final target = networkTarget(c, path);
|
|
final base = target.path.endsWith('/')
|
|
? target
|
|
: target.replace(path: '${target.path}/');
|
|
final found = <String, NetworkEntry>{};
|
|
void add(String href, bool directory, int? size) {
|
|
final uri = base.resolve(href);
|
|
if (uri.origin != base.origin ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment ||
|
|
uri.userInfo.isNotEmpty ||
|
|
!uri.path.startsWith(base.path)) {
|
|
return;
|
|
}
|
|
final tail = Uri.decodeComponent(uri.path.substring(base.path.length));
|
|
final name = tail.endsWith('/')
|
|
? tail.substring(0, tail.length - 1)
|
|
: tail;
|
|
if (name.isEmpty ||
|
|
name.contains('/') ||
|
|
name == '..' ||
|
|
name == '.' ||
|
|
name.contains('\\')) {
|
|
return;
|
|
}
|
|
final relative = '$path$name${directory ? '/' : ''}';
|
|
found[relative] = NetworkEntry(
|
|
relative,
|
|
name,
|
|
directory: directory,
|
|
size: size,
|
|
);
|
|
}
|
|
|
|
if (c.protocol == NetworkProtocol.webdav) {
|
|
final doc = XmlDocument.parse(body);
|
|
for (final response in doc.descendants.whereType<XmlElement>().where(
|
|
(e) => e.name.local == 'response',
|
|
)) {
|
|
final elements = response.descendants.whereType<XmlElement>();
|
|
final href = elements
|
|
.where((e) => e.name.local == 'href')
|
|
.firstOrNull
|
|
?.innerText;
|
|
final props = elements
|
|
.where((e) => e.name.local == 'propstat')
|
|
.where(
|
|
(e) => e.childElements.any(
|
|
(s) =>
|
|
s.name.local == 'status' && s.innerText.contains(' 200 '),
|
|
),
|
|
);
|
|
final values = props.expand(
|
|
(p) => p.descendants.whereType<XmlElement>(),
|
|
);
|
|
if (href != null && props.isNotEmpty) {
|
|
add(
|
|
href,
|
|
values.any((e) => e.name.local == 'collection'),
|
|
int.tryParse(
|
|
values
|
|
.where((e) => e.name.local == 'getcontentlength')
|
|
.firstOrNull
|
|
?.innerText ??
|
|
'',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
for (final a in html.parse(body).querySelectorAll('a[href]')) {
|
|
final href = a.attributes['href']!;
|
|
add(href, Uri.tryParse(href)?.path.endsWith('/') == true, null);
|
|
}
|
|
}
|
|
return _sorted(found.values.toList());
|
|
}
|
|
|
|
/// Keep DOM parsing and sorting away from the Flutter frame while retaining
|
|
/// the synchronous parser for unsupported native hosts and contract tests.
|
|
static Future<List<NetworkEntry>> parseListingInBackground(
|
|
NetworkConnection connection,
|
|
String path,
|
|
Uint8List bytes,
|
|
) async {
|
|
List<dynamic> entries;
|
|
try {
|
|
final result = await PlatformBridge.runNativeDataJob({
|
|
'operation': 'parse_network_listing',
|
|
'protocol': connection.protocol.name,
|
|
'base_url': networkTarget(connection, path).toString(),
|
|
'path': path,
|
|
}, bytes: bytes);
|
|
entries = result['entries'] as List;
|
|
} on MissingPluginException {
|
|
return compute(_parseNetworkListingInBackground, {
|
|
'protocol': connection.protocol.name,
|
|
'address': connection.address,
|
|
'path': path,
|
|
'bytes': bytes,
|
|
});
|
|
} on PlatformException catch (error) {
|
|
if (error.message?.contains('XML nesting exceeds') != true) rethrow;
|
|
return compute(_parseNetworkListingInBackground, {
|
|
'protocol': connection.protocol.name,
|
|
'address': connection.address,
|
|
'path': path,
|
|
'bytes': bytes,
|
|
});
|
|
}
|
|
return compute(_hydrateNetworkEntriesInBackground, entries);
|
|
}
|
|
|
|
Future<void> _serve(HttpRequest request) async {
|
|
HttpClient? client;
|
|
NetworkSmbClient? smb;
|
|
try {
|
|
final route = _routes[request.uri.path.replaceFirst('/', '')];
|
|
if (route == null || !['GET', 'HEAD'].contains(request.method)) {
|
|
request.response.statusCode = HttpStatus.notFound;
|
|
return;
|
|
}
|
|
final c = await connection(route.id);
|
|
final target = networkTarget(c, route.path);
|
|
final range = request.headers.value(HttpHeaders.rangeHeader);
|
|
if (c.protocol == NetworkProtocol.smb) {
|
|
smb = await _smb(c, target);
|
|
final file = await smb.stat().timeout(timeout);
|
|
if (!file.isFile) {
|
|
throw const HttpException('File unavailable');
|
|
}
|
|
final ({int start, int end}) part;
|
|
try {
|
|
part = networkByteRange(range, file.size);
|
|
} on FormatException {
|
|
request.response.statusCode = HttpStatus.requestedRangeNotSatisfiable;
|
|
request.response.headers.set(
|
|
HttpHeaders.contentRangeHeader,
|
|
'bytes */${file.size}',
|
|
);
|
|
return;
|
|
}
|
|
request.response.statusCode = range == null ? 200 : 206;
|
|
request.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes');
|
|
request.response.headers.contentType = ContentType.parse(
|
|
networkContentType(target.pathSegments.last),
|
|
);
|
|
request.response.contentLength = part.end - part.start + 1;
|
|
if (range != null) {
|
|
request.response.headers.set(
|
|
HttpHeaders.contentRangeHeader,
|
|
'bytes ${part.start}-${part.end}/${file.size}',
|
|
);
|
|
}
|
|
if (request.method == 'GET' && file.size > 0) {
|
|
await request.response.addStream(
|
|
smb.read(part.start, part.end + 1).timeout(timeout),
|
|
);
|
|
}
|
|
} else {
|
|
client = _client();
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
request.method,
|
|
target,
|
|
range: range,
|
|
);
|
|
request.response.statusCode =
|
|
response.statusCode >= 300 && response.statusCode < 400
|
|
? HttpStatus.badGateway
|
|
: response.statusCode;
|
|
for (final header in [
|
|
HttpHeaders.contentTypeHeader,
|
|
HttpHeaders.contentLengthHeader,
|
|
HttpHeaders.contentRangeHeader,
|
|
HttpHeaders.acceptRangesHeader,
|
|
]) {
|
|
final value = response.headers.value(header);
|
|
if (value != null) request.response.headers.set(header, value);
|
|
}
|
|
if (request.method == 'GET') {
|
|
await request.response.addStream(response.timeout(timeout));
|
|
}
|
|
}
|
|
} catch (_) {
|
|
try {
|
|
request.response.statusCode = HttpStatus.badGateway;
|
|
} catch (_) {}
|
|
} finally {
|
|
client?.close(force: true);
|
|
try {
|
|
await smb?.close();
|
|
} catch (_) {}
|
|
try {
|
|
await request.response.close();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
final server = _server;
|
|
_server = null;
|
|
_routes.clear();
|
|
if (server != null) await (await server).close(force: true);
|
|
}
|
|
}
|
|
|
|
String networkContentType(String name) =>
|
|
switch (name.split('.').last.toLowerCase()) {
|
|
'mp3' => 'audio/mpeg',
|
|
'flac' => 'audio/flac',
|
|
'm4a' || 'mp4' || 'alac' => 'audio/mp4',
|
|
'aac' => 'audio/aac',
|
|
'ogg' || 'opus' => 'audio/ogg',
|
|
'wav' => 'audio/wav',
|
|
'aif' || 'aiff' => 'audio/aiff',
|
|
'jpg' || 'jpeg' => 'image/jpeg',
|
|
'png' => 'image/png',
|
|
_ => 'application/octet-stream',
|
|
};
|