Merge commit '19d4f7b' into next974

review_fix
RyuJongJin 2026-10-18 02:59:15 +09:00
commit f6bdc35920
7 changed files with 62 additions and 26 deletions

View File

@ -296,6 +296,7 @@
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.",
"TAESD (빠른 디코더)": "TAESD (fast decoder)", "TAESD (빠른 디코더)": "TAESD (fast decoder)",
"TTL 만료": "TTL expired", "TTL 만료": "TTL expired",
"Tailscale 에서도 열기": "Also open on Tailscale",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor executable - GPL-enabled build)", "Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor executable - GPL-enabled build)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS does not accept the connection", "Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS does not accept the connection",
"Tor SOCKS 포트를 찾지 못했습니다": "Could not find the Tor SOCKS port", "Tor SOCKS 포트를 찾지 못했습니다": "Could not find the Tor SOCKS port",
@ -864,6 +865,7 @@
"남은 시간 {0}": "{0} left", "남은 시간 {0}": "{0} left",
"남은 약 {0}": "about {0} left", "남은 약 {0}": "about {0} left",
"남은 용량": "free", "남은 용량": "free",
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "Devices on your Tailscale network (tailnet) can also open it. When off, only the same Wi-Fi or wired network",
"내려받기": "Download", "내려받기": "Download",
"내려받기 (Windows 만)": "Download (Windows only)", "내려받기 (Windows 만)": "Download (Windows only)",
"내려받기 실패 ({0})": "Download failed ({0})", "내려받기 실패 ({0})": "Download failed ({0})",

View File

@ -296,6 +296,7 @@
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。",
"TAESD (빠른 디코더)": "TAESD (高速デコーダー)", "TAESD (빠른 디코더)": "TAESD (高速デコーダー)",
"TTL 만료": "TTL 期限切れ", "TTL 만료": "TTL 期限切れ",
"Tailscale 에서도 열기": "Tailscale でも開く",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 実行ファイル - GPL を有効にしたビルド)", "Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 実行ファイル - GPL を有効にしたビルド)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS が接続を受け付けません", "Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS が接続を受け付けません",
"Tor SOCKS 포트를 찾지 못했습니다": "Tor SOCKS ポートが見つかりませんでした", "Tor SOCKS 포트를 찾지 못했습니다": "Tor SOCKS ポートが見つかりませんでした",
@ -864,6 +865,7 @@
"남은 시간 {0}": "残り時間 {0}", "남은 시간 {0}": "残り時間 {0}",
"남은 약 {0}": "残り約 {0}", "남은 약 {0}": "残り約 {0}",
"남은 용량": "空き容量", "남은 용량": "空き容量",
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "自分の Tailscale ネットワーク (tailnet) の機器もこのアドレスで見られます。オフにすると同じ Wi-Fi・有線ネットワークのみ",
"내려받기": "ダウンロード", "내려받기": "ダウンロード",
"내려받기 (Windows 만)": "ダウンロード (Windows のみ)", "내려받기 (Windows 만)": "ダウンロード (Windows のみ)",
"내려받기 실패 ({0})": "ダウンロード失敗({0})", "내려받기 실패 ({0})": "ダウンロード失敗({0})",

View File

@ -296,6 +296,7 @@
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。",
"TAESD (빠른 디코더)": "TAESD (快速解码器)", "TAESD (빠른 디코더)": "TAESD (快速解码器)",
"TTL 만료": "TTL 过期", "TTL 만료": "TTL 过期",
"Tailscale 에서도 열기": "也在 Tailscale 上开放",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 可执行文件 - 启用 GPL 的构建)", "Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 可执行文件 - 启用 GPL 的构建)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS 不接受连接", "Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS 不接受连接",
"Tor SOCKS 포트를 찾지 못했습니다": "未找到 Tor SOCKS 端口", "Tor SOCKS 포트를 찾지 못했습니다": "未找到 Tor SOCKS 端口",
@ -864,6 +865,7 @@
"남은 시간 {0}": "剩余时间 {0}", "남은 시간 {0}": "剩余时间 {0}",
"남은 약 {0}": "剩余约 {0}", "남은 약 {0}": "剩余约 {0}",
"남은 용량": "剩余空间", "남은 용량": "剩余空间",
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "你的 Tailscale 网络 (tailnet) 中的设备也能通过此地址查看。关闭后仅限同一 Wi-Fi 或有线网络",
"내려받기": "下载", "내려받기": "下载",
"내려받기 (Windows 만)": "下载 (仅 Windows)", "내려받기 (Windows 만)": "下载 (仅 Windows)",
"내려받기 실패 ({0})": "下载失败({0})", "내려받기 실패 ({0})": "下载失败({0})",

