import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; /// Explicit, short-lived private-LAN media sharing. The random session URL is a /// bearer capability; never log/persist it. Only caller-registered files exist /// in this HTTP namespace. No filesystem path is derived from a request. class LocalMediaServer { LocalMediaServer._( this._server, this._host, this._token, this._files, this._mediaName, this._subtitleName, ) : _port = _server.port { _server.listen((request) { final operation = _handle(request); _pending.add(operation); unawaited(operation.whenComplete(() => _pending.remove(operation))); }, onError: (Object _) {}); } final HttpServer _server; final String _host, _token, _mediaName; final String? _subtitleName; final int _port; final Map _files; final Set> _pending = {}; Future? _closing; bool _closed = false; static Future start({ required File file, File? subtitle, InternetAddress? bindAddress, String? advertisedHost, Map extraFiles = const {}, }) async { final address = bindAddress ?? await _discoverAddress(); final hostAddress = InternetAddress.tryParse( advertisedHost ?? address.address, ); if (hostAddress == null || (!hostAddress.isLoopback && !_privateV4(hostAddress))) { throw ArgumentError( 'Choose a private LAN address for local media sharing.', ); } if (!address.isLoopback && !_privateV4(address) && address.address != '0.0.0.0') { throw ArgumentError( 'Local media sharing cannot bind a public interface.', ); } final mediaName = 'media${_extension(file)}'; final subtitleName = subtitle == null ? null : 'subtitle${_extension(subtitle)}'; final selected = {mediaName: file}; if (subtitleName != null && subtitle != null) { selected[subtitleName] = subtitle; } for (final entry in extraFiles.entries) { if (!_safeName(entry.key) || selected.containsKey(entry.key)) { throw ArgumentError('Invalid or reserved local resource name.'); } selected[entry.key] = entry.value; } final registered = {}; for (final entry in selected.entries) { registered[entry.key] = await _RegisteredMedia.create(entry.value); } final random = Random.secure(); final token = base64Url .encode(List.generate(32, (_) => random.nextInt(256))) .replaceAll('=', ''); final server = await HttpServer.bind(address, 0, shared: false); server.autoCompress = false; return LocalMediaServer._( server, hostAddress.address, token, registered, mediaName, subtitleName, ); } static bool _safeName(String name) => name.isNotEmpty && name .split('/') .every( (part) => part.isNotEmpty && !part.startsWith('.') && RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(part), ); Uri _uri(String name) => Uri( scheme: 'http', host: _host, port: _port, pathSegments: [_token, ...name.split('/')], ); Uri get mediaUri => _uri(_mediaName); Uri? get subtitleUri => _subtitleName == null ? null : _uri(_subtitleName); Future close() => _closing ??= _close(); Future _close() async { _closed = true; await _server.close(force: true); await Future.wait(_pending.toList()); } Future _handle(HttpRequest request) async { final response = request.response; try { final parts = request.uri.pathSegments; final name = parts.length > 1 ? parts.skip(1).join('/') : ''; final resource = parts.isNotEmpty && parts.first == _token && _safeName(name) ? _files[name] : null; if (_closed || resource == null || !await resource.unchanged()) { response.statusCode = HttpStatus.notFound; response.contentLength = 0; await response.close(); return; } response.headers.set('access-control-allow-origin', '*'); response.headers.set( 'access-control-allow-methods', 'GET, HEAD, OPTIONS', ); response.headers.set('access-control-allow-headers', 'Range, If-Range'); response.headers.set( 'access-control-expose-headers', 'Content-Length, Content-Range, Accept-Ranges', ); if (request.headers.value('access-control-request-private-network') == 'true') { response.headers.set('access-control-allow-private-network', 'true'); } response.headers.set(HttpHeaders.cacheControlHeader, 'no-store'); if (request.method == 'OPTIONS') { response.statusCode = HttpStatus.noContent; response.contentLength = 0; await response.close(); return; } if (request.method != 'GET' && request.method != 'HEAD') { response.statusCode = HttpStatus.methodNotAllowed; response.headers.set(HttpHeaders.allowHeader, 'GET, HEAD, OPTIONS'); response.contentLength = 0; await response.close(); return; } final size = resource.stat.size; response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); response.headers.set( HttpHeaders.contentTypeHeader, _contentType(resource.source), ); response.headers.set( HttpHeaders.lastModifiedHeader, HttpDate.format(resource.stat.modified.toUtc()), ); var start = 0, end = size - 1; final range = request.headers.value(HttpHeaders.rangeHeader); if (range != null) { final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim()); var valid = match != null && size > 0 && (match.group(1)!.isNotEmpty || match.group(2)!.isNotEmpty); if (valid) { if (match.group(1)!.isEmpty) { final suffix = int.tryParse(match.group(2)!); valid = suffix != null && suffix > 0; if (valid) start = max(0, size - suffix); } else { final first = int.tryParse(match.group(1)!); final last = match.group(2)!.isEmpty ? size - 1 : int.tryParse(match.group(2)!); valid = first != null && last != null && first < size && last >= first; if (valid) { start = first; end = min(last, size - 1); } } } if (!valid) { response.statusCode = HttpStatus.requestedRangeNotSatisfiable; response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$size'); response.contentLength = 0; await response.close(); return; } response.statusCode = HttpStatus.partialContent; response.headers.set( HttpHeaders.contentRangeHeader, 'bytes $start-$end/$size', ); } response.contentLength = size == 0 ? 0 : end - start + 1; if (request.method == 'GET' && size > 0) { await response.addStream( File(resource.canonicalPath).openRead(start, end + 1), ); } await response.close(); } catch (_) { // Never expose filesystem paths or capability tokens to a disconnected peer. try { await response.close(); } catch (_) { /* Socket already closed. */ } } } static String _extension(File file) => RegExp(r'\.[A-Za-z0-9]{1,8}$') .firstMatch(file.path) ?.group(0) ?.toLowerCase() ?? ''; static String _contentType(File file) => switch (_extension(file)) { '.mp4' || '.m4v' => 'video/mp4', '.mkv' => 'video/x-matroska', '.webm' => 'video/webm', '.m3u8' => 'application/vnd.apple.mpegurl', '.mpd' => 'application/dash+xml', '.ts' => 'video/mp2t', '.m4s' => 'video/iso.segment', '.aac' => 'audio/aac', '.mp3' => 'audio/mpeg', '.vtt' => 'text/vtt; charset=utf-8', '.srt' => 'text/plain; charset=utf-8', '.ttml' || '.xml' => 'application/ttml+xml', _ => 'application/octet-stream', }; static bool _privateV4(InternetAddress address) { if (address.type != InternetAddressType.IPv4) return false; final b = address.rawAddress; return b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); } static Future _discoverAddress() async { final interfaces = await NetworkInterface.list( type: InternetAddressType.IPv4, ); for (final interface in interfaces) { if (!RegExp( r'^(wlan|wifi|eth|en)', caseSensitive: false, ).hasMatch(interface.name)) { continue; } for (final address in interface.addresses) { if (_privateV4(address)) return address; } } throw StateError( 'Connect to a private Wi-Fi or Ethernet network to share offline media.', ); } } class _RegisteredMedia { const _RegisteredMedia(this.source, this.canonicalPath, this.stat); final File source; final String canonicalPath; final FileStat stat; static Future<_RegisteredMedia> create(File file) async { final source = file.absolute; try { final path = await source.resolveSymbolicLinks(); final stat = await File(path).stat(); if (stat.type != FileSystemEntityType.file) { throw ArgumentError('Select an existing media file.'); } return _RegisteredMedia(source, path, stat); } on FileSystemException { throw ArgumentError('The selected media file is unavailable.'); } } Future unchanged() async { try { if (await source.resolveSymbolicLinks() != canonicalPath) return false; final current = await File(canonicalPath).stat(); return current.type == FileSystemEntityType.file && current.size == stat.size && current.modified == stat.modified; } on FileSystemException { return false; } } }