JJ_MKVMaker/lib/ui/ftp_settings.dart

321 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/ftp.dart';
import '../core/secret_gate.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;
/// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀.
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다
class FtpSettings extends StatelessWidget {
final AppController c;
const FtpSettings({super.key, required this.c});
@override
Widget build(BuildContext context) {
final servers = c.settings.ftpServers;
return Column(children: [
SettingTile(
leading: const Icon(Icons.folder_shared_outlined),
title: const Text('FTP · FTPS'),
subtitle: Text(tr('FTP 서버의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). '
'처음 값은 암호화하는 FTPS 이고, 비밀번호는 이 기기의 안전 저장소에만 둡니다.')),
trailing: TextButton.icon(
onPressed: () => editFtpServer(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(s.tls ? Icons.folder_shared : Icons.warning_amber_outlined, color: s.tls ? JjColors.accent : JjColors.danger),
title: Text(s.label),
subtitle: Text(
[
'${s.user.isEmpty ? '' : '${s.user}@'}${s.host}:${s.port}',
ftpSecurityLabel(s.security),
if (s.root.isNotEmpty) s.root,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: s.tls ? null : const TextStyle(color: JjColors.danger)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editFtpServer(context, c, old: s)),
IconButton(
tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => deleteFtpServer(context, c, s),
),
]),
),
),
]);
}
}
String ftpSecurityLabel(String security) => switch (security) {
'implicit' => tr('FTPS (암시, 보통 990)'),
'none' => tr('⚠ 평문 FTP (암호화 안 됨)'),
_ => tr('FTPS (명시, AUTH TLS)'),
};
/// 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true
Future<bool> deleteFtpServer(BuildContext context, AppController c, FtpServer s) async {
final users = davServerUsers(c.settings, s.id);
final ok = await confirmAction(
context,
title: trf('FTP 서버 "{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.ftpServers = [for (final y in x.ftpServers) if (y.id != s.id) y]);
return true;
}
/// 처음 보는 자체 서명 인증서를 믿을지 묻는다 (TOFU). 믿으면 true. 바뀐 인증서는 여기서 믿지 않는다 (서버 고치기에서 지운 뒤)
Future<bool> askTrustFtpCert(BuildContext context, String serverLabel, FtpCertException e) async {
if (e.changed) {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.gpp_bad_outlined, color: JjColors.danger),
title: Text(trf('"{0}" 서버의 인증서가 바뀌었습니다', [serverLabel])),
content: SelectableText(
'${tr('중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.')}\n\n'
'${tr('예전')}: ${e.previous}\n${tr('지금')}: ${e.fingerprint}',
style: const TextStyle(fontSize: 12)),
actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
),
);
return false;
}
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.fingerprint),
title: Text(trf('"{0}" 의 인증서를 처음 봅니다', [serverLabel])),
content: SelectableText(
'${tr('자체 서명 인증서입니다. 지문이 서버 (NAS 설정) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 인증서가 바뀌면 연결하지 않고 알립니다.')}\n\n'
'${e.subject}\n${e.fingerprint}',
style: const TextStyle(fontSize: 12)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('믿고 연결'))),
],
),
);
return ok == true;
}
/// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때)
Future<void> trustFtpCert(AppController c, String id, FtpCertException e) =>
c.updateSettings((x) => x.ftpServers = [for (final y in x.ftpServers) y.id == id ? y.copyWith(certFingerprint: e.fingerprint) : y]);
/// FTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null)
Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServer? old}) async {
if (old != null && old.password.isNotEmpty && !await SecretGate.pass(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 ?? 21}');
final user = TextEditingController(text: old?.user ?? '');
final pass = TextEditingController(text: old?.password ?? '');
final root = TextEditingController(text: old?.root ?? '');
var security = old?.security ?? 'explicit';
var fingerprint = old?.certFingerprint ?? '';
var hide = true;
String? result;
var passed = false;
var testing = false;
FtpServer current() => FtpServer(
id: old?.id ?? 'ftp${DateTime.now().microsecondsSinceEpoch}',
name: name.text.trim(),
host: host.text.trim(),
port: int.tryParse(port.text.trim()) ?? 21,
user: user.text.trim(),
root: root.text.trim(),
password: pass.text,
security: security,
certFingerprint: fingerprint,
);
final saved = await showDialog<FtpServer>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Future<void> test() async {
FocusScope.of(ctx).unfocus();
set(() {
testing = true;
result = null;
});
for (var round = 0; round < 2; round++) {
final client = FtpRemote(current());
try {
final items = await client.list('/');
passed = true;
result = trf('연결됨: 항목 {0}개', [items.length]);
} on FtpCertException catch (e) {
passed = false;
result = e.message;
if (!e.changed && ctx.mounted && await askTrustFtpCert(ctx, current().label, e)) {
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);
}
final ok = host.text.trim().isNotEmpty && (int.tryParse(port.text.trim()) ?? 0) > 0;
return AlertDialog(
scrollable: true,
title: Text(old == null ? tr('FTP 서버 추가') : tr('FTP 서버 고치기')),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(controller: name, decoration: InputDecoration(labelText: tr('이름 (탭에 보임, 비우면 주소)'))),
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)')),
onChanged: (_) => set(() {}),
),
),
const SizedBox(width: 8),
SizedBox(
width: 80,
child: TextField(
controller: port,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: tr('포트')),
onChanged: (_) => set(() {}),
),
),
]),
const SizedBox(height: 8),
Text(tr('암호화'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
RadioGroup<String>(
groupValue: security,
onChanged: (x) => set(() {
final was = security;
security = x ?? 'explicit';
// 포트를 그대로 두었으면 방식에 맞게 (21 ↔ 990)
if (port.text.trim() == (was == 'implicit' ? '990' : '21')) port.text = security == 'implicit' ? '990' : '21';
}),
child: Column(children: [
for (final v in const ['explicit', 'implicit', 'none'])
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
dense: true,
value: v,
title: Text(ftpSecurityLabel(v), style: v == 'none' ? const TextStyle(color: JjColors.danger) : null),
),
]),
),
if (security == 'none')
Container(
padding: const EdgeInsets.all(8),
color: JjColors.danger.withValues(alpha: 0.12),
child: Text(
tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.danger)),
),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))),
TextField(
controller: pass,
obscureText: hide,
decoration: InputDecoration(
labelText: tr('비밀번호'),
suffixIcon: IconButton(
tooltip: hide ? tr('보이기') : tr('가리기'),
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
onPressed: () => set(() => hide = !hide),
),
),
),
TextField(
controller: root,
decoration: InputDecoration(labelText: tr('시작 폴더 (비우면 로그인한 곳)'), helperText: tr('예: /volume1/video · 탭의 맨 위가 됩니다')),
),
if (security != 'none') ...[
const SizedBox(height: 6),
Text(
fingerprint.isEmpty
? tr('인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다')
: '${tr('믿은 인증서')}: $fingerprint',
style: const 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 = '';
result = tr('믿은 인증서를 지웠습니다. [연결 확인] 으로 새 인증서를 확인하세요');
passed = true;
});
},
child: Text(tr('믿은 인증서 지우기')),
),
),
],
if (testing) const LinearProgressIndicator(),
if (result != null) Text(result!, style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)),
]),
),
actions: [
if (old != null)
TextButton(
onPressed: () async {
if (await deleteFtpServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx);
},
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
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.ftpServers = [
for (final y in x.ftpServers) y.id == saved.id ? saved : y,
if (!x.ftpServers.any((y) => y.id == saved.id)) saved,
]);
return saved;
}