import 'dart:io'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../core/secret_gate.dart'; import '../core/sftp.dart'; import '../core/webdav.dart' show DavLockedException; import '../l10n/tr.dart'; import 'confirm.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; /// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. class SftpSettings extends StatelessWidget { final AppController c; const SftpSettings({super.key, required this.c}); @override Widget build(BuildContext context) { final servers = c.settings.remote.sftpServers; return Column(children: [ SettingTile( leading: const Icon(Icons.vpn_key_outlined), title: const Text('SFTP'), subtitle: Text(tr('SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). ' '비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.')), trailing: TextButton.icon( onPressed: () => editSftpServer(context, c), icon: const Icon(Icons.add, size: 18), label: Text(tr('추가')), ), ), for (final s in servers) Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( dense: true, leading: Icon(Icons.vpn_key, color: JjColors.accent), title: Text(s.label), subtitle: Text( [ '${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}', if (s.root.isNotEmpty) s.root, if (s.privateKey.isNotEmpty) tr('개인 키'), if (s.legacyRsa) '⚠ ${tr('오래된 ssh-rsa 허용')}', if (s.hostKeyFingerprint.isEmpty) tr('서버 키 아직 확인 안 함'), ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editSftpServer(context, c, old: s), ), IconButton( tooltip: tr('지우기'), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), onPressed: () => deleteSftpServer(context, c, s), ), ]), ), ), ]); } } /// 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true Future deleteSftpServer(BuildContext context, AppController c, SftpServer s) async { final users = davServerUsers(c.settings, s.id); final ok = await confirmAction( context, title: trf('SFTP 서버 "{0}" 을(를) 지울까요?', [s.label]), body: tr('서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. ' '이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') + (users.isEmpty ? '' : '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n' '${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'), ok: tr('지우기'), ); if (!ok) return false; await c.updateSettings((x) => x.remote.sftpServers = [for (final y in x.remote.sftpServers) if (y.id != s.id) y]); return true; } /// 처음 보는 서버 키를 믿을지 묻는다 (TOFU). 믿으면 true. 바뀐 키는 여기서 믿지 않는다 (서버 고치기에서 저장된 키를 지운 뒤) Future askTrustHostKey(BuildContext context, String serverLabel, SftpHostKeyException e) async { if (e.changed) { await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: Icon(Icons.gpp_bad_outlined, color: JjColors.danger), title: Text(trf('"{0}" 서버의 키가 바뀌었습니다', [serverLabel])), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, ' '서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.')), const SizedBox(height: 10), SelectableText('${tr('예전')}: ${e.previous}\n${tr('지금')}: ${e.fingerprint} (${e.type})', style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ]), ), actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], ), ); return false; } final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: const Icon(Icons.fingerprint), title: Text(trf('"{0}" 에 처음 연결합니다', [serverLabel])), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('서버의 키 지문이 서버 (NAS 설정 · ssh-keygen -lf) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, ' '다음부터 키가 바뀌면 연결하지 않고 알립니다.')), const SizedBox(height: 10), SelectableText('${e.fingerprint}\n(${e.type})', style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('믿고 연결'))), ], ), ); return ok == true; } /// 믿은 서버 키를 설정에 저장 (탐색기에서 처음 연결할 때) Future trustSftpHostKey(AppController c, String id, SftpHostKeyException e) => c.updateSettings((x) => x.remote.sftpServers = [ for (final y in x.remote.sftpServers) y.id == id ? y.copyWith(hostKeyType: e.type, hostKeyFingerprint: e.fingerprint) : y, ]); /// SFTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null) Future editSftpServer(BuildContext context, AppController c, {SftpServer? old}) async { // 저장된 비밀번호 · 키가 칸에 채워지므로 먼저 마스터 비밀번호 (정해 두었으면) if (old != null && !await SecretGate.passIf(old.password.isNotEmpty || old.privateKey.isNotEmpty, force: true)) return null; if (!context.mounted) return null; final name = TextEditingController(text: old?.name ?? ''); final host = TextEditingController(text: old?.host ?? ''); final port = TextEditingController(text: '${old?.port ?? 22}'); final user = TextEditingController(text: old?.user ?? ''); final pass = TextEditingController(text: old?.password ?? ''); final passphrase = TextEditingController(text: old?.keyPassphrase ?? ''); final root = TextEditingController(text: old?.root ?? ''); var key = old?.privateKey ?? ''; var legacy = old?.legacyRsa ?? false; var keyType = old?.hostKeyType ?? ''; var fingerprint = old?.hostKeyFingerprint ?? ''; var hide = true; String? result; var passed = false; var testing = false; SftpServer current() => SftpServer( id: old?.id ?? 'sftp${DateTime.now().microsecondsSinceEpoch}', name: name.text.trim(), host: host.text.trim(), port: int.tryParse(port.text.trim()) ?? 22, user: user.text.trim(), root: root.text.trim(), password: pass.text, privateKey: key, keyPassphrase: passphrase.text, legacyRsa: legacy, hostKeyType: keyType, hostKeyFingerprint: fingerprint, ); final saved = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) { Future test() async { FocusScope.of(ctx).unfocus(); set(() { testing = true; result = null; }); for (var round = 0; round < 2; round++) { final client = SftpRemote(current()); try { final items = await client.list('/'); final (free, _) = await client.quota(); passed = true; result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']); } on SftpHostKeyException catch (e) { passed = false; result = e.message; // 처음 보는 키: 묻고 믿으면 이 창의 값에 넣고 한 번 더 (저장은 [저장] 때) if (!e.changed && ctx.mounted && await askTrustHostKey(ctx, current().label, e)) { keyType = e.type; fingerprint = e.fingerprint; result = null; client.close(); continue; } } on DavLockedException catch (e) { passed = false; result = e.message; } catch (e) { passed = false; result = '$e'; } finally { client.close(); } break; } if (ctx.mounted) set(() => testing = false); } Future pickKey() async { final files = await c.services.storage.pickFiles(title: tr('개인 키 파일 고르기'), extensions: const []); if (files.isEmpty) return; try { final f = File(files.first); // 개인 키는 작다 - 큰 파일은 키가 아니다 (실수로 다른 파일을 고름) if (await f.length() > 64 * 1024) throw FormatException(tr('개인 키 파일이 아닙니다 (너무 큼)')); final text = await f.readAsString(); // 관리자 지시 (10/21): 고를 때 바로 꼴을 본다 (OpenSSH · PEM RSA · EC - PuTTY · PKCS#8 · 공개 키는 바꾸는 법을 알림) final encrypted = checkPrivateKey(text); set(() { key = text; // 관리자 지시 (10/21, 15 찾음 F4): 안내는 창 맨 아래 - 키 암호 칸은 그 위 (칸 이름은 실제 칸 이름 그대로) result = encrypted ? trf('개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)', [tr('개인 키 암호')]) : tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); passed = true; }); } catch (e) { set(() { result = e is FormatException ? e.message : e is SftpException ? e.message : '$e'; passed = false; }); } } final ok = host.text.trim().isNotEmpty && (int.tryParse(port.text.trim()) ?? 0) > 0; return AlertDialog( scrollable: true, title: Text(old == null ? tr('SFTP 서버 추가') : tr('SFTP 서버 고치기')), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), Row(children: [ Expanded( child: TextField( controller: host, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3), onChanged: (_) => set(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, child: TextField( controller: port, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: tr('포트')), onChanged: (_) => set(() {}), ), ), ]), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), TextField( controller: pass, obscureText: hide, decoration: InputDecoration( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 labelText: tr('비밀번호'), helperText: tr('개인 키만 쓰면 비웁니다'), helperMaxLines: 3, suffixIcon: IconButton( tooltip: hide ? tr('보이기') : tr('가리기'), icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), onPressed: () => set(() => hide = !hide), ), ), ), const SizedBox(height: 8), Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton.icon( icon: const Icon(Icons.key_outlined, size: 16), label: Text(key.isEmpty ? tr('개인 키 파일 고르기') : tr('개인 키 바꾸기')), onPressed: pickKey, ), if (key.isNotEmpty) ...[ Text(tr('개인 키 있음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), TextButton(onPressed: () => set(() => key = ''), child: Text(tr('개인 키 빼기'))), ], ]), if (key.isNotEmpty) TextField( controller: passphrase, obscureText: hide, decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3), ), TextField( controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), ), CheckboxListTile( contentPadding: EdgeInsets.zero, value: legacy, onChanged: (v) => set(() => legacy = v ?? false), title: Text(tr('오래된 ssh-rsa 허용')), subtitle: Text(tr('SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)')), ), // 믿은 서버 키 (처음 연결 때 확인) Text( fingerprint.isEmpty ? tr('서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)') : '${tr('믿은 서버 키')}: $fingerprint${keyType.isEmpty ? '' : ' ($keyType)'}', style: TextStyle(fontSize: 12, color: JjColors.textDim)), if (fingerprint.isNotEmpty) Align( alignment: Alignment.centerLeft, child: TextButton( // 바뀐 키를 새로 믿으려면: 마스터 비밀번호 (정해 두었으면) 뒤 지우고 다시 확인 onPressed: () async { if (!await SecretGate.pass(force: true)) return; set(() { fingerprint = ''; keyType = ''; result = tr('믿은 키를 지웠습니다. [연결 확인] 으로 새 키를 확인하세요'); passed = true; }); }, child: Text(tr('믿은 키 지우기')), ), ), if (testing) const LinearProgressIndicator(), if (result != null) Text(result!, style: TextStyle(fontSize: 12, color: passed ? JjColors.positive : JjColors.alert)), ]), ), actions: [ if (old != null) TextButton( onPressed: () async { if (await deleteSftpServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx); }, style: TextButton.styleFrom(foregroundColor: JjColors.alert), child: Text(tr('지우기')), ), TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))), ], ); }, ), ); if (saved == null) return null; await c.updateSettings((x) => x.remote.sftpServers = [ for (final y in x.remote.sftpServers) y.id == saved.id ? saved : y, if (!x.remote.sftpServers.any((y) => y.id == saved.id)) saved, ]); return saved; }