View File

@ -463,6 +463,9 @@ class AppSettings {
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17) /// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
int wifiShareMinutes = 30; int wifiShareMinutes = 30;
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
bool wifiShareTailscale = true;
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것) /// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
String wifiShareFolder = ''; String wifiShareFolder = '';
@ -818,6 +821,7 @@ class AppSettings {
'explorerProgressHidden': explorerProgressHidden, 'explorerProgressHidden': explorerProgressHidden,
'zipLevel': zipLevel, 'zipLevel': zipLevel,
'wifiShareMinutes': wifiShareMinutes, 'wifiShareMinutes': wifiShareMinutes,
'wifiShareTailscale': wifiShareTailscale,
'wifiShareFolder': wifiShareFolder, 'wifiShareFolder': wifiShareFolder,
'explorerSortPerFolder': explorerSortPerFolder, 'explorerSortPerFolder': explorerSortPerFolder,
'explorerFolderSorts': explorerFolderSorts, 'explorerFolderSorts': explorerFolderSorts,
@ -1054,6 +1058,7 @@ class AppSettings {
..explorerProgressHidden = j['explorerProgressHidden'] == true ..explorerProgressHidden = j['explorerProgressHidden'] == true
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6 ..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30 ..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : '' ..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask' ..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
..explorerFolderSorts = { ..explorerFolderSorts = {

View File

@ -80,15 +80,16 @@ class WifiShare extends ChangeNotifier {
String urlWithCode(String host) => 'http://$host:$port/?code=$code'; String urlWithCode(String host) => 'http://$host:$port/?code=$code';
/// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다 /// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다
static Future<WifiShare> start(String root, {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false}) async { static Future<WifiShare> start(String root,
{Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false, bool tailscale = true}) async {
await instance?.stop(); await instance?.stop();
final dir = Directory(root); final dir = Directory(root);
if (!dir.existsSync()) throw FileSystemException(tr('폴더가 없습니다'), root); if (!await dir.exists()) throw FileSystemException(tr('폴더가 없습니다'), root);
final real = dir.resolveSymbolicLinksSync(); final real = await dir.resolveSymbolicLinks();
final rng = random ?? Random.secure(); final rng = random ?? Random.secure();
final code = List.generate(6, (_) => rng.nextInt(10)).join(); final code = List.generate(6, (_) => rng.nextInt(10)).join();
final loop = loopbackOnly || debugLoopbackOnly; final loop = loopbackOnly || debugLoopbackOnly;
final addrs = debugAddresses ?? await localAddresses(); final addrs = debugAddresses ?? await localAddresses(tailscale: tailscale);
// 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게 // 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게
if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다')); if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다'));
final binds = loop ? ['127.0.0.1'] : addrs; final binds = loop ? ['127.0.0.1'] : addrs;
@ -156,13 +157,13 @@ class WifiShare extends ChangeNotifier {
/// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 - /// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 -
/// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음) /// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음)
static Future<List<String>> localAddresses() async { static Future<List<String>> localAddresses({bool tailscale = true}) async {
try { try {
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false); final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
final all = [ final all = [
for (final i in list) for (final i in list)
for (final a in i.addresses) for (final a in i.addresses)
if (shareInterface(i.name, a.address)) a.address if (shareInterface(i.name, a.address, tailscale: tailscale)) a.address
]; ];
int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3; 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)))); return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b))));
@ -172,10 +173,11 @@ class WifiShare extends ChangeNotifier {
} }
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0) /// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
static bool shareInterface(String name, String ip) { /// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기")
static bool shareInterface(String name, String ip, {bool tailscale = true}) {
final v = ip.split('.'); final v = ip.split('.');
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0; 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 == 100 && b >= 64 && b <= 127) return tailscale && !isMobileInterfaceName(name);
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결 if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
return isLanInterfaceName(name); return isLanInterfaceName(name);
} }
@ -193,8 +195,9 @@ class WifiShare extends ChangeNotifier {
(a == 100 && b >= 64 && b <= 127); (a == 100 && b >= 64 && b <= 127);
} }
/// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null /// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null.
String? resolve(String rel) { /// 보안 검토 10: 디스크는 비동기로 본다 (같은 망의 기기가 요청할 때마다 화면이 멈칫하지 않게)
Future<String?> resolve(String rel) async {
final segs = <String>[]; final segs = <String>[];
for (final s in rel.split('/')) { for (final s in rel.split('/')) {
if (s.isEmpty) continue; if (s.isEmpty) continue;
@ -205,11 +208,11 @@ class WifiShare extends ChangeNotifier {
for (final s in segs) { for (final s in segs) {
at = p.join(at, s); at = p.join(at, s);
// 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다 // 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다
if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.link) return null; if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.link) return null;
} }
if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.notFound) return null; if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.notFound) return null;
try { try {
final real = FileSystemEntity.isDirectorySync(at) ? Directory(at).resolveSymbolicLinksSync() : File(at).resolveSymbolicLinksSync(); 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; if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null;
} catch (_) { } catch (_) {
return null; return null;
@ -218,13 +221,13 @@ class WifiShare extends ChangeNotifier {
} }
/// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지 /// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지
bool _blocked(String rel) { Future<bool> _blocked(String rel) async {
var at = _rootReal; var at = _rootReal;
for (final s in rel.split('/')) { for (final s in rel.split('/')) {
if (s.isEmpty) continue; if (s.isEmpty) continue;
if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true; if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true;
at = p.join(at, s); at = p.join(at, s);
final t = FileSystemEntity.typeSync(at, followLinks: false); final t = await FileSystemEntity.type(at, followLinks: false);
if (t == FileSystemEntityType.link) return true; if (t == FileSystemEntityType.link) return true;
if (t == FileSystemEntityType.notFound) return false; if (t == FileSystemEntityType.notFound) return false;
} }
@ -283,13 +286,13 @@ class WifiShare extends ChangeNotifier {
return; return;
} }
final rel = Uri.decodeComponent(path); final rel = Uri.decodeComponent(path);
final target = resolve(rel); final target = await resolve(rel);
if (target == null) { if (target == null) {
status = _blocked(rel) ? 403 : 404; status = await _blocked(rel) ? 403 : 404;
return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다')); return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다'));
} }
if (FileSystemEntity.isDirectorySync(target)) { if (await FileSystemEntity.isDirectory(target)) {
return await _page(res, 200, _listHtml(rel, target), head: req.method == 'HEAD'); return await _page(res, 200, await _listHtml(rel, target), head: req.method == 'HEAD');
} }
status = await _file(req, res, target); status = await _file(req, res, target);
} catch (_) { } catch (_) {
@ -453,13 +456,19 @@ class WifiShare extends ChangeNotifier {
'<form method="get" action="/"><input name="code" inputmode="numeric" autocomplete="off" maxlength="6" autofocus>' '<form method="get" action="/"><input name="code" inputmode="numeric" autocomplete="off" maxlength="6" autofocus>'
'<button type="submit">${_esc(tr('열기'))}</button></form></body></html>'; '<button type="submit">${_esc(tr('열기'))}</button></form></body></html>';
String _listHtml(String rel, String dir) { Future<String> _listHtml(String rel, String dir) async {
final entries = <FileSystemEntity>[]; final entries = <FileSystemEntity>[];
final sizes = <String, int>{};
try { try {
for (final e in Directory(dir).listSync(followLinks: false)) { await for (final e in Directory(dir).list(followLinks: false)) {
final n = p.basename(e.path); final n = p.basename(e.path);
if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게 if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게
entries.add(e); entries.add(e);
if (e is File) {
try {
sizes[e.path] = await e.length();
} catch (_) {}
}
} }
} catch (_) {} } catch (_) {}
entries.sort((a, b) { entries.sort((a, b) {
@ -481,10 +490,7 @@ class WifiShare extends ChangeNotifier {
if (e is Directory) { if (e is Directory) {
b.write('<tr><td><a href="$href/">📁 ${_esc(n)}</a></td><td class="s"></td></tr>'); b.write('<tr><td><a href="$href/">📁 ${_esc(n)}</a></td><td class="s"></td></tr>');
} else { } else {
int size = 0; final size = sizes[e.path] ?? 0;
try {
size = (e as File).lengthSync();
} catch (_) {}
b.write('<tr><td><a href="$href">${_esc(n)}</a></td>' 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>'); '<td class="s">${_esc(formatSize(size))} · <a href="$href?dl=1">${_esc(tr('받기'))}</a></td></tr>');
} }

View File

@ -70,7 +70,8 @@ class _WifiSharePageState extends State<WifiSharePage> {
}); });
try { try {
final mins = widget.c.settings.wifiShareMinutes; final mins = widget.c.settings.wifiShareMinutes;
final s = await WifiShare.start(widget.folder, autoOff: mins > 0 ? Duration(minutes: mins) : null); final s = await WifiShare.start(widget.folder,
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.wifiShareTailscale);
await widget.c.updateSettings((x) => x.wifiShareFolder = widget.folder); await widget.c.updateSettings((x) => x.wifiShareFolder = widget.folder);
_s?.removeListener(_changed); _s?.removeListener(_changed);
_s = s..addListener(_changed); _s = s..addListener(_changed);
@ -124,6 +125,17 @@ class _WifiSharePageState extends State<WifiSharePage> {
}, },
), ),
]), ]),
// 보안 검토 3 (관리자 결정 10/18): Tailscale 주소에서도 열지 (처음 켬)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(tr('Tailscale 에서도 열기')),
subtitle: Text(tr('내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만')),
value: widget.c.settings.wifiShareTailscale,
onChanged: (v) async {
await widget.c.updateSettings((x) => x.wifiShareTailscale = v);
if (mounted) setState(() {});
},
),
const SizedBox(height: 12), const SizedBox(height: 12),
_note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')), _note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')),
_note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')), _note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')),

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/lan_scan.dart'; import 'package:jj_mkvmaker/core/lan_scan.dart';
import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/wifi_share.dart'; import 'package:jj_mkvmaker/core/wifi_share.dart';
@ -51,6 +52,12 @@ void main() {
expect(WifiShare.shareInterface('vEthernet (WSL)', '172.24.240.1'), isFalse); expect(WifiShare.shareInterface('vEthernet (WSL)', '172.24.240.1'), isFalse);
expect(WifiShare.shareInterface('VirtualBox Host-Only Network', '192.168.56.1'), isFalse); expect(WifiShare.shareInterface('VirtualBox Host-Only Network', '192.168.56.1'), isFalse);
expect(WifiShare.shareInterface('Wi-Fi', '169.254.10.1'), isFalse, reason: '주소를 못 받은 연결'); expect(WifiShare.shareInterface('Wi-Fi', '169.254.10.1'), isFalse, reason: '주소를 못 받은 연결');
// 설정 "Tailscale 에서도 열기" 를 끄면 Tailscale 주소는 열지 않는다 (처음 값은 켬)
expect(WifiShare.shareInterface('Tailscale', '100.69.29.106', tailscale: false), isFalse);
expect(WifiShare.shareInterface('Wi-Fi', '192.168.0.5', tailscale: false), isTrue);
expect(AppSettings().wifiShareTailscale, isTrue);
expect(AppSettings.fromJson(const {'wifiShareTailscale': false}).wifiShareTailscale, isFalse);
expect(AppSettings.fromJson(const {'wifiShareTailscale': 'x'}).wifiShareTailscale, isTrue, reason: '틀린 값은 처음 값');
}); });
test('11: mDNS 로 찾은 주소는 사설망만', () { test('11: mDNS 로 찾은 주소는 사설망만', () {