JJ_MKVMaker/lib/core/wifi_share.dart

463 lines
19 KiB
Dart

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;
/// 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<WifiShare?>(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;
/// 화면에 보이는 접속 기록 개수
static const maxLog = 200;
final String root;
final String _rootReal;
final String code;
final HttpServer _server;
final Random _rng;
final DateTime started;
DateTime? endsAt;
Timer? _timer;
bool _stopped = false;
/// 꺼진 까닭 (사용자가 끔이면 null)
String? stopReason;
/// 접속 기록 (새것이 앞)
final log = <WifiAccess>[];
final _sessions = <String>{};
final _fails = <String, (int, DateTime)>{};
int _totalFails = 0;
/// 이 기기의 주소들 (사설망 먼저). 화면 · QR 에 쓴다
List<String> addresses = const [];
WifiShare._(this.root, this._rootReal, this.code, this._server, this._rng) : started = DateTime.now();
int get port => _server.port;
bool get running => !_stopped;
/// 접속 주소 (코드를 넣은 것 - QR 용)
String urlWithCode(String host) => 'http://$host:$port/?code=$code';
/// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다
static Future<WifiShare> start(String root, {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false}) async {
await instance?.stop();
final dir = Directory(root);
if (!dir.existsSync()) throw FileSystemException(tr('폴더가 없습니다'), root);
final real = dir.resolveSymbolicLinksSync();
final rng = random ?? Random.secure();
final code = List.generate(6, (_) => rng.nextInt(10)).join();
final any = loopbackOnly || debugLoopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4;
HttpServer server;
try {
server = await HttpServer.bind(any, port);
} on SocketException {
server = await HttpServer.bind(any, 0); // 쓰는 중이면 빈 포트
}
final s = WifiShare._(root, real, code, server, rng);
server.autoCompress = false;
server.defaultResponseHeaders.clear();
server.serverHeader = null;
s.addresses = await localAddresses();
if (autoOff != null && autoOff > Duration.zero) {
s.endsAt = s.started.add(autoOff);
s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다')));
}
server.listen(s._handle, onError: (_) {});
current.value = s;
return s;
}
/// 공유를 끈다 (접속 중인 것도 끊는다)
Future<void> stop({String? reason}) async {
if (_stopped) return;
_stopped = true;
stopReason = reason;
_timer?.cancel();
_sessions.clear();
if (identical(current.value, this)) current.value = null;
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 주소 (이 기기 자신 제외, 사설망 먼저)
static Future<List<String>> localAddresses() async {
try {
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
final all = [for (final i in list) for (final a in i.addresses) 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 [];
}
}
/// 사설망 · 링크 로컬 · 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
String? resolve(String rel) {
final segs = <String>[];
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 (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.link) return null;
}
if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.notFound) return null;
try {
final real = FileSystemEntity.isDirectorySync(at) ? Directory(at).resolveSymbolicLinksSync() : File(at).resolveSymbolicLinksSync();
if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null;
} catch (_) {
return null;
}
return at;
}
/// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지
bool _blocked(String rel) {
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 = FileSystemEntity.typeSync(at, followLinks: false);
if (t == FileSystemEntityType.link) return true;
if (t == FileSystemEntityType.notFound) return false;
}
return true; // 있는데 밖을 가리킴
}
Future<void> _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 = resolve(rel);
if (target == null) {
status = _blocked(rel) ? 403 : 404;
return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다'));
}
if (FileSystemEntity.isDirectorySync(target)) {
return await _page(res, 200, _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<int> _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<void> _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<void> _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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
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) => '<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1"><title>JJ_MKVMaker</title><style>$_css</style></head><body>'
'<h1>${_esc(tr('JJ_MKVMaker Wi-Fi 공유'))}</h1>'
'<p>${_esc(tr('공유한 기기의 화면에 보이는 6자리 코드를 넣으세요.'))}</p>'
'${error == null ? '' : '<p class="e">${_esc(error)}</p>'}'
'<form method="get" action="/"><input name="code" inputmode="numeric" autocomplete="off" maxlength="6" autofocus>'
'<button type="submit">${_esc(tr('열기'))}</button></form></body></html>';
String _listHtml(String rel, String dir) {
final entries = <FileSystemEntity>[];
try {
for (final e in Directory(dir).listSync(followLinks: false)) {
final n = p.basename(e.path);
if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게
entries.add(e);
}
} 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('<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1"><title>${_esc(title)}</title><style>$_css</style></head><body>'
'<h1>${_esc(title)}</h1><p class="n">${_esc(tr('읽기 전용 · 보기와 받기만 됩니다'))}</p><table>');
if (parts.isNotEmpty) {
final up = _href(parts.sublist(0, parts.length - 1).join('/'));
b.write('<tr><td><a href="${up == '/' ? '/' : '$up/'}">⬆ ${_esc(tr('상위 폴더'))}</a></td><td></td></tr>');
}
for (final e in entries) {
final n = p.basename(e.path);
final href = _href([...parts, n].join('/'));
if (e is Directory) {
b.write('<tr><td><a href="$href/">📁 ${_esc(n)}</a></td><td class="s"></td></tr>');
} else {
int size = 0;
try {
size = (e as File).lengthSync();
} catch (_) {}
b.write('<tr><td><a href="$href">${_esc(n)}</a></td>'
'<td class="s">${_esc(formatSize(size))} · <a href="$href?dl=1">${_esc(tr('받기'))}</a></td></tr>');
}
}
if (entries.isEmpty) b.write('<tr><td class="n">${_esc(tr('빈 폴더입니다'))}</td><td></td></tr>');
b.write('</table></body></html>');
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);
}