WIP l FTP · FTPS: 탐색기 원격 탭 (WebDAV · SFTP 와 같은 길) - dart:io 로 직접, 처음 값 FTPS (검토 대기)
관리자 지시 (10/17, l): FTP · FTPS 를 탐색기 원격 탭으로. 평문 FTP 는 "암호화 안 됨" 경고, 처음 값은 FTPS, 비밀번호는 안전 저장소.
- lib/core/ftp.dart (새, 패키지 없음 - ftpconnect 3.0.0 은 dartssh2 ^3 에 묶여 우리 dartssh2 4.1.0 과 함께 못 씀):
· 명령 채널 RawSocket → 명시 FTPS 는 AUTH TLS 뒤 RawSecureSocket.secure (같은 연결을 TLS 로), 암시 FTPS (990) 는 연결하자마자 TLS. PBSZ 0 · PROT P
· 목록 MLSD (FEAT 에 MLST 가 있으면) 아니면 LIST (유닉스 꼴), 정보 · 만들기 · 지우기 (안쪽까지) · 옮기기 (RNFR/RNTO)
· 받기 · 올리기는 따로 연결 (REST 로 부분 읽기 - 동영상 건너뛰기 · 이어 받기). 자료 연결의 TLS 는 명령에 150/125 를 받은 뒤
· 자료 연결 주소는 명령 채널의 서버 주소로만 (PASV 가 알려 준 다른 주소로 가지 않음 - FTP 바운스 막기). EPSV 먼저
· FTPS 로 고른 서버가 평문만 되면 몰래 평문으로 내려가지 않고 이유를 알림
· 자체 서명 인증서: SFTP 호스트 키처럼 처음 한 번 지문 (SHA-256) 을 보이고 믿을지 묻고 (TOFU), 바뀌면 막음. 공인 인증서는 그대로
· DNS 강제를 따름 (FtpRegistry.resolveWithDns - SFTP 와 같게)
- vfs: ftp://<서버 id>/<경로> (isFtp · noStreamUrl → 받아서 열기, 표시 📂 · 평문은 ⚠, 임시 폴더 jj_ftp)
- 설정: ftpServers (비밀번호는 ftp.pw.<id> 안전 저장소, 설정 파일 · .bak · 보관본에서 뺌), 마스터 초기화에서 지움
- 화면: 환경 설정 > 파일 탐색기 > FTP · FTPS (추가 · 고치기 · 지우기 · 연결 확인, 암호화 고르기 - 평문은 빨간 경고),
탐색기 위 줄 탭 (평문은 ⚠ 이름) · [FTP 서버 추가] · 좁은 창 메뉴 · 길게 누르기 고치기 · 인증서 지문 안내 [지문 확인하고 연결]
시험: test/ftp_test.dart 7 - 설정의 비밀번호 (안전 저장소 · 보관본), 목록 줄 읽기, 실제 서버 (tool/ftp_test_server.py = pyftpdlib 2.2.0 ·
pyOpenSSL 26.4.0, 시험용 파이썬에만 - 앱에 안 들어감): 명시 FTPS (인증서 TOFU · 바뀐 인증서 막기 · 폴더 · 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기),
암시 FTPS, 평문 FTP (+ FTPS 서버가 평문만이면 이유), 틀린 비밀번호 · 비밀번호 없음 · 닿지 않음.
sftp · master_lock · settings_store · webdav · explorer_webdav · explorer · sftp_explorer · licenses 와 함께 98 통과, analyze 문제 없음, l10n 빠짐 0.
시험 서버 메모: pyftpdlib 2.2.0 + pyOpenSSL 26 은 자료 연결 TLS 닫기에서 끝없이 되풀이해 STOR 가 끝나지 않는다 - 시험 서버에서만 덮어씀.
못 한 것: 실제 NAS (Synology 등) FTPS · TLS 세션 재사용을 요구하는 서버 (vsftpd require_ssl_reuse) 는 확인 못 함 (Dart 는 자료 연결의 TLS 세션 재사용을 못 함),
FTP 스트리밍 재생 (sftp_next 의 다리와 합칠 때 FtpRemote.openReadFrom 을 넣으면 됨), 경로 복사의 ftp:// 꼴.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ftp
parent
e5a115d450
commit
dd9e93b4fe
|
|
@ -29,6 +29,7 @@
|
|||
" 외 {0}개": " and {0} more",
|
||||
" 지금 설치됨 ({0}MB)": " Installed now ({0}MB)",
|
||||
"\"{0}\" 검색 결과 {1}개": "{1} results for \"{0}\"",
|
||||
"\"{0}\" 서버의 인증서가 바뀌었습니다": "The certificate of server \"{0}\" has changed",
|
||||
"\"{0}\" 서버의 키가 바뀌었습니다": "The key of server \"{0}\" has changed",
|
||||
"\"{0}\" 안에 만듭니다": "Created inside \"{0}\"",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "No items match \"{0}\"",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "Delete \"{0}\"? This cannot be undone.",
|
||||
"\"{0}\" 을(를) 휴지통으로 보낼까요?": "Move \"{0}\" to the Recycle Bin?",
|
||||
"\"{0}\" 의 이름을 바꾸지 못해 멈췄습니다: {1}": "Stopped: couldn't rename \"{0}\": {1}",
|
||||
"\"{0}\" 의 인증서를 처음 봅니다": "First time seeing the certificate of \"{0}\"",
|
||||
"\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "\"{0}\" is empty, so syncing deletes all the items below from the target \"{1}\" (cannot be undone). Press only if you emptied the source on purpose.",
|
||||
"\"{0}\" 이동": "Move \"{0}\"",
|
||||
"\"{0}\" 찾기 · {1}개": "Search \"{0}\" · {1}",
|
||||
|
|
@ -191,6 +193,14 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: part of Windows, nothing to install. If you sign in to YouTube here, downloads use that sign-in too.\nChrome: if chosen, about {0}MB is downloaded and it can be used after a restart (Google sign-in may be blocked).{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "Downloads and installs FFmpeg · yt-dlp · aria2 · Deno if missing",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg ready: {0}{1}",
|
||||
"FTP 서버 \"{0}\" 을(를) 지울까요?": "Delete FTP server \"{0}\"?",
|
||||
"FTP 서버 고치기": "Edit FTP server",
|
||||
"FTP 서버 설정이 없습니다 ({0})": "No FTP server settings ({0})",
|
||||
"FTP 서버 주소가 없습니다": "The FTP server address is empty",
|
||||
"FTP 서버 추가": "Add FTP server",
|
||||
"FTP 서버의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 처음 값은 암호화하는 FTPS 이고, 비밀번호는 이 기기의 안전 저장소에만 둡니다.": "Open FTP server folders in the File Explorer and Rsync screens to copy and sync (top tabs). The default is encrypted FTPS, and passwords are kept only in this device's secure storage.",
|
||||
"FTPS (명시, AUTH TLS)": "FTPS (explicit, AUTH TLS)",
|
||||
"FTPS (암시, 보통 990)": "FTPS (implicit, usually 990)",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "Choose any version published on GitHub and install it (update, or roll back to an earlier working version). The settings of the version you leave are kept and restored when you return to it.",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
|
||||
"HTML 파일로 내보내기": "Export to HTML file",
|
||||
|
|
@ -587,6 +597,8 @@
|
|||
"⚠ 동기화 멈춤 {0}개": "⚠ {0} sync stopped",
|
||||
"⚠ 동기화 멈춤 {0}개 · 원본 확인 필요": "⚠ {0} sync stopped · check the source",
|
||||
"⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ The last run did not end normally (started {0}, last check {1}). Earlier records are in app.log in the Logs folder.",
|
||||
"⚠ 평문 FTP (암호화 안 됨)": "⚠ Plain FTP (not encrypted)",
|
||||
"⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.": "⚠ With plain FTP, your user name, password and files travel unencrypted. Others on the same network can see them. Use it only when the server can't do FTPS, and only on a trusted home network.",
|
||||
"가로 16:9": "Landscape 16:9",
|
||||
"가로 고정": "Landscape only",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)",
|
||||
|
|
@ -1163,6 +1175,9 @@
|
|||
"미지정": "Unspecified",
|
||||
"믿고 연결": "Trust and connect",
|
||||
"믿은 서버 키": "Trusted server key",
|
||||
"믿은 인증서": "Trusted certificate",
|
||||
"믿은 인증서 지우기": "Forget trusted certificate",
|
||||
"믿은 인증서를 지웠습니다. [연결 확인] 으로 새 인증서를 확인하세요": "The trusted certificate was removed. Check the new certificate with [Test connection]",
|
||||
"믿은 키 지우기": "Forget trusted key",
|
||||
"믿은 키를 지웠습니다. [연결 확인] 으로 새 키를 확인하세요": "Trusted key removed. Check the new key with [Test connection]",
|
||||
"바깥 폴더 (../)": "Parent folder (../)",
|
||||
|
|
@ -1400,11 +1415,15 @@
|
|||
"색온도": "Temperature",
|
||||
"서버 ({0}) 가 답하지 않습니다": "The server ({0}) isn't responding",
|
||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "Can't connect to the server ({0}). Check that the server is on and, if you're away from home, that Tailscale is on",
|
||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "The connection to the server ({0}) was lost",
|
||||
"서버 · 서비스 (기기 밖)": "Servers · services (off device)",
|
||||
"서버 설정 고치기": "Edit server settings",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server settings and the password, private key and trusted server key saved on this device. Files on the server stay as they are. Syncs and copies that use this server won't connect until you add it again.",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "Deletes the server settings and the password and trusted certificate saved on this device. Files on the server are not touched.",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server setting and the password saved on this device. Files on the server stay as they are. Syncs and copies using this server won't connect until you add it again.",
|
||||
"서버 인증서가 바뀌었습니다": "The server certificate has changed",
|
||||
"서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다": "The server certificate has changed. You may be connected to a different server",
|
||||
"서버 인증서를 확인할 수 없습니다": "Cannot verify the server certificate",
|
||||
"서버 저장 공간 부족": "server storage full",
|
||||
"서버 주소": "Server address",
|
||||
|
|
@ -1415,6 +1434,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "The server returned no image",
|
||||
"서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk",
|
||||
"서버가 이 기능을 허용하지 않음": "the server does not allow this",
|
||||
"서버에 닿을 수 없음": "host unreachable",
|
||||
"서버에 닿지 않습니다": "Cannot reach the server",
|
||||
|
|
@ -1422,6 +1442,7 @@
|
|||
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].",
|
||||
"서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "Couldn't agree on an encryption method with the server. If it's an old server, try turning on \"Allow old ssh-rsa\" in the server settings",
|
||||
"서버와 암호 방식을 맞추지 못했습니다: {0}": "Couldn't agree on an encryption method with the server: {0}",
|
||||
"서버의 자료 연결 응답을 읽지 못했습니다: {0}": "Couldn't read the server's data connection reply: {0}",
|
||||
"서버의 키 지문이 서버 (NAS 설정 · ssh-keygen -lf) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 키가 바뀌면 연결하지 않고 알립니다.": "Check that the server's key fingerprint matches what the server shows (NAS settings or ssh-keygen -lf). If you trust it, it's saved on this device, and if the key changes later you'll be told and not connected.",
|
||||
"서버의 키가 바뀌었습니다": "The server's key has changed",
|
||||
"서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "The server's key has changed. This could be an interception attack, so the connection was not made. Before: {0} · Now: {1}",
|
||||
|
|
@ -1512,6 +1533,7 @@
|
|||
"시작 메뉴에 등록": "Add to Start menu",
|
||||
"시작 안 함 (모니터링에서 시작)": "Don't start (start from Monitor)",
|
||||
"시작 폴더 (공유 안, 비우면 맨 위)": "Start folder (inside the share, top if empty)",
|
||||
"시작 폴더 (비우면 로그인한 곳)": "Start folder (empty = where you log in)",
|
||||
"시작 폴더 (비우면 홈 폴더)": "Start folder (home folder if empty)",
|
||||
"시작: {0} → {1}{2}": "Started: {0} → {1}{2}",
|
||||
"시작하는 중…": "Starting…",
|
||||
|
|
@ -1559,6 +1581,7 @@
|
|||
"아무것도 안 함": "Do nothing",
|
||||
"아이디": "User name",
|
||||
"아이디 (비우면 게스트)": "User name (guest if empty)",
|
||||
"아이디 (비우면 익명)": "User name (empty = anonymous)",
|
||||
"아이디 (선택)": "Username (optional)",
|
||||
"아이디 · 비밀번호를 확인하세요": "check the user name and password",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong",
|
||||
|
|
@ -1580,6 +1603,8 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||
"암호화": "Encryption",
|
||||
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "Couldn't establish an encrypted (TLS) connection: {0}",
|
||||
"암호화 안 됨 (http)": "Not encrypted (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
|
||||
"압축 파일 안에 없습니다": "Not in the archive",
|
||||
|
|
@ -1652,6 +1677,7 @@
|
|||
"연결 안 됨": "Not connected",
|
||||
"연결 확인": "Test connection",
|
||||
"연결됨": "Connected",
|
||||
"연결됨: 항목 {0}개": "Connected: {0} items",
|
||||
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})",
|
||||
"연결이 끊겼습니다": "The connection was closed",
|
||||
|
|
@ -1963,6 +1989,7 @@
|
|||
"인도네시아어": "Indonesian",
|
||||
"인식된 말이 없습니다.": "No speech was recognized.",
|
||||
"인증서 확인 안 함": "Don't verify certificate",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "Certificate: a publicly trusted one connects directly; a self-signed one shows its fingerprint and asks at [Test connection]",
|
||||
"인코딩": "Encoding",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Check your internet connection or the server address in Settings > Network · DNS.",
|
||||
|
|
@ -2033,6 +2060,7 @@
|
|||
"자세히": "Details",
|
||||
"자세히 닫기": "Hide details",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "For a home NAS with a self-signed certificate, etc. (trusted servers only)",
|
||||
"자체 서명 인증서입니다. 지문이 서버 (NAS 설정) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 인증서가 바뀌면 연결하지 않고 알립니다.": "This is a self-signed certificate. Check that the fingerprint matches the one shown on the server (NAS settings). If you trust it, it is saved on this device, and if the certificate changes later the app won't connect and will tell you.",
|
||||
"작게": "Small",
|
||||
"작업": "Jobs",
|
||||
"작업 기록 · 받다 만 파일": "Activity log · unfinished downloads",
|
||||
|
|
@ -2085,6 +2113,7 @@
|
|||
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "Asked when using saved passwords, settings or features that need a password. If you forget it, reset it with the top password, or delete the saved passwords and reset it.",
|
||||
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "It will no longer be asked when using saved passwords or settings.",
|
||||
"저장된 비밀번호가 없습니다": "No password is saved",
|
||||
"저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요": "No saved password. Enter the password in the server settings",
|
||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "No saved password. Please enter it once more",
|
||||
"저장된 비밀번호를 되살리지 못했습니다": "Couldn't restore the saved passwords",
|
||||
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to use features with saved passwords or open settings. It won't be asked again until the app is closed.",
|
||||
|
|
@ -2171,6 +2200,7 @@
|
|||
"준비 중…": "Preparing…",
|
||||
"준비하는 중": "Preparing",
|
||||
"줄 삭제": "Delete line",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "Not connected because this could be an interception attack. Only if you're sure the server certificate was changed, use [Forget trusted certificate] in the server settings and connect again.",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "This could be an interception attack, so the connection was not made. Only if you're sure the server was reinstalled or its key was changed, use [Forget trusted key] in the server settings and connect again to check the new key.",
|
||||
"중국어": "Chinese",
|
||||
"중국어 간체": "Chinese (Simplified)",
|
||||
|
|
@ -2287,6 +2317,8 @@
|
|||
"채우기 (가운데 잘라냄)": "Fill (crop the center)",
|
||||
"처리 장치": "Processor",
|
||||
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "Start / 🏠 address (default: the Tor connection check page)",
|
||||
"처음 보는 서버 인증서입니다": "This server certificate is new",
|
||||
"처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "This server certificate is new (self-signed). Check the fingerprint, then decide whether to trust it",
|
||||
"처음 사용 시 내려받기 {0}": "Downloads {0} on first use",
|
||||
"처음 시작 시": "At app start",
|
||||
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
" 외 {0}개": " ほか {0} 件",
|
||||
" 지금 설치됨 ({0}MB)": " インストール済み({0}MB)",
|
||||
"\"{0}\" 검색 결과 {1}개": "「{0}」の検索結果 {1}件",
|
||||
"\"{0}\" 서버의 인증서가 바뀌었습니다": "サーバー「{0}」の証明書が変わりました",
|
||||
"\"{0}\" 서버의 키가 바뀌었습니다": "サーバー「{0}」の鍵が変わりました",
|
||||
"\"{0}\" 안에 만듭니다": "「{0}」の中に作成します",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "「{0}」に合う項目はありません",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "「{0}」を削除しますか? 元に戻せません。",
|
||||
"\"{0}\" 을(를) 휴지통으로 보낼까요?": "「{0}」をごみ箱に移動しますか?",
|
||||
"\"{0}\" 의 이름을 바꾸지 못해 멈췄습니다: {1}": "「{0}」の名前を変更できず中止しました: {1}",
|
||||
"\"{0}\" 의 인증서를 처음 봅니다": "「{0}」の証明書を初めて確認します",
|
||||
"\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "「{0}」が空のため、同期すると対象「{1}」の以下の項目がすべて削除されます (元に戻せません)。元フォルダーを意図的に空にした場合のみ押してください。",
|
||||
"\"{0}\" 이동": "「{0}」を移動",
|
||||
"\"{0}\" 찾기 · {1}개": "「{0}」を検索 · {1}件",
|
||||
|
|
@ -191,6 +193,14 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows に含まれているので別途インストール不要です。ここで YouTube にログインすると、ダウンロードにもそのログインが使われます。\nChrome: 選ぶと約 {0}MB をダウンロードし、再起動後に使えます(Google ログインはブロックされることがあります)。{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "FFmpeg・yt-dlp・aria2・Deno がなければダウンロードしてインストールします",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg 準備完了: {0}{1}",
|
||||
"FTP 서버 \"{0}\" 을(를) 지울까요?": "FTP サーバー「{0}」を削除しますか?",
|
||||
"FTP 서버 고치기": "FTP サーバーを編集",
|
||||
"FTP 서버 설정이 없습니다 ({0})": "FTP サーバーの設定がありません ({0})",
|
||||
"FTP 서버 주소가 없습니다": "FTP サーバーのアドレスがありません",
|
||||
"FTP 서버 추가": "FTP サーバーを追加",
|
||||
"FTP 서버의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 처음 값은 암호화하는 FTPS 이고, 비밀번호는 이 기기의 안전 저장소에만 둡니다.": "FTP サーバーのフォルダーをファイルエクスプローラー · Rsync 画面で開いてコピー · 同期します (上部のタブ)。既定は暗号化する FTPS で、パスワードはこの端末の安全な保存先にのみ置きます。",
|
||||
"FTPS (명시, AUTH TLS)": "FTPS (明示的, AUTH TLS)",
|
||||
"FTPS (암시, 보통 990)": "FTPS (暗黙的, 通常 990)",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "GitHub に公開したすべてのバージョンから選んでインストールします (新しいバージョンへ · 以前の正常なバージョンへ戻す)。切り替える前のバージョンの設定は保管し、そのバージョンに戻ると復元します",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
|
||||
"HTML 파일로 내보내기": "HTML ファイルに書き出す",
|
||||
|
|
@ -587,6 +597,8 @@
|
|||
"⚠ 동기화 멈춤 {0}개": "⚠ 同期停止 {0} 件",
|
||||
"⚠ 동기화 멈춤 {0}개 · 원본 확인 필요": "⚠ 同期停止 {0} 件・元を確認してください",
|
||||
"⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ 前回の実行が正常に終了しませんでした(開始 {0}、最終確認 {1})。それ以前の記録は Logs フォルダーの app.log にあります。",
|
||||
"⚠ 평문 FTP (암호화 안 됨)": "⚠ 平文 FTP (暗号化なし)",
|
||||
"⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.": "⚠ 平文 FTP ではユーザー名 · パスワード · ファイルが暗号化されずにやり取りされます。同じネットワークの他人に見られる可能性があります。サーバーが FTPS に対応していないときだけ、信頼できる家庭内ネットワークでのみ使ってください。",
|
||||
"가로 16:9": "横 16:9",
|
||||
"가로 고정": "横向き固定",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)",
|
||||
|
|
@ -1163,6 +1175,9 @@
|
|||
"미지정": "未指定",
|
||||
"믿고 연결": "信頼して接続",
|
||||
"믿은 서버 키": "信頼したサーバー鍵",
|
||||
"믿은 인증서": "信頼した証明書",
|
||||
"믿은 인증서 지우기": "信頼した証明書を削除",
|
||||
"믿은 인증서를 지웠습니다. [연결 확인] 으로 새 인증서를 확인하세요": "信頼した証明書を削除しました。[接続確認] で新しい証明書を確認してください",
|
||||
"믿은 키 지우기": "信頼した鍵を削除",
|
||||
"믿은 키를 지웠습니다. [연결 확인] 으로 새 키를 확인하세요": "信頼した鍵を削除しました。[接続確認] で新しい鍵を確認してください",
|
||||
"바깥 폴더 (../)": "外側のフォルダー (../)",
|
||||
|
|
@ -1400,11 +1415,15 @@
|
|||
"색온도": "色温度",
|
||||
"서버 ({0}) 가 답하지 않습니다": "サーバー ({0}) が応答しません",
|
||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "サーバー ({0}) に接続できません。サーバーの電源が入っているか、外出先なら Tailscale がオンか確認してください",
|
||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました",
|
||||
"서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)",
|
||||
"서버 설정 고치기": "サーバー設定を修正",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定と、この端末に保存されたパスワード・秘密鍵・信頼したサーバー鍵を削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再登録するまで接続されません。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "サーバー設定と、この端末に保存したパスワード · 信頼した証明書を削除します。サーバーのファイルはそのままです。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定とこの端末に保存したパスワードを削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再度追加するまで接続できません。",
|
||||
"서버 인증서가 바뀌었습니다": "サーバー証明書が変わりました",
|
||||
"서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다": "サーバー証明書が変わりました。別のサーバーに接続している可能性があります",
|
||||
"서버 인증서를 확인할 수 없습니다": "サーバー証明書を確認できません",
|
||||
"서버 저장 공간 부족": "サーバーの空き容量不足",
|
||||
"서버 주소": "サーバーアドレス",
|
||||
|
|
@ -1415,6 +1434,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした",
|
||||
"서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください",
|
||||
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
||||
"서버에 닿을 수 없음": "ホストに到達できません",
|
||||
"서버에 닿지 않습니다": "サーバーに接続できません",
|
||||
|
|
@ -1422,6 +1442,7 @@
|
|||
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。",
|
||||
"서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "サーバーと暗号方式を合わせられませんでした。古いサーバーの場合はサーバー設定の「古い ssh-rsa を許可」をオンにしてみてください",
|
||||
"서버와 암호 방식을 맞추지 못했습니다: {0}": "サーバーと暗号方式を合わせられませんでした: {0}",
|
||||
"서버의 자료 연결 응답을 읽지 못했습니다: {0}": "サーバーのデータ接続の応答を読み取れませんでした: {0}",
|
||||
"서버의 키 지문이 서버 (NAS 설정 · ssh-keygen -lf) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 키가 바뀌면 연결하지 않고 알립니다.": "サーバーの鍵のフィンガープリントがサーバー側 (NAS の設定・ssh-keygen -lf) で確認したものと同じか確認してください。信頼するとこの端末に保存し、以後鍵が変わった場合は接続せずにお知らせします。",
|
||||
"서버의 키가 바뀌었습니다": "サーバーの鍵が変わりました",
|
||||
"서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "サーバーの鍵が変わりました。中間者攻撃の可能性があるため接続しませんでした。以前: {0} · 現在: {1}",
|
||||
|
|
@ -1512,6 +1533,7 @@
|
|||
"시작 메뉴에 등록": "スタートメニューに登録",
|
||||
"시작 안 함 (모니터링에서 시작)": "開始しない (モニタリングで開始)",
|
||||
"시작 폴더 (공유 안, 비우면 맨 위)": "開始フォルダー (共有内、空欄なら最上位)",
|
||||
"시작 폴더 (비우면 로그인한 곳)": "開始フォルダー (空欄ならログインした場所)",
|
||||
"시작 폴더 (비우면 홈 폴더)": "開始フォルダー (空欄ならホームフォルダー)",
|
||||
"시작: {0} → {1}{2}": "開始: {0} → {1}{2}",
|
||||
"시작하는 중…": "開始中…",
|
||||
|
|
@ -1559,6 +1581,7 @@
|
|||
"아무것도 안 함": "何もしない",
|
||||
"아이디": "ユーザー名",
|
||||
"아이디 (비우면 게스트)": "ユーザー名 (空欄ならゲスト)",
|
||||
"아이디 (비우면 익명)": "ユーザー名 (空欄なら匿名)",
|
||||
"아이디 (선택)": "ユーザー名(任意)",
|
||||
"아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません",
|
||||
|
|
@ -1580,6 +1603,8 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호화": "暗号化",
|
||||
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "暗号化 (TLS) 接続を確立できませんでした: {0}",
|
||||
"암호화 안 됨 (http)": "暗号化なし (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
|
||||
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
||||
|
|
@ -1652,6 +1677,7 @@
|
|||
"연결 안 됨": "未接続",
|
||||
"연결 확인": "接続テスト",
|
||||
"연결됨": "接続済み",
|
||||
"연결됨: 항목 {0}개": "接続しました: {0} 件",
|
||||
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)",
|
||||
"연결이 끊겼습니다": "接続が切れました",
|
||||
|
|
@ -1963,6 +1989,7 @@
|
|||
"인도네시아어": "インドネシア語",
|
||||
"인식된 말이 없습니다.": "認識された音声がありません。",
|
||||
"인증서 확인 안 함": "証明書を検証しない",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "証明書: 公的な証明書ならそのまま接続し、自己署名なら [接続確認] で指紋を表示して確認します",
|
||||
"인코딩": "エンコード",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "インターネット接続、または 設定 > ネットワーク · DNS のサーバーアドレスを確認してください。",
|
||||
|
|
@ -2033,6 +2060,7 @@
|
|||
"자세히": "詳細",
|
||||
"자세히 닫기": "詳細を閉じる",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "自己署名証明書を使う自宅 NAS など (信頼できるサーバーのみ)",
|
||||
"자체 서명 인증서입니다. 지문이 서버 (NAS 설정) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 인증서가 바뀌면 연결하지 않고 알립니다.": "自己署名証明書です。指紋がサーバー (NAS 設定) で見たものと同じか確認してください。信頼するとこの端末に保存し、次回から証明書が変わると接続せずにお知らせします。",
|
||||
"작게": "小さく",
|
||||
"작업": "作業",
|
||||
"작업 기록 · 받다 만 파일": "作業記録・ダウンロード途中のファイル",
|
||||
|
|
@ -2085,6 +2113,7 @@
|
|||
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます。忘れたら最上位パスワードで初期化するか、保存したパスワードを削除して初期化できます。",
|
||||
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "保存したパスワード・設定を使うときにもう尋ねません。",
|
||||
"저장된 비밀번호가 없습니다": "保存されたパスワードがありません",
|
||||
"저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요": "保存されたパスワードがありません。サーバー設定でパスワードを入力してください",
|
||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "保存されたパスワードがありません。もう一度だけ入力してください",
|
||||
"저장된 비밀번호를 되살리지 못했습니다": "保存されたパスワードを復元できませんでした",
|
||||
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "保存したパスワードを使う機能・設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。",
|
||||
|
|
@ -2171,6 +2200,7 @@
|
|||
"준비 중…": "準備中…",
|
||||
"준비하는 중": "準備中",
|
||||
"줄 삭제": "行を削除",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "中間者攻撃の可能性があるため接続しませんでした。サーバー証明書を変えたことが確かな場合だけ、サーバー設定の [信頼した証明書を削除] の後に再接続してください。",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "中間者攻撃の可能性があるため接続しませんでした。サーバーを再インストールした、または鍵を変更したことが確かな場合のみ、サーバー設定の [信頼した鍵を削除] の後に再接続して新しい鍵を確認してください。",
|
||||
"중국어": "中国語",
|
||||
"중국어 간체": "中国語(簡体字)",
|
||||
|
|
@ -2287,6 +2317,8 @@
|
|||
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
|
||||
"처리 장치": "処理装置",
|
||||
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "最初・🏠 のアドレス (既定: Tor 接続確認ページ)",
|
||||
"처음 보는 서버 인증서입니다": "初めて見るサーバー証明書です",
|
||||
"처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "初めて見るサーバー証明書です (自己署名)。指紋を確認してから信頼するか決めてください",
|
||||
"처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}",
|
||||
"처음 시작 시": "起動時",
|
||||
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
" 외 {0}개": " 等 {0} 个",
|
||||
" 지금 설치됨 ({0}MB)": " 已安装({0}MB)",
|
||||
"\"{0}\" 검색 결과 {1}개": "“{0}”的搜索结果 {1} 个",
|
||||
"\"{0}\" 서버의 인증서가 바뀌었습니다": "服务器\"{0}\"的证书已更改",
|
||||
"\"{0}\" 서버의 키가 바뀌었습니다": "服务器\"{0}\"的密钥已更改",
|
||||
"\"{0}\" 안에 만듭니다": "在“{0}”中创建",
|
||||
"\"{0}\" 에 맞는 항목이 없습니다": "没有与“{0}”匹配的项目",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"\"{0}\" 을(를) 지울까요? 되돌릴 수 없습니다.": "删除\"{0}\"吗? 此操作无法撤销。",
|
||||
"\"{0}\" 을(를) 휴지통으로 보낼까요?": "将“{0}”移到回收站吗?",
|
||||
"\"{0}\" 의 이름을 바꾸지 못해 멈췄습니다: {1}": "无法重命名“{0}”,已停止: {1}",
|
||||
"\"{0}\" 의 인증서를 처음 봅니다": "首次看到\"{0}\"的证书",
|
||||
"\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "“{0}”为空,同步后会删除目标“{1}”中的以下所有项目 (无法撤销)。仅在你有意清空源文件夹时按下。",
|
||||
"\"{0}\" 이동": "移动“{0}”",
|
||||
"\"{0}\" 찾기 · {1}개": "搜索\"{0}\" · {1}个",
|
||||
|
|
@ -191,6 +193,14 @@
|
|||
"Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows 自带,无需另外安装。在此登录 YouTube 后,下载也会使用该登录。\nChrome: 选择后会下载约 {0}MB,重新启动后可用(Google 登录可能被阻止)。{1}{2}",
|
||||
"FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "如缺少 FFmpeg · yt-dlp · aria2 · Deno,将下载并安装",
|
||||
"FFmpeg 준비됨: {0}{1}": "FFmpeg 已就绪: {0}{1}",
|
||||
"FTP 서버 \"{0}\" 을(를) 지울까요?": "删除 FTP 服务器\"{0}\"?",
|
||||
"FTP 서버 고치기": "编辑 FTP 服务器",
|
||||
"FTP 서버 설정이 없습니다 ({0})": "没有 FTP 服务器设置 ({0})",
|
||||
"FTP 서버 주소가 없습니다": "FTP 服务器地址为空",
|
||||
"FTP 서버 추가": "添加 FTP 服务器",
|
||||
"FTP 서버의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 처음 값은 암호화하는 FTPS 이고, 비밀번호는 이 기기의 안전 저장소에만 둡니다.": "在文件管理器和 Rsync 界面中打开 FTP 服务器的文件夹进行复制和同步 (顶部标签)。默认使用加密的 FTPS,密码仅保存在本设备的安全存储中。",
|
||||
"FTPS (명시, AUTH TLS)": "FTPS (显式, AUTH TLS)",
|
||||
"FTPS (암시, 보통 990)": "FTPS (隐式, 通常 990)",
|
||||
"GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). 바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다": "从 GitHub 上发布的所有版本中选择安装 (更新 · 回退到以前正常的版本)。切换前版本的设置会被保存,回到该版本时恢复",
|
||||
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
|
||||
"HTML 파일로 내보내기": "导出为 HTML 文件",
|
||||
|
|
@ -587,6 +597,8 @@
|
|||
"⚠ 동기화 멈춤 {0}개": "⚠ {0} 个同步已停止",
|
||||
"⚠ 동기화 멈춤 {0}개 · 원본 확인 필요": "⚠ {0} 个同步已停止 · 请检查源",
|
||||
"⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). 그 전의 기록은 Logs 폴더의 app.log 에 있습니다.": "⚠ 上次运行未正常结束(开始 {0},最后检查 {1})。之前的记录在 Logs 文件夹的 app.log 中。",
|
||||
"⚠ 평문 FTP (암호화 안 됨)": "⚠ 明文 FTP (未加密)",
|
||||
"⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.": "⚠ 明文 FTP 会以未加密方式传输用户名、密码和文件,同一网络中的其他人可能看到。仅在服务器不支持 FTPS 时、并且只在可信的家庭网络中使用。",
|
||||
"가로 16:9": "横屏 16:9",
|
||||
"가로 고정": "固定横屏",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)",
|
||||
|
|
@ -1163,6 +1175,9 @@
|
|||
"미지정": "未指定",
|
||||
"믿고 연결": "信任并连接",
|
||||
"믿은 서버 키": "已信任的服务器密钥",
|
||||
"믿은 인증서": "已信任的证书",
|
||||
"믿은 인증서 지우기": "删除已信任的证书",
|
||||
"믿은 인증서를 지웠습니다. [연결 확인] 으로 새 인증서를 확인하세요": "已删除已信任的证书。请用 [测试连接] 确认新证书",
|
||||
"믿은 키 지우기": "清除已信任的密钥",
|
||||
"믿은 키를 지웠습니다. [연결 확인] 으로 새 키를 확인하세요": "已清除信任的密钥。请用 [测试连接] 确认新密钥",
|
||||
"바깥 폴더 (../)": "上级文件夹 (../)",
|
||||
|
|
@ -1400,11 +1415,15 @@
|
|||
"색온도": "色温",
|
||||
"서버 ({0}) 가 답하지 않습니다": "服务器 ({0}) 没有响应",
|
||||
"서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "无法连接服务器 ({0})。请确认服务器已开启;如果不在家,请确认 Tailscale 已开启",
|
||||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开",
|
||||
"서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)",
|
||||
"서버 설정 고치기": "修改服务器设置",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置以及本设备保存的密码、私钥和已信任的服务器密钥。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加前无法连接。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "删除服务器设置以及本设备上保存的密码和已信任的证书。服务器上的文件不受影响。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置和本设备上保存的密码。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加之前无法连接。",
|
||||
"서버 인증서가 바뀌었습니다": "服务器证书已更改",
|
||||
"서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다": "服务器证书已更改。可能连接到了其他服务器",
|
||||
"서버 인증서를 확인할 수 없습니다": "无法验证服务器证书",
|
||||
"서버 저장 공간 부족": "服务器存储空间不足",
|
||||
"서버 주소": "服务器地址",
|
||||
|
|
@ -1415,6 +1434,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片",
|
||||
"서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"",
|
||||
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
||||
"서버에 닿을 수 없음": "主机不可达",
|
||||
"서버에 닿지 않습니다": "无法连接服务器",
|
||||
|
|
@ -1422,6 +1442,7 @@
|
|||
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。",
|
||||
"서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "无法与服务器协商加密方式。如果是旧服务器,请在服务器设置中开启\"允许旧的 ssh-rsa\"",
|
||||
"서버와 암호 방식을 맞추지 못했습니다: {0}": "无法与服务器协商加密方式: {0}",
|
||||
"서버의 자료 연결 응답을 읽지 못했습니다: {0}": "无法读取服务器的数据连接响应:{0}",
|
||||
"서버의 키 지문이 서버 (NAS 설정 · ssh-keygen -lf) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 키가 바뀌면 연결하지 않고 알립니다.": "请确认服务器的密钥指纹与服务器端 (NAS 设置 · ssh-keygen -lf) 显示的一致。信任后会保存在本设备上,之后密钥若有变化将不连接并提示。",
|
||||
"서버의 키가 바뀌었습니다": "服务器的密钥已更改",
|
||||
"서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "服务器的密钥已更改。可能是中间人攻击,因此未连接。之前: {0} · 现在: {1}",
|
||||
|
|
@ -1512,6 +1533,7 @@
|
|||
"시작 메뉴에 등록": "添加到开始菜单",
|
||||
"시작 안 함 (모니터링에서 시작)": "不开始 (在监控中开始)",
|
||||
"시작 폴더 (공유 안, 비우면 맨 위)": "起始文件夹 (共享内,留空则为最上层)",
|
||||
"시작 폴더 (비우면 로그인한 곳)": "起始文件夹 (留空则为登录位置)",
|
||||
"시작 폴더 (비우면 홈 폴더)": "起始文件夹 (留空则为主目录)",
|
||||
"시작: {0} → {1}{2}": "开始: {0} → {1}{2}",
|
||||
"시작하는 중…": "正在开始…",
|
||||
|
|
@ -1559,6 +1581,7 @@
|
|||
"아무것도 안 함": "不执行任何操作",
|
||||
"아이디": "用户名",
|
||||
"아이디 (비우면 게스트)": "用户名 (留空则为访客)",
|
||||
"아이디 (비우면 익명)": "用户名 (留空则匿名)",
|
||||
"아이디 (선택)": "用户名(可选)",
|
||||
"아이디 · 비밀번호를 확인하세요": "请检查用户名和密码",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误",
|
||||
|
|
@ -1580,6 +1603,8 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호화": "加密",
|
||||
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "无法建立加密 (TLS) 连接:{0}",
|
||||
"암호화 안 됨 (http)": "未加密 (http)",
|
||||
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
|
||||
"압축 파일 안에 없습니다": "压缩文件中没有",
|
||||
|
|
@ -1652,6 +1677,7 @@
|
|||
"연결 안 됨": "未连接",
|
||||
"연결 확인": "测试连接",
|
||||
"연결됨": "已连接",
|
||||
"연결됨: 항목 {0}개": "已连接:{0} 个项目",
|
||||
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
||||
"연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)",
|
||||
"연결이 끊겼습니다": "连接已断开",
|
||||
|
|
@ -1963,6 +1989,7 @@
|
|||
"인도네시아어": "印尼语",
|
||||
"인식된 말이 없습니다.": "未识别到语音。",
|
||||
"인증서 확인 안 함": "不验证证书",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "证书:公共信任的证书直接连接;自签名证书会在 [测试连接] 时显示指纹并询问",
|
||||
"인코딩": "编码",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "请检查网络连接或 设置 > 网络 · DNS 中的服务器地址。",
|
||||
|
|
@ -2033,6 +2060,7 @@
|
|||
"자세히": "详情",
|
||||
"자세히 닫기": "收起详情",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "用于使用自签名证书的家用 NAS 等 (仅限可信服务器)",
|
||||
"자체 서명 인증서입니다. 지문이 서버 (NAS 설정) 에서 본 것과 같은지 확인하세요. 믿으면 이 기기에 저장하고, 다음부터 인증서가 바뀌면 연결하지 않고 알립니다.": "这是自签名证书。请确认指纹与服务器 (NAS 设置) 上显示的一致。信任后会保存在本设备上,以后证书若有变化将不连接并提示。",
|
||||
"작게": "小",
|
||||
"작업": "任务",
|
||||
"작업 기록 · 받다 만 파일": "操作记录 · 未下载完的文件",
|
||||
|
|
@ -2085,6 +2113,7 @@
|
|||
"저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "使用已保存的密码 · 设置 · 需要密码的功能时询问。忘记时可用最高密码重置,或删除已保存的密码后重置。",
|
||||
"저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "使用已保存的密码 · 设置时将不再询问。",
|
||||
"저장된 비밀번호가 없습니다": "没有保存的密码",
|
||||
"저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요": "没有保存的密码。请在服务器设置中输入密码",
|
||||
"저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "没有保存的密码。请再输入一次",
|
||||
"저장된 비밀번호를 되살리지 못했습니다": "无法恢复已保存的密码",
|
||||
"저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要使用保存了密码的功能或打开设置,请输入主密码。在关闭应用前不会再次询问。",
|
||||
|
|
@ -2171,6 +2200,7 @@
|
|||
"준비 중…": "准备中…",
|
||||
"준비하는 중": "正在准备",
|
||||
"줄 삭제": "删除行",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "可能是中间人攻击,因此未连接。仅当确定服务器证书已更换时,在服务器设置中 [删除已信任的证书] 后重新连接。",
|
||||
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버를 다시 설치했거나 키를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 키 지우기] 뒤 다시 연결해 새 키를 확인하세요.": "可能是中间人攻击,因此未连接。仅当确定服务器已重装或更换了密钥时,请在服务器设置中 [清除已信任的密钥] 后重新连接并确认新密钥。",
|
||||
"중국어": "中文",
|
||||
"중국어 간체": "简体中文",
|
||||
|
|
@ -2287,6 +2317,8 @@
|
|||
"채우기 (가운데 잘라냄)": "填充(裁切中间)",
|
||||
"처리 장치": "处理器",
|
||||
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "起始 · 🏠 地址 (默认: Tor 连接检查页面)",
|
||||
"처음 보는 서버 인증서입니다": "这是首次见到的服务器证书",
|
||||
"처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요": "这是首次见到的服务器证书 (自签名)。请确认指纹后决定是否信任",
|
||||
"처음 사용 시 내려받기 {0}": "首次使用时下载 {0}",
|
||||
"처음 시작 시": "启动时",
|
||||
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../core/mkv_command_builder.dart';
|
|||
import '../core/vfs.dart';
|
||||
import '../core/sftp.dart' show SftpRegistry;
|
||||
import '../core/smb.dart' show SmbRegistry;
|
||||
import '../core/ftp.dart' show FtpRegistry;
|
||||
import '../core/webdav.dart';
|
||||
import '../core/models.dart';
|
||||
import '../core/output_paths.dart';
|
||||
|
|
@ -157,6 +158,7 @@ class AppController extends ChangeNotifier {
|
|||
SftpRegistry.configure(settings.sftpServers);
|
||||
// 88: Android SMB 서버 (smb:// 경로)
|
||||
SmbRegistry.configure(settings.smbServers);
|
||||
FtpRegistry.configure(settings.ftpServers);
|
||||
outputRootOverride =
|
||||
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../core/dns_resolver.dart';
|
|||
import '../core/download_detect.dart';
|
||||
import '../core/encode_options.dart';
|
||||
import '../core/playlist.dart';
|
||||
import '../core/ftp.dart';
|
||||
import '../core/sftp.dart';
|
||||
import '../core/smb.dart';
|
||||
import '../core/webdav.dart';
|
||||
|
|
@ -560,6 +561,9 @@ class AppSettings {
|
|||
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
|
||||
List<SmbServer> smbServers = [];
|
||||
|
||||
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
|
||||
List<FtpServer> ftpServers = [];
|
||||
|
||||
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
|
||||
|
||||
/// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스)
|
||||
|
|
@ -842,6 +846,7 @@ class AppSettings {
|
|||
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
|
||||
'sftpServers': [for (final x in sftpServers) x.toJson()],
|
||||
'smbServers': [for (final x in smbServers) x.toJson()],
|
||||
'ftpServers': [for (final x in ftpServers) x.toJson()],
|
||||
'rsyncOptions': rsyncOptions,
|
||||
'robocopyOptions': robocopyOptions,
|
||||
'copyRunMode': copyRunMode,
|
||||
|
|
@ -1092,6 +1097,10 @@ class AppSettings {
|
|||
for (final x in (j['smbServers'] as List?) ?? const [])
|
||||
if (x is Map) SmbServer.fromJson(x),
|
||||
]
|
||||
..ftpServers = [
|
||||
for (final x in (j['ftpServers'] as List?) ?? const [])
|
||||
if (x is Map) FtpServer.fromJson(x),
|
||||
]
|
||||
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
|
||||
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
|
||||
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
|
||||
|
|
@ -1173,6 +1182,12 @@ class AppSettings {
|
|||
for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x,
|
||||
];
|
||||
}
|
||||
final ftp = r['ftpServers'];
|
||||
if (ftp is List) {
|
||||
r['ftpServers'] = [
|
||||
for (final x in ftp) x is Map ? (Map.of(x)..remove('password')) : x,
|
||||
];
|
||||
}
|
||||
final ai = r['aiServices'];
|
||||
if (ai is List) {
|
||||
r['aiServices'] = [
|
||||
|
|
@ -1294,6 +1309,7 @@ class SettingsStore {
|
|||
static String _sftpKey(String id) => 'sftp.key.$id';
|
||||
static String _sftpKp(String id) => 'sftp.kp.$id';
|
||||
static String _smbPw(String id) => 'smb.pw.$id';
|
||||
static String _ftpPw(String id) => 'ftp.pw.$id';
|
||||
static String _aiKey(String id) => 'ai.key.$id';
|
||||
static const _davServers = 'dav.servers';
|
||||
|
||||
|
|
@ -1318,6 +1334,8 @@ class SettingsStore {
|
|||
},
|
||||
for (final x in s.smbServers)
|
||||
if (x.password.isNotEmpty) _smbPw(x.id): x.password,
|
||||
for (final x in s.ftpServers)
|
||||
if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
|
||||
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40)
|
||||
if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]),
|
||||
};
|
||||
|
|
@ -1370,6 +1388,9 @@ class SettingsStore {
|
|||
s.smbServers = [
|
||||
for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
|
||||
];
|
||||
s.ftpServers = [
|
||||
for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54).
|
||||
|
|
@ -1574,7 +1595,7 @@ class SettingsStore {
|
|||
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
||||
final unsaved = <String>{};
|
||||
for (final k in {..._known.keys, ...values.keys}) {
|
||||
if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.')) continue;
|
||||
if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.') && !k.startsWith('ftp.')) continue;
|
||||
final v = values[k];
|
||||
if (v == _known[k]) continue;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,678 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' show sha256;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'dns_resolver.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'secret_gate.dart';
|
||||
import 'webdav.dart' show DavException, DavItem, DavLockedException;
|
||||
|
||||
/// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음).
|
||||
/// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고).
|
||||
/// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단).
|
||||
class FtpServer {
|
||||
const FtpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 21,
|
||||
this.user = '',
|
||||
this.root = '',
|
||||
this.password = '',
|
||||
this.security = 'explicit',
|
||||
this.certFingerprint = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String user;
|
||||
|
||||
/// 서버의 시작 폴더 (비우면 로그인한 곳)
|
||||
final String root;
|
||||
|
||||
/// 안전 저장소에서 채움 (설정 파일에는 없음)
|
||||
final String password;
|
||||
final String security;
|
||||
|
||||
/// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님)
|
||||
final String certFingerprint;
|
||||
|
||||
bool get tls => security != 'none';
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim());
|
||||
|
||||
FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) =>
|
||||
FtpServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
user: user ?? this.user,
|
||||
root: root ?? this.root,
|
||||
password: password ?? this.password,
|
||||
security: security ?? this.security,
|
||||
certFingerprint: certFingerprint ?? this.certFingerprint,
|
||||
);
|
||||
|
||||
/// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'root': root,
|
||||
'hasPassword': password.isNotEmpty,
|
||||
'security': security,
|
||||
'certFingerprint': certFingerprint,
|
||||
};
|
||||
|
||||
factory FtpServer.fromJson(Map<Object?, Object?> j) => FtpServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
host: j['host'] as String? ?? '',
|
||||
port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 },
|
||||
user: j['user'] as String? ?? '',
|
||||
root: j['root'] as String? ?? '',
|
||||
// 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선")
|
||||
security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit',
|
||||
certFingerprint: j['certFingerprint'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other }
|
||||
|
||||
class FtpException extends DavException {
|
||||
final FtpFailure kind;
|
||||
const FtpException(super.message, this.kind);
|
||||
}
|
||||
|
||||
/// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장
|
||||
class FtpCertException extends FtpException {
|
||||
final String fingerprint;
|
||||
final String subject;
|
||||
|
||||
/// 바뀐 경우 예전에 믿은 지문
|
||||
final String? previous;
|
||||
const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous});
|
||||
bool get changed => previous != null;
|
||||
}
|
||||
|
||||
/// 인증서 지문 "SHA256:AB:CD:…"
|
||||
String certSha256(X509Certificate c) =>
|
||||
'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}';
|
||||
|
||||
/// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김)
|
||||
typedef FtpResolve = Future<InternetAddress?> Function(String host);
|
||||
|
||||
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
|
||||
class _Session {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
final InternetAddress addr;
|
||||
RawSocket _raw;
|
||||
StreamSubscription<RawSocketEvent>? _sub;
|
||||
final _buf = <int>[];
|
||||
final _lines = <String>[];
|
||||
Completer<void>? _wake;
|
||||
bool _closed = false;
|
||||
bool secure = false;
|
||||
bool mlsd = false;
|
||||
bool _protP = false;
|
||||
|
||||
/// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위)
|
||||
String home = '/';
|
||||
|
||||
_Session._(this.server, this.timeout, this.addr, this._raw);
|
||||
|
||||
static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async {
|
||||
final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first;
|
||||
final raw = await RawSocket.connect(addr, s.port, timeout: timeout);
|
||||
final x = _Session._(s, timeout, addr, raw);
|
||||
if (s.security == 'implicit') {
|
||||
x._raw = await x._secure(raw, null);
|
||||
x.secure = true;
|
||||
}
|
||||
x._listen();
|
||||
return x;
|
||||
}
|
||||
|
||||
void _listen() {
|
||||
_sub = _raw.listen((ev) {
|
||||
if (ev == RawSocketEvent.read) {
|
||||
final d = _raw.read();
|
||||
if (d != null) _feed(d);
|
||||
} else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
}
|
||||
}, onError: (_) {
|
||||
_closed = true;
|
||||
_poke();
|
||||
});
|
||||
}
|
||||
|
||||
void _feed(Uint8List d) {
|
||||
_buf.addAll(d);
|
||||
while (true) {
|
||||
final i = _buf.indexOf(10);
|
||||
if (i < 0) break;
|
||||
var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true);
|
||||
if (line.endsWith('\r')) line = line.substring(0, line.length - 1);
|
||||
_buf.removeRange(0, i + 1);
|
||||
debugLog?.call('< $line');
|
||||
_lines.add(line);
|
||||
}
|
||||
_poke();
|
||||
}
|
||||
|
||||
void _poke() {
|
||||
final w = _wake;
|
||||
_wake = null;
|
||||
w?.complete();
|
||||
}
|
||||
|
||||
Future<String> _line() async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (_lines.isEmpty) {
|
||||
if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable);
|
||||
final left = end.difference(DateTime.now());
|
||||
if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다'));
|
||||
_wake = Completer<void>();
|
||||
await _wake!.future.timeout(left, onTimeout: () {});
|
||||
}
|
||||
return _lines.removeAt(0);
|
||||
}
|
||||
|
||||
/// 응답 하나 (여러 줄이면 합침): (코드, 글)
|
||||
Future<(int, String)> reply() async {
|
||||
final first = await _line();
|
||||
final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0;
|
||||
final text = StringBuffer(first.length > 4 ? first.substring(4) : '');
|
||||
if (first.length > 3 && first[3] == '-') {
|
||||
while (true) {
|
||||
final l = await _line();
|
||||
if (l.startsWith('$code ')) {
|
||||
text.write('\n${l.substring(4)}');
|
||||
break;
|
||||
}
|
||||
text.write('\n${l.startsWith(' ') ? l.substring(1) : l}');
|
||||
}
|
||||
}
|
||||
return (code, text.toString());
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림)
|
||||
static void Function(String line)? debugLog;
|
||||
|
||||
void send(String cmd) {
|
||||
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
|
||||
_raw.write(utf8.encode('$cmd\r\n'));
|
||||
}
|
||||
|
||||
Future<(int, String)> cmd(String c) async {
|
||||
send(c);
|
||||
return reply();
|
||||
}
|
||||
|
||||
/// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException]
|
||||
Future<String> must(String c, {bool Function(int code)? ok, String? path}) async {
|
||||
final (code, text) = await cmd(c);
|
||||
if (ok != null ? ok(code) : code >= 200 && code < 300) return text;
|
||||
throw failure(code, text, path);
|
||||
}
|
||||
|
||||
FtpException failure(int code, String text, String? path) {
|
||||
if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth);
|
||||
if (code == 550 || code == 450) {
|
||||
final t = text.toLowerCase();
|
||||
if (t.contains('permission') || t.contains('denied') || t.contains('access')) {
|
||||
return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission);
|
||||
}
|
||||
return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound);
|
||||
}
|
||||
return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만
|
||||
Future<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? sub) async {
|
||||
X509Certificate? bad;
|
||||
try {
|
||||
return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) {
|
||||
bad = c;
|
||||
return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint;
|
||||
}).timeout(timeout);
|
||||
} on HandshakeException {
|
||||
final c = bad;
|
||||
if (c == null) rethrow;
|
||||
final fp = certSha256(c);
|
||||
throw FtpCertException(
|
||||
server.certFingerprint.isEmpty
|
||||
? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요')
|
||||
: tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'),
|
||||
server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged,
|
||||
fingerprint: fp,
|
||||
subject: c.subject,
|
||||
previous: server.certFingerprint.isEmpty ? null : server.certFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더
|
||||
Future<void> login() async {
|
||||
final (hello, text) = await reply();
|
||||
if (hello != 220) throw failure(hello, text, null);
|
||||
if (server.security == 'explicit') {
|
||||
final (code, t) = await cmd('AUTH TLS');
|
||||
if (code != 234) {
|
||||
// FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야)
|
||||
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
|
||||
FtpFailure.noTls);
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
_raw = await _secure(_raw, sub);
|
||||
secure = true;
|
||||
// RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다
|
||||
_listen();
|
||||
}
|
||||
if (secure) {
|
||||
await must('PBSZ 0');
|
||||
await must('PROT P');
|
||||
_protP = true;
|
||||
}
|
||||
if (server.user.isNotEmpty || server.password.isNotEmpty) {
|
||||
final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}');
|
||||
if (code == 331 || code == 332) {
|
||||
await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202);
|
||||
} else if (code != 230) {
|
||||
throw failure(code, t, null);
|
||||
}
|
||||
} else {
|
||||
await must('USER anonymous', ok: (c) => c == 230 || c == 331);
|
||||
}
|
||||
final feat = await cmd('FEAT');
|
||||
// MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다)
|
||||
mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase());
|
||||
if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON');
|
||||
await must('TYPE I');
|
||||
final pwd = await cmd('PWD');
|
||||
final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2);
|
||||
if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"');
|
||||
}
|
||||
|
||||
/// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기)
|
||||
Future<Socket> data() async {
|
||||
int? port;
|
||||
final (c1, t1) = await cmd('EPSV');
|
||||
if (c1 == 229) {
|
||||
final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1);
|
||||
if (m != null) port = int.parse(m[1]!);
|
||||
}
|
||||
if (port == null) {
|
||||
final t = await must('PASV', ok: (c) => c == 227);
|
||||
final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t);
|
||||
if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other);
|
||||
port = int.parse(m[5]!) * 256 + int.parse(m[6]!);
|
||||
}
|
||||
return Socket.connect(addr, port, timeout: timeout);
|
||||
}
|
||||
|
||||
/// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등)
|
||||
Future<Socket> secureData(Socket sock) async {
|
||||
if (!_protP) return sock;
|
||||
final x = await SecureSocket.secure(sock,
|
||||
host: server.host.trim(),
|
||||
onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout);
|
||||
return x;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
send('QUIT');
|
||||
} catch (_) {}
|
||||
_sub?.cancel();
|
||||
_raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를
|
||||
/// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다.
|
||||
class FtpRemote implements RemoteClient {
|
||||
final FtpServer server;
|
||||
final Duration timeout;
|
||||
FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)});
|
||||
|
||||
Future<_Session>? _main;
|
||||
Future<void> _lock = Future.value();
|
||||
|
||||
Future<_Session> _connect() async {
|
||||
final s = server;
|
||||
if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other);
|
||||
if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (s.user.isNotEmpty && s.password.isEmpty) {
|
||||
throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret);
|
||||
}
|
||||
_Session? x;
|
||||
try {
|
||||
x = await _Session.open(s, timeout, FtpRegistry.resolve);
|
||||
await x.login();
|
||||
return x;
|
||||
} catch (e) {
|
||||
x?.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
}
|
||||
|
||||
Object _explain(Object e) {
|
||||
if (e is FtpException || e is DavLockedException) return e;
|
||||
if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable);
|
||||
if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable);
|
||||
if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other);
|
||||
return FtpException('$e', FtpFailure.other);
|
||||
}
|
||||
|
||||
/// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결
|
||||
Future<T> _run<T>(Future<T> Function(_Session s) f) {
|
||||
final prev = _lock;
|
||||
final done = Completer<void>();
|
||||
_lock = done.future;
|
||||
return prev.then((_) async {
|
||||
try {
|
||||
for (var attempt = 0;; attempt++) {
|
||||
final _Session s;
|
||||
try {
|
||||
s = await (_main ??= _connect());
|
||||
} catch (_) {
|
||||
_main = null;
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
return await f(s);
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.unreachable && attempt == 0) {
|
||||
s.close();
|
||||
_main = null;
|
||||
continue;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is! FtpException && e is! DavLockedException) {
|
||||
_main = null;
|
||||
throw _explain(e);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
done.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로
|
||||
String _abs(_Session s, String rel) {
|
||||
var base = server.root.trim().isEmpty ? s.home : server.root.trim();
|
||||
if (!base.startsWith('/')) base = '/$base';
|
||||
if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1);
|
||||
final r = rel == '/' || rel.isEmpty ? '' : rel;
|
||||
if (base == '/') return r.isEmpty ? '/' : r;
|
||||
return '$base$r';
|
||||
}
|
||||
|
||||
static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name';
|
||||
static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'));
|
||||
|
||||
/// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름"
|
||||
static (String, bool, int, DateTime)? parseMlsx(String line) {
|
||||
final sp = line.indexOf(' ');
|
||||
if (sp < 0) return null;
|
||||
final facts = <String, String>{};
|
||||
for (final f in line.substring(0, sp).split(';')) {
|
||||
final eq = f.indexOf('=');
|
||||
if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1);
|
||||
}
|
||||
final name = line.substring(sp + 1);
|
||||
final type = (facts['type'] ?? '').toLowerCase();
|
||||
if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null;
|
||||
final m = facts['modify'] ?? '';
|
||||
var when = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
if (m.length >= 14) {
|
||||
when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)),
|
||||
int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14)));
|
||||
}
|
||||
final isDir = type == 'dir';
|
||||
return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when);
|
||||
}
|
||||
|
||||
static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
|
||||
/// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버)
|
||||
static (String, bool, int, DateTime)? parseList(String line) {
|
||||
final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line);
|
||||
if (m == null) return null;
|
||||
var name = m[6]!;
|
||||
if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> '));
|
||||
if (name == '.' || name == '..') return null;
|
||||
final mon = _months.indexOf(m[3]!.toLowerCase()) + 1;
|
||||
final now = DateTime.now();
|
||||
final t = m[5]!;
|
||||
final when = t.contains(':')
|
||||
? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1]))
|
||||
: DateTime(int.parse(t), mon, int.parse(m[4]!));
|
||||
final isDir = m[1] == 'd';
|
||||
return (name, isDir, isDir ? 0 : int.parse(m[2]!), when);
|
||||
}
|
||||
|
||||
/// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다
|
||||
Future<List<(String, bool, int, DateTime)>> _listRaw(_Session s, String abs, String rel) async {
|
||||
final plain = await s.data();
|
||||
// LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다
|
||||
s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
final bytes = await sock.fold<List<int>>(<int>[], (a, b) => a..addAll(b));
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
final out = <(String, bool, int, DateTime)>[];
|
||||
for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) {
|
||||
final x = s.mlsd ? parseMlsx(l) : parseList(l);
|
||||
if (x != null) out.add(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DavItem>> list(String rel) => _run((s) async => [
|
||||
for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0));
|
||||
// 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게)
|
||||
try {
|
||||
for (final x in await list(_parent(rel))) {
|
||||
if (x.rel == rel) return x;
|
||||
}
|
||||
} on FtpException catch (e) {
|
||||
if (e.kind == FtpFailure.notFound) return null;
|
||||
rethrow;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> mkdirs(String rel) async {
|
||||
if (rel == '/' || rel.isEmpty) return;
|
||||
if (await stat(rel) != null) return;
|
||||
await mkdirs(_parent(rel));
|
||||
await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
if (st == null) return;
|
||||
if (st.isDir) {
|
||||
for (final x in await list(rel)) {
|
||||
await delete(x.rel);
|
||||
}
|
||||
await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel));
|
||||
} else {
|
||||
await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) async {
|
||||
if (await stat(to) != null) {
|
||||
if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other);
|
||||
await delete(to);
|
||||
}
|
||||
await _run((s) async {
|
||||
await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from);
|
||||
await s.must('RNTO ${_abs(s, to)}', path: to);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
|
||||
|
||||
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기)
|
||||
Future<Stream<List<int>>> openReadFrom(String rel, {int offset = 0, int? length}) async {
|
||||
final s = await _connect();
|
||||
Socket? sock;
|
||||
try {
|
||||
sock = await s.data();
|
||||
if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel);
|
||||
s.send('RETR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) throw s.failure(code, text, rel);
|
||||
sock = await s.secureData(sock);
|
||||
} catch (e) {
|
||||
sock?.destroy();
|
||||
s.close();
|
||||
throw _explain(e);
|
||||
}
|
||||
final data = sock;
|
||||
Stream<List<int>> read() async* {
|
||||
var left = length;
|
||||
try {
|
||||
await for (var chunk in data) {
|
||||
if (left != null) {
|
||||
if (chunk.length >= left) {
|
||||
yield chunk.sublist(0, left);
|
||||
left = 0;
|
||||
break;
|
||||
}
|
||||
left -= chunk.length;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
if (left == null) {
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
}
|
||||
} finally {
|
||||
data.destroy();
|
||||
s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서)
|
||||
}
|
||||
}
|
||||
|
||||
return read();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final s = await _connect();
|
||||
try {
|
||||
final plain = await s.data();
|
||||
s.send('STOR ${_abs(s, rel)}');
|
||||
final (code, text) = await s.reply();
|
||||
if (code != 150 && code != 125) {
|
||||
plain.destroy();
|
||||
throw s.failure(code, text, rel);
|
||||
}
|
||||
final sock = await s.secureData(plain);
|
||||
await sock.addStream(data);
|
||||
await sock.flush();
|
||||
// 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다
|
||||
// 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어
|
||||
// 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다)
|
||||
await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock);
|
||||
await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {});
|
||||
final (end, et) = await s.reply();
|
||||
if (end != 226 && end != 250) throw s.failure(end, et, rel);
|
||||
} catch (e) {
|
||||
throw _explain(e);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP 는 남은 공간을 알려 주는 표준 명령이 없다
|
||||
@override
|
||||
Future<(int?, int?)> quota() async => (null, null);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
final m = _main;
|
||||
_main = null;
|
||||
m?.then((s) => s.close(), onError: (_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나)
|
||||
class FtpRegistry {
|
||||
static final _clients = <String, FtpRemote>{};
|
||||
static final _servers = <String, FtpServer>{};
|
||||
|
||||
/// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup)
|
||||
static FtpResolve resolve = (host) async => InternetAddress.tryParse(host);
|
||||
|
||||
/// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게
|
||||
static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async {
|
||||
final ip = InternetAddress.tryParse(host);
|
||||
if (ip != null) return ip;
|
||||
if (!resolver.config().forced || isLocalName(host)) return null;
|
||||
final addrs = await resolver.lookup(host);
|
||||
return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull;
|
||||
};
|
||||
|
||||
static void configure(List<FtpServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<FtpServer> get servers => _servers.values.toList();
|
||||
static FtpServer? server(String id) => _servers[id];
|
||||
|
||||
static FtpRemote client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other);
|
||||
return _clients.putIfAbsent(id, () => FtpRemote(s));
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험: 명령 채널 기록 (비밀번호 가림)
|
||||
void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f;
|
||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'ftp.dart';
|
||||
import 'remote_fs.dart';
|
||||
import 'sftp.dart';
|
||||
import 'smb.dart';
|
||||
|
|
@ -17,16 +18,21 @@ const sftpScheme = 'sftp://';
|
|||
/// 88: Android SMB (smbj) `smb://<서버 id>/<경로>` - Windows 는 운영체제의 \\서버\공유 를 그대로 쓴다
|
||||
const smbScheme = 'smb://';
|
||||
|
||||
/// l: FTP · FTPS `ftp://<서버 id>/<경로>` (dart:io 로 직접 - lib/core/ftp.dart)
|
||||
const ftpScheme = 'ftp://';
|
||||
|
||||
/// 원격 (WebDAV · SFTP · SMB) 경로인지. 이름은 예전 그대로 (원격이면 같은 길 - 복사 · 동기화 · 임시로 받아 열기)
|
||||
bool isDav(String path) => path.startsWith(davScheme) || path.startsWith(sftpScheme) || path.startsWith(smbScheme);
|
||||
bool isDav(String path) =>
|
||||
path.startsWith(davScheme) || path.startsWith(sftpScheme) || path.startsWith(smbScheme) || path.startsWith(ftpScheme);
|
||||
|
||||
/// WebDAV 만 (HTTP 로 바로 재생 · 서버 고치기 창 등 WebDAV 에만 있는 것)
|
||||
bool isWebDav(String path) => path.startsWith(davScheme);
|
||||
bool isSftp(String path) => path.startsWith(sftpScheme);
|
||||
bool isSmb(String path) => path.startsWith(smbScheme);
|
||||
bool isFtp(String path) => path.startsWith(ftpScheme);
|
||||
|
||||
/// 스트리밍 주소가 없는 원격 (SFTP · SMB) - 재생 · 다른 앱으로 열기는 받아서
|
||||
bool noStreamUrl(String path) => isSftp(path) || isSmb(path);
|
||||
bool noStreamUrl(String path) => isSftp(path) || isSmb(path) || isFtp(path);
|
||||
|
||||
/// 원격 경로: 서버 종류 ([scheme]) · 서버 id · 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/")
|
||||
class DavPath {
|
||||
|
|
@ -41,7 +47,9 @@ class DavPath {
|
|||
? sftpScheme
|
||||
: path.startsWith(smbScheme)
|
||||
? smbScheme
|
||||
: davScheme;
|
||||
: path.startsWith(ftpScheme)
|
||||
? ftpScheme
|
||||
: davScheme;
|
||||
var rest = path.substring(scheme.length).replaceAll('\\', '/');
|
||||
final slash = rest.indexOf('/');
|
||||
final server = slash < 0 ? rest : rest.substring(0, slash);
|
||||
|
|
@ -55,6 +63,7 @@ class DavPath {
|
|||
bool get isRoot => rel == '/';
|
||||
bool get isSftp => scheme == sftpScheme;
|
||||
bool get isSmb => scheme == smbScheme;
|
||||
bool get isFtp => scheme == ftpScheme;
|
||||
String get full => isRoot ? '$scheme$server/' : '$scheme$server$rel';
|
||||
String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1);
|
||||
DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')), scheme);
|
||||
|
|
@ -65,24 +74,37 @@ class DavPath {
|
|||
? SftpRegistry.client(server)
|
||||
: isSmb
|
||||
? SmbRegistry.client(server)
|
||||
: DavRegistry.client(server);
|
||||
: isFtp
|
||||
? FtpRegistry.client(server)
|
||||
: DavRegistry.client(server);
|
||||
|
||||
/// 서버 이름 (화면 표시)
|
||||
String get serverLabel => isSftp
|
||||
? SftpRegistry.server(server)?.label ?? 'SFTP'
|
||||
: isSmb
|
||||
? SmbRegistry.server(server)?.label ?? 'SMB'
|
||||
: DavRegistry.server(server)?.label ?? 'WebDAV';
|
||||
: isFtp
|
||||
? FtpRegistry.server(server)?.label ?? 'FTP'
|
||||
: DavRegistry.server(server)?.label ?? 'WebDAV';
|
||||
|
||||
/// 설정에 이 서버가 있는지 (지운 서버의 경로 · 즐겨찾기 등)
|
||||
bool get serverKnown => isSftp
|
||||
? SftpRegistry.server(server) != null
|
||||
: isSmb
|
||||
? SmbRegistry.server(server) != null
|
||||
: DavRegistry.server(server) != null;
|
||||
: isFtp
|
||||
? FtpRegistry.server(server) != null
|
||||
: DavRegistry.server(server) != null;
|
||||
|
||||
/// 화면 표시 아이콘 글자: WebDAV ☁ · SFTP 🔑 · SMB 🗄
|
||||
String get mark => isSftp ? '🔑' : isSmb ? '🗄' : '☁';
|
||||
/// l: FTP 📂 (평문 FTP 는 ⚠ - 암호화 안 됨)
|
||||
String get mark => isSftp
|
||||
? '🔑'
|
||||
: isSmb
|
||||
? '🗄'
|
||||
: isFtp
|
||||
? (FtpRegistry.server(server)?.tls ?? true ? '📂' : '⚠')
|
||||
: '☁';
|
||||
}
|
||||
|
||||
/// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더 (인증은 헤더로만 - 주소에 비밀번호를 넣지 않는다)
|
||||
|
|
@ -271,7 +293,7 @@ Future<String> vLocalCopy(String path, String tempDir, {void Function(int done,
|
|||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
final st = await d.client.stat(d.rel);
|
||||
final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
|
||||
final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : d.isFtp ? 'jj_ftp' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
|
||||
await dir.create(recursive: true);
|
||||
final out = File(p.join(dir.path, d.name));
|
||||
// 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import 'app/settings.dart';
|
|||
import 'core/app_update.dart';
|
||||
import 'core/playlist.dart';
|
||||
import 'core/sftp.dart' show SftpRegistry;
|
||||
import 'core/ftp.dart' show FtpRegistry;
|
||||
import 'platform/android/android_download_tools.dart';
|
||||
import 'platform/android/android_keep_alive.dart';
|
||||
import 'platform/android/android_shell.dart' show applyScreenOrientation;
|
||||
|
|
@ -472,6 +473,7 @@ Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}
|
|||
HttpOverrides.global = JjHttpOverrides();
|
||||
// 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS)
|
||||
SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver);
|
||||
FtpRegistry.resolve = FtpRegistry.resolveWithDns(g.resolver);
|
||||
if (second) return;
|
||||
TorService.dataDir = dataDir;
|
||||
TorService.instance.log = c.note;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import '../core/file_ops.dart';
|
|||
import '../core/playlist.dart' show isAudioFile, isVideoFile;
|
||||
import '../core/vfs.dart';
|
||||
import '../core/sftp.dart' show SftpHostKeyException, SftpRegistry;
|
||||
import '../core/ftp.dart' show FtpCertException, FtpRegistry;
|
||||
import '../core/smb.dart' show SmbRegistry;
|
||||
import '../core/webdav.dart' show DavRegistry;
|
||||
import '../platform/android/android_storage.dart';
|
||||
|
|
@ -38,6 +39,7 @@ import '../core/sync_preview.dart';
|
|||
import 'path_label.dart';
|
||||
import 'sync_preview_view.dart';
|
||||
import 'sftp_settings.dart';
|
||||
import 'ftp_settings.dart';
|
||||
import 'smb_settings.dart';
|
||||
import 'webdav_settings.dart';
|
||||
import 'reader_page.dart';
|
||||
|
|
@ -230,6 +232,9 @@ class _Pane extends ChangeNotifier {
|
|||
|
||||
/// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결]
|
||||
final hostKeyErrors = <String, SftpHostKeyException>{};
|
||||
|
||||
/// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다
|
||||
final certErrors = <String, FtpCertException>{};
|
||||
final marked = <String>{};
|
||||
|
||||
/// 74: 범위 고르기의 시작 (마지막으로 표시한 것)
|
||||
|
|
@ -328,6 +333,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label),
|
||||
// 88: SMB 서버 (Android)
|
||||
for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label),
|
||||
// l: FTP · FTPS 서버 (평문 FTP 는 이름 앞에 ⚠)
|
||||
for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
|
||||
];
|
||||
late final List<_Pane> _panes = [_Pane(''), _Pane('')];
|
||||
int _active = 0;
|
||||
|
|
@ -645,6 +652,31 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
|
||||
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
|
||||
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
|
||||
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
|
||||
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
|
||||
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
|
||||
if (ftp != null && cert != null) {
|
||||
return _notice(
|
||||
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
|
||||
title: '${_displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
|
||||
body: cert.message,
|
||||
actions: [
|
||||
if (!cert.changed)
|
||||
FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
|
||||
await trustFtpCert(c, ftp.id, cert);
|
||||
pane.certErrors.remove(where);
|
||||
pane.errors.remove(where);
|
||||
pane.changed();
|
||||
await _load(pane, where, force: true);
|
||||
},
|
||||
child: Text(tr('지문 확인하고 연결')),
|
||||
),
|
||||
TextButton(onPressed: () => _editFtp(pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
||||
],
|
||||
);
|
||||
}
|
||||
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
|
||||
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
|
||||
if (sftp != null && hostKey != null) {
|
||||
|
|
@ -673,7 +705,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
dav: dav,
|
||||
noPassword: (server != null && server.password.isEmpty) ||
|
||||
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
|
||||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty));
|
||||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
|
||||
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
|
||||
final locked = isLockedError(err);
|
||||
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
|
||||
if (err == androidRestrictedError) {
|
||||
|
|
@ -716,7 +749,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? _editSftp(pane, pane.root)
|
||||
: smb != null
|
||||
? _editSmb(pane, pane.root)
|
||||
: _editDav(pane, pane.root),
|
||||
: ftp != null
|
||||
? _editFtp(pane, pane.root)
|
||||
: _editDav(pane, pane.root),
|
||||
child: Text(tr('서버 설정 고치기'))),
|
||||
],
|
||||
);
|
||||
|
|
@ -846,6 +881,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
pane.errors.remove(dir);
|
||||
}
|
||||
pane.hostKeyErrors.remove(dir);
|
||||
pane.certErrors.remove(dir);
|
||||
} catch (e) {
|
||||
pane.cache[dir] = const [];
|
||||
pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e';
|
||||
|
|
@ -854,6 +890,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
} else {
|
||||
pane.hostKeyErrors.remove(dir);
|
||||
}
|
||||
if (e is FtpCertException) {
|
||||
pane.certErrors[dir] = e;
|
||||
} else {
|
||||
pane.certErrors.remove(dir);
|
||||
}
|
||||
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로)
|
||||
if (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) {
|
||||
var up = vDirname(dir);
|
||||
|
|
@ -4080,6 +4121,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
'path' => _enterPath(pane),
|
||||
'sftp' => _editSftp(pane, null),
|
||||
'smb' => _editSmb(pane, null),
|
||||
'ftp' => _editFtp(pane, null),
|
||||
_ => _editDav(pane, null),
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
|
|
@ -4090,6 +4132,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))),
|
||||
PopupMenuItem(
|
||||
value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))),
|
||||
PopupMenuItem(
|
||||
value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))),
|
||||
// 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유)
|
||||
if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid)
|
||||
PopupMenuItem(
|
||||
|
|
@ -4177,6 +4221,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
icon: Icon(
|
||||
isSftp(path)
|
||||
? Icons.vpn_key_outlined
|
||||
: isFtp(path)
|
||||
? Icons.folder_shared_outlined
|
||||
: isSmb(path)
|
||||
? Icons.dns_outlined
|
||||
: isDav(path)
|
||||
|
|
@ -4202,6 +4248,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
},
|
||||
onLongPress: isSftp(path)
|
||||
? () => _editSftp(pane, path)
|
||||
: isFtp(path)
|
||||
? () => _editFtp(pane, path)
|
||||
: isSmb(path)
|
||||
? () => _editSmb(pane, path)
|
||||
: isDav(path)
|
||||
|
|
@ -4242,6 +4290,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
icon: const Icon(Icons.vpn_key_outlined, size: 16),
|
||||
onPressed: () => _editSftp(pane, null),
|
||||
),
|
||||
// l: FTP · FTPS 서버 추가
|
||||
IconButton(
|
||||
tooltip: tr('FTP 서버 추가'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.folder_shared_outlined, size: 16),
|
||||
onPressed: () => _editFtp(pane, null),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('경로 입력'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
|
|
@ -4312,6 +4367,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _goTo(pane, '$sftpScheme${saved.id}/');
|
||||
}
|
||||
|
||||
/// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||
Future<void> _editFtp(_Pane pane, String? root) async {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
final old = root == null ? null : FtpRegistry.server(DavPath.parse(root).server);
|
||||
final saved = await editFtpServer(context, c, old: old);
|
||||
if (saved == null || !mounted) return;
|
||||
for (final x in _panes) {
|
||||
x.cache.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||||
x.certErrors.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||||
}
|
||||
await _goTo(pane, '$ftpScheme${saved.id}/');
|
||||
}
|
||||
|
||||
/// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||
Future<void> _editSmb(_Pane pane, String? root, {String? from}) async {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -143,6 +143,7 @@ Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
|
|||
// 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김)
|
||||
..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')]
|
||||
..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')]
|
||||
..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')]
|
||||
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
|
||||
..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')]
|
||||
..openSubtitlesKey = ''
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'cef_setup.dart';
|
|||
import 'cleanup_dialog.dart';
|
||||
import 'copy_sync_settings.dart';
|
||||
import 'sftp_settings.dart';
|
||||
import 'ftp_settings.dart';
|
||||
import 'smb_settings.dart';
|
||||
import 'webdav_settings.dart';
|
||||
import 'explorer_look.dart' show ExplorerStyle;
|
||||
|
|
@ -846,6 +847,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
WebDavSettings(c: c),
|
||||
// 88: SFTP 서버 (WebDAV 옆 탭)
|
||||
SftpSettings(c: c),
|
||||
// l: FTP · FTPS 서버
|
||||
FtpSettings(c: c),
|
||||
// 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다)
|
||||
if (Platform.isAndroid) SmbSettings(c: c),
|
||||
ViewerSettings(c: c),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/ftp.dart';
|
||||
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/sftp_server.dart' show sftpTestPython;
|
||||
|
||||
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
|
||||
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
|
||||
void main() {
|
||||
final hasPy = File(sftpTestPython).existsSync();
|
||||
|
||||
test('설정: 비밀번호는 설정 파일에 넣지 않음 · 모르는 보안 값은 FTPS', () {
|
||||
const s = FtpServer(id: 'f1', name: 'n', host: 'h', user: 'u', password: 'secret', security: 'none');
|
||||
final j = s.toJson();
|
||||
expect(jsonEncode(j), isNot(contains('secret')));
|
||||
expect(j['hasPassword'], isTrue);
|
||||
expect(FtpServer.fromJson({'id': 'x', 'security': '???'}).security, 'explicit', reason: '처음 값은 FTPS (관리자 지시)');
|
||||
expect(FtpServer.fromJson({'id': 'x'}).security, 'explicit');
|
||||
expect(FtpServer.fromJson({'id': 'x', 'security': 'implicit'}).port, 990);
|
||||
expect(FtpServer.fromJson({'id': 'x', 'security': 'none'}).security, 'none');
|
||||
});
|
||||
|
||||
test('설정 파일 · 보관본에는 비밀번호를 넣지 않고 안전 저장소에 (ftp.pw.<id>) · 다시 읽으면 채움', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_ftp_set_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final secrets = MemorySecretStore();
|
||||
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||
final s = AppSettings()
|
||||
..ftpServers = [const FtpServer(id: 'f1', name: 'NAS', host: 'nas.local', port: 2121, user: 'jj', password: 'ftp-secret', certFingerprint: 'SHA256:AB')];
|
||||
await store.save(s);
|
||||
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('ftp-secret')));
|
||||
expect(secrets.values['ftp.pw.f1'], 'ftp-secret');
|
||||
final x = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).ftpServers.single;
|
||||
expect((x.host, x.port, x.user, x.password, x.security, x.certFingerprint), ('nas.local', 2121, 'jj', 'ftp-secret', 'explicit', 'SHA256:AB'));
|
||||
final stripped = AppSettings.stripSecrets({
|
||||
'ftpServers': [
|
||||
{'id': 'x', 'password': 'leak', 'host': 'h'}
|
||||
]
|
||||
});
|
||||
expect(jsonEncode(stripped), isNot(contains('leak')));
|
||||
});
|
||||
|
||||
test('목록 줄 읽기: MLSD · 유닉스 LIST', () {
|
||||
final m = FtpRemote.parseMlsx('type=file;size=11;modify=20261017123456; 가 나.txt')!;
|
||||
expect((m.$1, m.$2, m.$3, m.$4), ('가 나.txt', false, 11, DateTime.utc(2026, 10, 17, 12, 34, 56)));
|
||||
expect(FtpRemote.parseMlsx('type=dir;modify=20261017123456; 폴더')!.$2, isTrue);
|
||||
expect(FtpRemote.parseMlsx('type=cdir; .'), isNull);
|
||||
final l = FtpRemote.parseList('-rw-r--r-- 1 jj jj 1234 Oct 17 2025 some file.mkv')!;
|
||||
expect((l.$1, l.$2, l.$3), ('some file.mkv', false, 1234));
|
||||
expect(FtpRemote.parseList('drwxr-xr-x 2 jj jj 4096 Oct 17 12:00 dir')!.$2, isTrue);
|
||||
expect(FtpRemote.parseList('lrwxrwxrwx 1 jj jj 4 Oct 17 12:00 ln -> x')!.$1, 'ln');
|
||||
});
|
||||
|
||||
group('실제 서버 (pyftpdlib)', () {
|
||||
late Directory tmp;
|
||||
late Directory root;
|
||||
Process? proc;
|
||||
late int port;
|
||||
late String fp;
|
||||
|
||||
Future<void> start(String mode) async {
|
||||
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode]);
|
||||
proc = pr;
|
||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||
pr.stderr.drain<void>();
|
||||
port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT ')).timeout(const Duration(seconds: 20))).split(' ')[1]);
|
||||
final f = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT')).timeout(const Duration(seconds: 5))).split(' ');
|
||||
fp = f.length > 1 ? f[1] : '';
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_ftp_');
|
||||
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
});
|
||||
tearDown(() async {
|
||||
FtpRegistry.configure(const []);
|
||||
final pr = proc;
|
||||
proc = null;
|
||||
if (pr != null) {
|
||||
await pr.stdin.close().catchError((_) {});
|
||||
pr.kill();
|
||||
await pr.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1);
|
||||
}
|
||||
for (var i = 0;; i++) {
|
||||
try {
|
||||
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
||||
break;
|
||||
} on FileSystemException {
|
||||
if (i > 30) rethrow;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) =>
|
||||
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
|
||||
|
||||
Future<Object?> err(Future<void> Function() f) async {
|
||||
try {
|
||||
await f();
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> roundTrip(FtpRemote c) async {
|
||||
await c.mkdirs('/영상/시즌1');
|
||||
expect(Directory(p.join(root.path, '영상', '시즌1')).existsSync(), isTrue);
|
||||
await c.write('/영상/시즌1/1화.mkv', Stream.value(utf8.encode('0123456789')));
|
||||
expect(File(p.join(root.path, '영상', '시즌1', '1화.mkv')).readAsStringSync(), '0123456789');
|
||||
final l = await c.list('/영상/시즌1');
|
||||
expect(l.map((e) => (e.rel, e.isDir, e.size)), [('/영상/시즌1/1화.mkv', false, 10)]);
|
||||
expect((await c.stat('/영상'))!.isDir, isTrue);
|
||||
expect(await c.stat('/없음.txt'), isNull);
|
||||
Future<String> read(int off, int? len) async =>
|
||||
utf8.decode(await (await c.openReadFrom('/영상/시즌1/1화.mkv', offset: off, length: len)).expand((x) => x).toList());
|
||||
expect(await read(0, null), '0123456789');
|
||||
expect(await read(4, 3), '456', reason: 'REST 로 부분 읽기 (동영상 건너뛰기)');
|
||||
expect(await read(8, null), '89');
|
||||
await c.move('/영상/시즌1/1화.mkv', '/영상/1화.mkv');
|
||||
expect(File(p.join(root.path, '영상', '1화.mkv')).existsSync(), isTrue);
|
||||
await c.delete('/영상');
|
||||
expect(Directory(p.join(root.path, '영상')).existsSync(), isFalse);
|
||||
}
|
||||
|
||||
test('FTPS (명시): 처음 보는 자체 서명 인증서는 지문을 보이고 막음 → 믿으면 폴더 · 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기', () async {
|
||||
await start('explicit');
|
||||
final e = await err(() => FtpRemote(srv()).list('/'));
|
||||
expect(e, isA<FtpCertException>().having((x) => x.fingerprint, 'fp', fp).having((x) => x.changed, 'changed', isFalse));
|
||||
final c = FtpRemote(srv(cert: fp));
|
||||
await roundTrip(c);
|
||||
c.close();
|
||||
// 다른 지문을 믿고 있으면 (서버가 바뀜) 막는다
|
||||
final bad = await err(() => FtpRemote(srv(cert: 'SHA256:00')).list('/'));
|
||||
expect(bad, isA<FtpCertException>().having((x) => x.changed, 'changed', isTrue));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('FTPS (암시, 990 꼴)', () async {
|
||||
await start('implicit');
|
||||
final c = FtpRemote(srv(security: 'implicit', cert: fp));
|
||||
await c.write('/a.txt', Stream.value(utf8.encode('A')));
|
||||
expect((await c.list('/')).map((e) => e.rel), ['/a.txt']);
|
||||
expect(File(p.join(root.path, 'a.txt')).readAsStringSync(), 'A', reason: '올린 내용이 그대로');
|
||||
c.close();
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('평문 FTP: 고르면 연결 (화면이 "암호화 안 됨" 경고) · FTPS 로 고른 서버가 평문만 되면 몰래 내려가지 않고 이유를', () async {
|
||||
await start('plain');
|
||||
final c = FtpRemote(srv(security: 'none'));
|
||||
await roundTrip(c);
|
||||
c.close();
|
||||
final e = await err(() => FtpRemote(srv()).list('/'));
|
||||
expect(e, isA<FtpException>().having((x) => x.kind, 'kind', FtpFailure.noTls));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
|
||||
test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async {
|
||||
await start('plain');
|
||||
expect(await err(() => FtpRemote(srv(security: 'none', password: 'x')).list('/')), isA<FtpException>().having((x) => x.kind, 'k', FtpFailure.auth));
|
||||
expect(await err(() => FtpRemote(srv(security: 'none', password: '')).list('/')), isA<FtpException>().having((x) => x.kind, 'k', FtpFailure.noSecret));
|
||||
final off = FtpServer(id: 'f2', name: 'x', host: '127.0.0.1', port: 1, user: 'jj', password: 'pw', security: 'none');
|
||||
expect(await err(() => FtpRemote(off, timeout: const Duration(seconds: 3)).list('/')), isA<FtpException>().having((x) => x.kind, 'k', FtpFailure.unreachable));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# FTP · FTPS 시험 서버 (pyftpdlib 2.2.0 · pyOpenSSL 26.4.0 - 시험용 파이썬 venv ..\tools\sftp_test_py 에 둠, 앱에는 들어가지 않음).
|
||||
# 사용: python tool/ftp_test_server.py --root 폴더 [--user jj --password pw] [--mode plain|explicit|implicit] [--cert 파일]
|
||||
# 127.0.0.1 의 빈 포트에 열고 "PORT n" · "FINGERPRINT SHA256:AA:BB:…" 를 알린다. 표준 입력이 닫히면 끝난다.
|
||||
# --cert 파일이 없으면 자체 서명 인증서를 만들어 그 파일에 둔다 (같은 파일이면 같은 지문).
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import logging
|
||||
if os.environ.get('JJ_FTP_DEBUG'):
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
from pyftpdlib.authorizers import DummyAuthorizer
|
||||
from pyftpdlib.handlers import FTPHandler
|
||||
from pyftpdlib.servers import FTPServer
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--root', required=True)
|
||||
ap.add_argument('--user', default='jj')
|
||||
ap.add_argument('--password', default='pw')
|
||||
ap.add_argument('--mode', default='explicit', choices=['plain', 'explicit', 'implicit'])
|
||||
ap.add_argument('--cert', default='')
|
||||
a = ap.parse_args()
|
||||
|
||||
auth = DummyAuthorizer()
|
||||
auth.add_user(a.user, a.password, a.root, perm='elradfmwMT')
|
||||
fingerprint = ''
|
||||
if a.mode == 'plain':
|
||||
handler = FTPHandler
|
||||
else:
|
||||
from OpenSSL import crypto
|
||||
from pyftpdlib.handlers import TLS_FTPHandler
|
||||
cert = a.cert or os.path.join(a.root, '..', 'ftp_cert.pem')
|
||||
if not os.path.exists(cert):
|
||||
k = crypto.PKey()
|
||||
k.generate_key(crypto.TYPE_RSA, 2048)
|
||||
c = crypto.X509()
|
||||
c.get_subject().CN = '127.0.0.1'
|
||||
c.set_serial_number(1)
|
||||
c.gmtime_adj_notBefore(0)
|
||||
c.gmtime_adj_notAfter(3600 * 24)
|
||||
c.set_issuer(c.get_subject())
|
||||
c.set_pubkey(k)
|
||||
c.sign(k, 'sha256')
|
||||
with open(cert, 'wb') as f:
|
||||
f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k) + crypto.dump_certificate(crypto.FILETYPE_PEM, c))
|
||||
der = crypto.dump_certificate(crypto.FILETYPE_ASN1, crypto.load_certificate(crypto.FILETYPE_PEM, open(cert, 'rb').read()))
|
||||
fingerprint = 'SHA256:' + ':'.join('%02X' % b for b in hashlib.sha256(der).digest())
|
||||
|
||||
from pyftpdlib.handlers import TLS_DTPHandler
|
||||
from pyftpdlib.handlers.ftps.ssl import SSLConnectionMixin
|
||||
|
||||
# pyftpdlib 2.2.0 + pyOpenSSL 26: 자료 연결의 TLS 닫기에서 빈 SSL.Error 를 "다시 해 보기" 로 보고 끝없이 되풀이해
|
||||
# 올리기 (STOR) 가 끝나지 않았다 (226 이 오지 않음, 10/18). 시험 서버만: TLS 를 한 번 닫아 보고 바로 연결을 닫는다
|
||||
class D(TLS_DTPHandler):
|
||||
def _do_ssl_shutdown(self):
|
||||
try:
|
||||
self.socket.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
super(SSLConnectionMixin, self).close()
|
||||
|
||||
class H(TLS_FTPHandler):
|
||||
certfile = cert
|
||||
dtp_handler = D
|
||||
tls_control_required = True
|
||||
tls_data_required = True
|
||||
|
||||
if a.mode == 'implicit':
|
||||
# pyftpdlib 은 암시 FTPS 를 따로 두지 않는다 - 연결하자마자 TLS 로 (secure_connection)
|
||||
class HI(H):
|
||||
def handle(self):
|
||||
self.secure_connection(self.ssl_context)
|
||||
|
||||
def handle_ssl_established(self):
|
||||
FTPHandler.handle(self)
|
||||
|
||||
def ftp_AUTH(self, line):
|
||||
self.respond('503 Already using TLS.')
|
||||
|
||||
handler = HI
|
||||
else:
|
||||
handler = H
|
||||
|
||||
handler.authorizer = auth
|
||||
handler.passive_ports = None
|
||||
handler.encoding = 'utf-8'
|
||||
server = FTPServer(('127.0.0.1', 0), handler)
|
||||
print('PORT', server.socket.getsockname()[1], flush=True)
|
||||
print('FINGERPRINT', fingerprint, flush=True)
|
||||
|
||||
|
||||
def watch():
|
||||
sys.stdin.read()
|
||||
server.close_all()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
server.serve_forever(handle_exit=False)
|
||||
Loading…
Reference in New Issue