354 lines
14 KiB
Dart
354 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io' show InternetAddress;
|
|
import 'dart:math' show min;
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../l10n/tr.dart';
|
|
import 'remote_fs.dart';
|
|
import 'secret_gate.dart';
|
|
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
|
import 'remote_idle.dart';
|
|
|
|
/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1).
|
|
/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
|
class SmbServer {
|
|
final String id;
|
|
final String name;
|
|
final String host;
|
|
final int port;
|
|
|
|
/// 공유 이름 (예: video · home)
|
|
final String share;
|
|
final String user;
|
|
|
|
/// 도메인 · 작업 그룹 (보통 비움)
|
|
final String domain;
|
|
|
|
/// 시작 폴더 (공유 안, 비우면 공유의 맨 위)
|
|
final String root;
|
|
final String password;
|
|
|
|
const SmbServer({
|
|
required this.id,
|
|
required this.name,
|
|
required this.host,
|
|
this.port = 445,
|
|
required this.share,
|
|
this.user = '',
|
|
this.domain = '',
|
|
this.root = '',
|
|
this.password = '',
|
|
});
|
|
|
|
/// 탐색기 위쪽에 보일 이름
|
|
String get label => name.trim().isNotEmpty ? name.trim() : '$host/$share';
|
|
|
|
SmbServer copyWith({String? name, String? host, int? port, String? share, String? user, String? domain, String? root, String? password}) =>
|
|
SmbServer(
|
|
id: id,
|
|
name: name ?? this.name,
|
|
host: host ?? this.host,
|
|
port: port ?? this.port,
|
|
share: share ?? this.share,
|
|
user: user ?? this.user,
|
|
domain: domain ?? this.domain,
|
|
root: root ?? this.root,
|
|
password: password ?? this.password,
|
|
);
|
|
|
|
/// 비밀번호는 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
|
Map<String, Object?> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'host': host,
|
|
'port': port,
|
|
'share': share,
|
|
'user': user,
|
|
'domain': domain,
|
|
'root': root,
|
|
'hasPassword': password.isNotEmpty,
|
|
};
|
|
|
|
factory SmbServer.fromJson(Map<Object?, Object?> j) => SmbServer(
|
|
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
|
name: j['name'] as String? ?? '',
|
|
host: j['host'] as String? ?? '',
|
|
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 445 },
|
|
share: j['share'] as String? ?? '',
|
|
user: j['user'] as String? ?? '',
|
|
domain: j['domain'] as String? ?? '',
|
|
root: j['root'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, insecure, other }
|
|
|
|
/// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
|
class SmbException extends DavException {
|
|
final SmbFailure kind;
|
|
const SmbException(super.message, this.kind);
|
|
}
|
|
|
|
/// 88: Android SMB 클라이언트 - smbj (Kotlin, android/.../SmbBridge.kt) 를 플랫폼 채널로 부른다.
|
|
/// 파일 내용은 1MB 조각으로 오간다 (열어 둔 파일 손잡이로).
|
|
class SmbRemote implements RemoteClient {
|
|
final SmbServer server;
|
|
SmbRemote(this.server);
|
|
|
|
/// 시험에서 가짜로 바꾼다
|
|
static MethodChannel channel = const MethodChannel('jj_mkvmaker/smb');
|
|
|
|
/// 보안 검토 5: 서버 이름을 앱의 DNS 설정 (강제 DNS) 으로 푼다 - null 이면 그대로 넘겨 기기가 푼다 (주소 · 집 안 이름).
|
|
/// main 에서 FTP 와 같은 것으로 바꾼다
|
|
static Future<InternetAddress?> Function(String host) resolve = (host) async => null;
|
|
static const chunk = 1 << 20;
|
|
|
|
Map<String, Object?> get _cfg => {
|
|
'id': server.id,
|
|
'host': server.host.trim(),
|
|
'port': server.port,
|
|
'share': server.share.trim(),
|
|
'user': server.user.trim(),
|
|
'domain': server.domain.trim(),
|
|
'password': server.password,
|
|
// 보안 검토 6: 서명 · 암호화가 안 되는 연결은 막기 (설정)
|
|
'requireSecure': SmbRegistry.requireSecure,
|
|
};
|
|
|
|
/// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함)
|
|
String _path(String rel) {
|
|
final root = server.root.trim().replaceAll('\\', '/').replaceAll(RegExp(r'^/+|/+$'), '');
|
|
final r = rel == '/' ? '' : rel;
|
|
return root.isEmpty ? (r.isEmpty ? '/' : r) : '/$root$r';
|
|
}
|
|
|
|
Future<T?> _call<T>(String method, [Map<String, Object?> args = const {}, String rel = '']) async {
|
|
if (server.host.trim().isEmpty || server.share.trim().isEmpty) {
|
|
throw SmbException(tr('SMB 서버 주소 · 공유 이름이 없습니다'), SmbFailure.other);
|
|
}
|
|
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
|
if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword);
|
|
// 보안 검토 5: 이름은 여기서 풀어 주소로 넘긴다 (Android 의 SMB 다리가 시스템 DNS 로 풀어 강제 DNS 를 비켜 가지 않게)
|
|
String host;
|
|
try {
|
|
host = (await resolve(server.host.trim()))?.address ?? server.host.trim();
|
|
} catch (e) {
|
|
throw SmbException(trf('서버 주소를 찾지 못했습니다 (DNS): {0}', [e]), SmbFailure.unreachable);
|
|
}
|
|
try {
|
|
final out = await channel.invokeMethod<T>(method, {..._cfg, 'host': host, ...args});
|
|
// 보안 검토 6: 처음 연결이 되면 서명 · 암호화되는지 한 번 물어 둔다 (탐색기 탭의 ⚠)
|
|
if (!_checked && method != 'info') {
|
|
_checked = true;
|
|
try {
|
|
final m = await channel.invokeMethod<Map<Object?, Object?>>('info', {..._cfg, 'host': host});
|
|
SmbRegistry._secure[server.id] = m?['encrypted'] == true || m?['signed'] == true;
|
|
} catch (_) {}
|
|
}
|
|
return out;
|
|
} on PlatformException catch (e) {
|
|
throw _explain(e, rel);
|
|
} on MissingPluginException {
|
|
throw SmbException(tr('이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)'), SmbFailure.other);
|
|
}
|
|
}
|
|
|
|
bool _checked = false;
|
|
|
|
SmbException _explain(PlatformException e, String rel) {
|
|
final s = server;
|
|
return switch (e.code) {
|
|
'insecure' => SmbException(
|
|
tr('서명 · 암호화가 되지 않는 연결이라 열지 않았습니다 (설정 "SMB 서명 · 암호화 필수"). NAS 에서 SMB 서명이나 SMB3 암호화를 켜거나, 설정을 끄세요'),
|
|
SmbFailure.insecure),
|
|
'unreachable' => SmbException(
|
|
trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable),
|
|
'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth),
|
|
'noShare' => SmbException(trf('공유 폴더 "{0}" 이(가) 없습니다. 공유 이름을 확인하세요', [s.share]), SmbFailure.noShare),
|
|
// 88: SMB1 은 보안 문제로 쓰지 않는다 (설계안 5)
|
|
'smb1' => SmbException(tr('이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요'),
|
|
SmbFailure.smb1),
|
|
'permission' => SmbException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SmbFailure.permission),
|
|
'notFound' => SmbException(trf('없는 경로입니다: {0}', [rel]), SmbFailure.notFound),
|
|
_ => SmbException(e.message ?? e.code, SmbFailure.other),
|
|
};
|
|
}
|
|
|
|
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
|
|
|
|
DavItem _item(String rel, Map<Object?, Object?> m) => DavItem(rel,
|
|
isDir: m['dir'] == true,
|
|
size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0,
|
|
modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0));
|
|
|
|
/// 연결이 암호화 · 서명되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고)
|
|
Future<({bool encrypted, bool signed, String dialect})> info() async {
|
|
final m = await _call<Map<Object?, Object?>>('info', const {}, '/');
|
|
final encrypted = m?['encrypted'] == true, signed = m?['signed'] == true;
|
|
SmbRegistry._secure[server.id] = encrypted || signed;
|
|
return (encrypted: encrypted, signed: signed, dialect: '${m?['dialect'] ?? ''}');
|
|
}
|
|
|
|
@override
|
|
Future<List<DavItem>> list(String rel) async {
|
|
final out = await _call<List<Object?>>('list', {'path': _path(rel)}, rel) ?? const [];
|
|
return [
|
|
for (final x in out)
|
|
if (x is Map) _item(_child(rel, '${x['name']}'), x),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<DavItem?> stat(String rel) async {
|
|
try {
|
|
final m = await _call<Map<Object?, Object?>>('stat', {'path': _path(rel)}, rel);
|
|
return m == null ? null : _item(rel, m);
|
|
} on SmbException catch (e) {
|
|
if (e.kind == SmbFailure.notFound) return null;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> mkdirs(String rel) => remoteMkdirs(this, rel, (r) => _call<void>('mkdir', {'path': _path(r)}, r));
|
|
|
|
@override
|
|
Future<void> delete(String rel) => _call<void>('delete', {'path': _path(rel)}, rel);
|
|
|
|
@override
|
|
Future<void> move(String from, String to, {bool overwrite = false}) =>
|
|
_call<void>('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from);
|
|
|
|
@override
|
|
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
|
|
|
/// [start] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
|
|
Future<Stream<List<int>>> openReadFrom(String rel, {int start = 0, int? length}) async {
|
|
final m = await _call<Map<Object?, Object?>>('openRead', {'path': _path(rel)}, rel);
|
|
final handle = m!['handle'] as int;
|
|
Stream<List<int>> read() async* {
|
|
var offset = start;
|
|
var left = length;
|
|
try {
|
|
while (left == null || left > 0) {
|
|
final Uint8List? data;
|
|
try {
|
|
// 관리자 판단 (10/20): 한 조각을 정한 시간 안에 받지 못하면 실패 (NAS 가 멈춰도 영원히 기다리지 않게)
|
|
data = await idleCall(channel.invokeMethod<Uint8List>(
|
|
'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}));
|
|
} on PlatformException catch (e) {
|
|
throw _explain(e, rel);
|
|
}
|
|
if (data == null || data.isEmpty) break;
|
|
offset += data.length;
|
|
if (left != null) left -= data.length;
|
|
yield data;
|
|
}
|
|
} finally {
|
|
await channel.invokeMethod<void>('closeHandle', {'handle': handle}).catchError((_) {});
|
|
}
|
|
}
|
|
|
|
return read();
|
|
}
|
|
|
|
@override
|
|
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
|
final handle = await _call<int>('openWrite', {'path': _path(rel)}, rel);
|
|
var offset = 0;
|
|
final buf = BytesBuilder(copy: false);
|
|
// 모인 것을 [chunk] 크기로 잘라 보낸다 (큰 조각 하나가 와도 채널로 한 번에 수백 MB 를 보내지 않게). [all] 이면 남은 것까지
|
|
Future<void> flush({bool all = false}) async {
|
|
if (buf.isEmpty || (!all && buf.length < chunk)) return;
|
|
final bytes = buf.takeBytes();
|
|
var at = 0;
|
|
while (bytes.length - at >= chunk || (all && at < bytes.length)) {
|
|
final end = (at + chunk).clamp(0, bytes.length);
|
|
try {
|
|
await idleCall(channel.invokeMethod<void>(
|
|
'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}));
|
|
} on PlatformException catch (e) {
|
|
throw _explain(e, rel);
|
|
}
|
|
offset += end - at;
|
|
at = end;
|
|
}
|
|
if (at < bytes.length) buf.add(Uint8List.sublistView(bytes, at));
|
|
}
|
|
|
|
try {
|
|
await for (final x in data) {
|
|
buf.add(x);
|
|
await flush();
|
|
}
|
|
await flush(all: true);
|
|
} finally {
|
|
await channel.invokeMethod<void>('closeHandle', {'handle': handle}).catchError((_) {});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<(int?, int?)> quota() async {
|
|
try {
|
|
final v = await _call<List<Object?>>('quota', const {}, '/');
|
|
return ((v?[0] as num?)?.toInt(), (v?[1] as num?)?.toInt());
|
|
} catch (_) {
|
|
return (null, null);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void close() => unawaited(channel.invokeMethod<void>('close', {'id': server.id}).catchError((_) {}));
|
|
}
|
|
|
|
/// 설정의 SMB 서버 목록과 클라이언트 (서버마다 하나)
|
|
class SmbRegistry {
|
|
static final _clients = <String, SmbRemote>{};
|
|
static final _servers = <String, SmbServer>{};
|
|
|
|
/// 보안 검토 6: 서버 id → 지난 연결이 서명이나 암호화되었는지 (모르면 없음)
|
|
static final _secure = <String, bool>{};
|
|
|
|
/// 서명 · 암호화가 안 된 연결이었는지 (탐색기 탭의 ⚠). 아직 연결하지 않았으면 false
|
|
static bool insecure(String id) => _secure[id] == false;
|
|
|
|
/// 설정 "SMB 서명 · 암호화 필수" - 바뀌면 열려 있던 연결을 닫는다 (다음 연결부터 따르게)
|
|
static bool _requireSecure = false;
|
|
static bool get requireSecure => _requireSecure;
|
|
static set requireSecure(bool v) {
|
|
if (v == _requireSecure) return;
|
|
_requireSecure = v;
|
|
for (final c in _clients.values) {
|
|
c.close();
|
|
}
|
|
_clients.clear();
|
|
}
|
|
|
|
static void configure(List<SmbServer> servers) {
|
|
final keep = {for (final s in servers) s.id: s};
|
|
for (final id in _clients.keys.toList()) {
|
|
final old = _servers[id], now = keep[id];
|
|
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
|
_clients.remove(id)?.close();
|
|
_secure.remove(id);
|
|
}
|
|
}
|
|
_secure.removeWhere((id, _) => !keep.containsKey(id));
|
|
_servers
|
|
..clear()
|
|
..addAll(keep);
|
|
}
|
|
|
|
static List<SmbServer> get servers => _servers.values.toList();
|
|
static SmbServer? server(String id) => _servers[id];
|
|
|
|
static SmbRemote client(String id) {
|
|
final s = _servers[id];
|
|
if (s == null) throw SmbException(trf('SMB 서버 설정이 없습니다 ({0})', [id]), SmbFailure.other);
|
|
return _clients.putIfAbsent(id, () => SmbRemote(s));
|
|
}
|
|
}
|