506 lines
22 KiB
Dart
506 lines
22 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;
|
|
import 'lan_scan.dart' show isLanInterfaceName, isMobileInterfaceName;
|
|
|
|
/// 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;
|
|
|
|
/// 시험: 이 기기의 주소 대신 쓸 것 (화면 캡처에 시험 PC 의 실제 망 주소가 남지 않게)
|
|
@visibleForTesting
|
|
static List<String>? debugAddresses;
|
|
|
|
/// 화면에 보이는 접속 기록 개수
|
|
static const maxLog = 200;
|
|
|
|
final String root;
|
|
final String _rootReal;
|
|
final String code;
|
|
/// 주소마다 하나 (보안 검토 3: 모든 연결 (0.0.0.0) 이 아니라 화면에 보인 Wi-Fi · 유선 주소에만 연다)
|
|
final List<HttpServer> _servers;
|
|
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._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<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 loop = loopbackOnly || debugLoopbackOnly;
|
|
final addrs = debugAddresses ?? await localAddresses();
|
|
// 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게
|
|
if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다'));
|
|
final binds = loop ? ['127.0.0.1'] : addrs;
|
|
final servers = <HttpServer>[];
|
|
final shown = <String>[];
|
|
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<void> 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<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)
|
|
if (shareInterface(i.name, a.address)) 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)
|
|
static bool shareInterface(String name, String ip) {
|
|
final v = ip.split('.');
|
|
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0;
|
|
if (a == 100 && b >= 64 && b <= 127) return !isMobileInterfaceName(name);
|
|
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
|
|
return isLanInterfaceName(name);
|
|
}
|
|
|
|
/// 사설망 · 링크 로컬 · 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('&', '&').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) => '<!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);
|
|
}
|