JJ_MKVMaker/lib/ui/sftp_settings.dart

373 lines
18 KiB
Dart

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<bool> 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<bool> askTrustHostKey(BuildContext context, String serverLabel, SftpHostKeyException e) async {
if (e.changed) {
await showDialog<void>(
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<bool>(
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<void> 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<SftpServer?> 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<SftpServer>(
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 = 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<void> 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;
}