import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile; import 'lan_scan.dart' show isLanAddress, isMobileInterfaceName, netKindsOf; /// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17): /// - 켤 때마다 6자리 코드를 새로 만든다 (주소 · QR · 코드를 화면에) /// - 고른 폴더 밖은 막는다 (../ · 절대 경로 · 링크 · junction 을 따라가지 않음) /// - 정한 시간이 지나면 저절로 꺼진다 (처음 30분). 앱을 끄면 함께 꺼진다 /// - 접속 기록은 화면에만 (작업 기록 파일에 남기지 않음), 코드는 어디에도 기록하지 않는다 /// 보안 (관리자 판단 · 테스터 15): /// - GET · HEAD 만 (그 밖은 405). 올리기 · 지우기 · 이름 바꾸기 없음 /// - 코드를 틀리면 그 기기는 5번에 1분 막고, 모두 합쳐 30번 틀리면 공유를 끈다 (6자리 무작위 대입 막기) /// - 사설망 (10/8 · 172.16/12 · 192.168/16 · 169.254/16 · 100.64/10 · 이 기기) 에서 온 요청만 받는다 /// - 공유한 파일은 격리해 보낸다 (CSP sandbox · nosniff, HTML · SVG · JS 는 내려받기로) - 공유 폴더 안의 웹 페이지가 /// 접속한 브라우저에서 스크립트를 돌리지 못하게 class WifiShare extends ChangeNotifier { /// 지금 켜진 공유 (한 번에 하나) static WifiShare? get instance => current.value; /// 켜고 끌 때 알림 (탐색기 위 줄 · Android 알림이 듣는다) static final current = ValueNotifier(null); /// 처음 해 볼 포트 (쓰는 중이면 빈 포트) static const defaultPort = 8765; /// 틀린 코드: 한 기기에서 [perClientFails] 번이면 [lockout] 동안 막고, 모두 [totalFails] 번이면 끈다 static const perClientFails = 5; static const lockout = Duration(minutes: 1); static const totalFails = 30; /// 시험: 화면 시험에서 이 기기 (127.0.0.1) 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이) @visibleForTesting static bool debugLoopbackOnly = false; /// 시험: 이 기기의 주소 대신 쓸 것 (화면 캡처에 시험 PC 의 실제 망 주소가 남지 않게) @visibleForTesting static List? debugAddresses; /// 화면에 보이는 접속 기록 개수 static const maxLog = 200; final String root; final String _rootReal; final String code; /// 주소마다 하나 (보안 검토 3: 모든 연결 (0.0.0.0) 이 아니라 화면에 보인 Wi-Fi · 유선 주소에만 연다) final List _servers; final Random _rng; final DateTime started; DateTime? endsAt; Timer? _timer; bool _stopped = false; /// 꺼진 까닭 (사용자가 끔이면 null) String? stopReason; /// 접속 기록 (새것이 앞) final log = []; final _sessions = {}; final _fails = {}; int _totalFails = 0; /// 이 기기의 주소들 (사설망 먼저). 화면 · QR 에 쓴다 List addresses = const []; WifiShare._(this.root, this._rootReal, this.code, this._servers, this._rng) : started = DateTime.now(); int get port => _servers.first.port; bool get running => !_stopped; /// 접속 주소 (코드를 넣은 것 - QR 용) String urlWithCode(String host) => 'http://$host:$port/?code=$code'; /// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다 static Future start(String root, {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false, bool tailscale = true}) async { await instance?.stop(); final dir = Directory(root); if (!await dir.exists()) throw FileSystemException(tr('폴더가 없습니다'), root); final real = await dir.resolveSymbolicLinks(); final rng = random ?? Random.secure(); final code = List.generate(6, (_) => rng.nextInt(10)).join(); final loop = loopbackOnly || debugLoopbackOnly; final addrs = debugAddresses ?? await localAddresses(tailscale: tailscale); // 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게 if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다')); final binds = loop ? ['127.0.0.1'] : addrs; final servers = []; final shown = []; try { for (final a in binds) { final at = InternetAddress(a); try { servers.add(await HttpServer.bind(at, servers.isEmpty ? port : servers.first.port)); } on SocketException { if (servers.isNotEmpty) continue; // 같은 포트를 못 연 주소는 빼고 보인다 servers.add(await HttpServer.bind(at, 0)); // 쓰는 중이면 빈 포트 } shown.add(a); } } catch (_) { for (final x in servers) { unawaited(x.close(force: true)); } rethrow; } final s = WifiShare._(root, real, code, servers, rng); for (final server in servers) { server.autoCompress = false; server.defaultResponseHeaders.clear(); server.serverHeader = null; } s.addresses = loop ? addrs : shown; if (autoOff != null && autoOff > Duration.zero) { s.endsAt = s.started.add(autoOff); s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다'))); } for (final server in servers) { server.listen(s._handle, onError: (_) {}); } current.value = s; return s; } /// 공유를 끈다 (접속 중인 것도 끊는다) Future stop({String? reason}) async { if (_stopped) return; _stopped = true; stopReason = reason; _timer?.cancel(); _sessions.clear(); if (identical(current.value, this)) current.value = null; for (final server in _servers) { try { await server.close(force: true); } catch (_) {} } notifyListeners(); } /// 저절로 꺼질 시각을 [more] 만큼 늦춘다 void extend(Duration more) { if (_stopped || endsAt == null) return; endsAt = endsAt!.add(more); _timer?.cancel(); _timer = Timer(endsAt!.difference(DateTime.now()), () => stop(reason: tr('정한 시간이 지나 저절로 껐습니다'))); notifyListeners(); } /// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 - /// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음) static Future> localAddresses({bool tailscale = true}) async { try { final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false); final kinds = await netKindsOf(); final all = [ for (final i in list) for (final a in i.addresses) if (shareInterface(i.name, a.address, tailscale: tailscale, kinds: kinds)) a.address ]; int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3; return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b)))); } catch (_) { return const []; } } /// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0) /// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기") /// [kinds]: Android 가 알려 준 주소별 망 종류 (있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 뺀다) static bool shareInterface(String name, String ip, {bool tailscale = true, Map? kinds}) { final v = ip.split('.'); final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0; // Tailscale 은 VPN 이지만 설정대로 (통신사 CGNAT 100.x 는 모바일 데이터라 뺀다) if (a == 100 && b >= 64 && b <= 127) return tailscale && kinds?[ip] != 'cellular' && !isMobileInterfaceName(name); if (a == 169 && b == 254) return false; // 주소를 못 받은 연결 return isLanAddress(ip, name, kinds); } /// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지 static bool isLocalNetwork(String ip) { final v = ip.split('.').map(int.tryParse).toList(); if (v.length != 4 || v.any((x) => x == null)) return ip == '::1'; final a = v[0]!, b = v[1]!; return a == 10 || a == 127 || (a == 172 && b >= 16 && b <= 31) || (a == 192 && b == 168) || (a == 169 && b == 254) || (a == 100 && b >= 64 && b <= 127); } /// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null. /// 보안 검토 10: 디스크는 비동기로 본다 (같은 망의 기기가 요청할 때마다 화면이 멈칫하지 않게) Future resolve(String rel) async { final segs = []; for (final s in rel.split('/')) { if (s.isEmpty) continue; if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return null; segs.add(s); } var at = _rootReal; for (final s in segs) { at = p.join(at, s); // 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다 if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.link) return null; } if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.notFound) return null; try { final real = await FileSystemEntity.isDirectory(at) ? await Directory(at).resolveSymbolicLinks() : await File(at).resolveSymbolicLinks(); if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null; } catch (_) { return null; } return at; } /// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지 Future _blocked(String rel) async { var at = _rootReal; for (final s in rel.split('/')) { if (s.isEmpty) continue; if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true; at = p.join(at, s); final t = await FileSystemEntity.type(at, followLinks: false); if (t == FileSystemEntityType.link) return true; if (t == FileSystemEntityType.notFound) return false; } return true; // 있는데 밖을 가리킴 } Future _handle(HttpRequest req) async { final res = req.response; final ip = req.connectionInfo?.remoteAddress.address ?? '?'; var status = 200; var path = '/'; try { res.headers ..set('X-Content-Type-Options', 'nosniff') ..set('Referrer-Policy', 'no-referrer') ..set('Cache-Control', 'no-store') ..set('X-Frame-Options', 'DENY'); if (!isLocalNetwork(ip)) { status = 403; return await _text(res, 403, tr('같은 네트워크에서만 열 수 있습니다')); } if (req.method != 'GET' && req.method != 'HEAD') { status = 405; res.headers.set('Allow', 'GET, HEAD'); return await _text(res, 405, tr('읽기 전용 공유입니다 (보기 · 받기만)')); } path = req.uri.path; // 코드 확인 (쿠키가 없으면 ?code= 로) if (!_hasSession(req)) { final given = req.uri.queryParameters['code']; final locked = _lockedUntil(ip); if (locked != null) { status = 429; res.headers.set('Retry-After', '${locked.difference(DateTime.now()).inSeconds + 1}'); return await _page(res, 429, _loginHtml(tr('코드를 여러 번 틀려 잠시 막았습니다. 1분 뒤에 다시 하세요.'))); } if (given == null || given.isEmpty) { status = 401; return await _page(res, 401, _loginHtml(null)); } if (!_sameCode(given)) { status = 401; _fail(ip); return await _page(res, 401, _loginHtml(tr('코드가 맞지 않습니다'))); } _fails.remove(ip); final token = base64Url.encode(List.generate(32, (_) => _rng.nextInt(256))).replaceAll('=', ''); _sessions.add(token); res.headers.set('Set-Cookie', 'jjshare=$token; Path=/; HttpOnly; SameSite=Strict'); // 코드가 주소 막대 · 기록에 남지 않게 코드를 뺀 주소로 다시 final q = Map.of(req.uri.queryParameters)..remove('code'); status = 303; res.statusCode = 303; res.headers.set('Location', q.isEmpty ? req.uri.path : '${req.uri.path}?${Uri(queryParameters: q).query}'); await res.close(); return; } final rel = Uri.decodeComponent(path); final target = await resolve(rel); if (target == null) { status = await _blocked(rel) ? 403 : 404; return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다')); } if (await FileSystemEntity.isDirectory(target)) { return await _page(res, 200, await _listHtml(rel, target), head: req.method == 'HEAD'); } status = await _file(req, res, target); } catch (_) { status = 500; try { await _text(res, 500, tr('읽지 못했습니다')); } catch (_) {} } finally { _record(ip, path, status); } } bool _hasSession(HttpRequest req) { for (final c in req.cookies) { if (c.name == 'jjshare' && _sessions.contains(c.value)) return true; } return false; } /// 걸리는 시간이 맞는 자리 수에 따라 달라지지 않게 비교 bool _sameCode(String given) { if (given.length != code.length) return false; var d = 0; for (var i = 0; i < code.length; i++) { d |= given.codeUnitAt(i) ^ code.codeUnitAt(i); } return d == 0; } DateTime? _lockedUntil(String ip) { final f = _fails[ip]; if (f == null || f.$1 < perClientFails) return null; final until = f.$2.add(lockout); if (DateTime.now().isAfter(until)) { _fails.remove(ip); return null; } return until; } void _fail(String ip) { final f = _fails[ip]; _fails[ip] = ((f?.$1 ?? 0) + 1, DateTime.now()); _totalFails++; if (_totalFails >= totalFails) { unawaited(stop(reason: tr('코드를 너무 많이 틀려 공유를 껐습니다 (다른 기기가 코드를 맞히려 했을 수 있습니다)'))); } } /// 시험: 틀린 코드 하나를 센다 (막힌 기기에서 온 것처럼 서버를 거치지 않고) @visibleForTesting void debugFail(String ip) => _fail(ip); void _record(String ip, String path, int status) { if (_stopped) return; log.insert(0, WifiAccess(DateTime.now(), ip, path, status)); if (log.length > maxLog) log.removeRange(maxLog, log.length); notifyListeners(); } /// 파일 보내기 (Range 한 구간 지원 - 동영상 건너뛰기). 돌려주는 값: 상태 코드 Future _file(HttpRequest req, HttpResponse res, String path) async { final f = File(path); final len = await f.length(); final name = p.basename(path); final ext = p.extension(name).toLowerCase().replaceFirst('.', ''); final active = const {'html', 'htm', 'xhtml', 'svg', 'xml', 'js', 'mjs'}.contains(ext); final download = active || req.uri.queryParameters['dl'] == '1'; res.headers ..set('Content-Type', active ? 'application/octet-stream' : contentTypeOf(ext)) ..set('Content-Security-Policy', 'sandbox') ..set('Accept-Ranges', 'bytes') ..set('Content-Disposition', '${download ? 'attachment' : 'inline'}; filename*=UTF-8\'\'${Uri.encodeComponent(name)}'); var start = 0, end = len - 1, status = 200; final range = req.headers.value('range'); final m = range == null ? null : RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim()); if (m != null && len > 0) { final a = int.tryParse(m[1]!), b = int.tryParse(m[2]!); if (a == null && b != null) { start = max(0, len - b); } else if (a != null) { start = a; if (b != null) end = min(b, len - 1); } if (start > end || start >= len) { res.statusCode = 416; res.headers.set('Content-Range', 'bytes */$len'); await res.close(); return 416; } status = 206; res.headers.set('Content-Range', 'bytes $start-$end/$len'); } res.statusCode = status; res.contentLength = len == 0 ? 0 : end - start + 1; if (req.method == 'HEAD' || len == 0) { await res.close(); return status; } await res.addStream(f.openRead(start, end + 1)); await res.close(); return status; } static String contentTypeOf(String ext) => switch (ext) { 'txt' || 'srt' || 'smi' || 'ass' || 'ssa' || 'vtt' || 'log' || 'md' || 'csv' || 'ini' || 'nfo' => 'text/plain; charset=utf-8', 'json' => 'application/json', 'pdf' => 'application/pdf', 'jpg' || 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp', 'mp4' || 'm4v' => 'video/mp4', 'webm' => 'video/webm', 'mkv' => 'video/x-matroska', 'mov' => 'video/quicktime', 'mp3' => 'audio/mpeg', 'm4a' || 'aac' => 'audio/aac', 'ogg' || 'opus' => 'audio/ogg', 'wav' => 'audio/wav', 'flac' => 'audio/flac', 'zip' => 'application/zip', _ => 'application/octet-stream', }; Future _text(HttpResponse res, int status, String msg) async { res.statusCode = status; res.headers.set('Content-Type', 'text/plain; charset=utf-8'); res.write(msg); await res.close(); } Future _page(HttpResponse res, int status, String html, {bool head = false}) async { res.statusCode = status; res.headers ..set('Content-Type', 'text/html; charset=utf-8') // 앱이 만드는 화면에도 스크립트를 쓰지 않는다 ..set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'"); if (!head) res.write(html); await res.close(); } static String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); static String _href(String rel) => '/${rel.split('/').where((s) => s.isNotEmpty).map(Uri.encodeComponent).join('/')}'; static const _css = 'body{font-family:sans-serif;background:#16171c;color:#e8e8ea;margin:0;padding:16px;max-width:900px}' 'a{color:#4fd1d9;text-decoration:none}h1{font-size:18px;word-break:break-all}' 'table{width:100%;border-collapse:collapse}td{padding:8px 4px;border-bottom:1px solid #2a2b33;word-break:break-all}' 'td.s{color:#9a9aa3;white-space:nowrap;text-align:right;font-size:13px}' 'input{font-size:24px;letter-spacing:6px;width:9em;padding:8px;margin-right:8px}button{font-size:18px;padding:10px 16px}' '.e{color:#ff6b6b}.n{color:#9a9aa3;font-size:13px}'; String _loginHtml(String? error) => '' 'JJ_MKVMaker' '

