Files
SpotiFLAC-Mobile/lib/services/network_storage_service.dart
T
zarzet 927fd033cd perf(nas): process listings, artwork caches, and upload recovery natively
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
2026-10-06 23:27:07 +07:00

742 lines
23 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: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:xml/xml.dart';
part 'network_storage_upload.dart';
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();
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);
try {
final files = await smb.list().timeout(timeout);
return _sorted([
for (final file in files)
if (file.name != '.' && file.name != '..')
NetworkEntry(
'$path${file.name}${file.stat.isDirectory ? '/' : ''}',
file.name,
directory: file.stat.isDirectory,
size: file.stat.size,
),
]);
} finally {
// A failed disconnect must not hide the original share/login error.
try {
await smb.close();
} catch (_) {}
}
}
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);
}
}
static List<NetworkEntry> _sorted(List<NetworkEntry> entries) =>
entries..sort(
(a, b) => a.directory != b.directory
? (a.directory ? -1 : 1)
: a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
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',
};