mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
676 lines
21 KiB
Dart
676 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:crypto/crypto.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:xml/xml.dart';
|
|
|
|
part 'network_storage_upload.dart';
|
|
|
|
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 List.unmodifiable(_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 = result;
|
|
return List<NetworkConnection>.unmodifiable(result);
|
|
} 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 = 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 = 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 = <int>[];
|
|
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.addAll(chunk);
|
|
}
|
|
return parseListing(c, path, utf8.decode(bytes, allowMalformed: true));
|
|
} 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());
|
|
}
|
|
|
|
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',
|
|
};
|