${_esc(tr('JJ_MKVMaker Wi-Fi 공유'))}

' '

${_esc(tr('공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.'))}

' '${error == null ? '' : '

${_esc(error)}

'}' '
' '
'; Future _listHtml(String rel, String dir) async { final entries = []; final sizes = {}; try { await for (final e in Directory(dir).list(followLinks: false)) { final n = p.basename(e.path); if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게 entries.add(e); if (e is File) { try { sizes[e.path] = await e.length(); } catch (_) {} } } } catch (_) {} entries.sort((a, b) { final d = (a is Directory ? 0 : 1) - (b is Directory ? 0 : 1); return d != 0 ? d : p.basename(a.path).toLowerCase().compareTo(p.basename(b.path).toLowerCase()); }); final parts = rel.split('/').where((s) => s.isNotEmpty).toList(); final title = parts.isEmpty ? p.basename(root) : '${p.basename(root)} / ${parts.join(' / ')}'; final b = StringBuffer('' '${_esc(title)}' '

${_esc(title)}

${_esc(tr('읽기 전용 · 보기와 받기만 됩니다'))}

'); if (parts.isNotEmpty) { final up = _href(parts.sublist(0, parts.length - 1).join('/')); b.write(''); } for (final e in entries) { final n = p.basename(e.path); final href = _href([...parts, n].join('/')); if (e is Directory) { b.write(''); } else { final size = sizes[e.path] ?? 0; b.write('' ''); } } if (entries.isEmpty) b.write(''); b.write('
⬆ ${_esc(tr('상위 폴더'))}
📁 ${_esc(n)}
${_esc(n)}${_esc(formatSize(size))} · ${_esc(tr('받기'))}
${_esc(tr('빈 폴더입니다'))}
'); return b.toString(); } } /// 89: 화면에 보이는 접속 한 줄 (파일에는 남기지 않는다) class WifiAccess { final DateTime at; final String ip; final String path; final int status; const WifiAccess(this.at, this.ip, this.path, this.status); }