feat: SFTP连接功能

This commit is contained in:
chaos-zhu
2026-05-23 18:44:31 +08:00
parent 44b60ec5bf
commit c338eb3172
10 changed files with 2176 additions and 44 deletions
@@ -8,6 +8,7 @@ import '../../core/crypto/aes_gcm_crypto.dart';
import '../../core/crypto/crypto_js_aes.dart';
import '../../core/crypto/rsa_crypto.dart';
import '../terminal/ssh_connection_config.dart';
import '../shell/sftp_session_manager.dart';
import 'server_form_data.dart';
import 'server_group_model.dart';
import 'server_model.dart';
@@ -21,6 +22,7 @@ abstract class ServerRepository {
Future<String> updateHost(ServerFormData form);
Future<String> deleteHost(String hostId);
Future<SshConnectionConfig> fetchSshConfig(String hostId);
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId);
}
/// Default [ServerRepository] backed by [ApiClient] and the RSA public key
@@ -31,10 +33,10 @@ class ApiServerRepository implements ServerRepository {
required String publicKeyPem,
RsaCrypto? rsa,
Random? random,
}) : _api = apiClient,
_publicKeyPem = publicKeyPem,
_rsa = rsa ?? RsaCrypto(),
_random = random ?? Random.secure();
}) : _api = apiClient,
_publicKeyPem = publicKeyPem,
_rsa = rsa ?? RsaCrypto(),
_random = random ?? Random.secure();
final ApiClient _api;
final String _publicKeyPem;
@@ -69,13 +71,19 @@ class ApiServerRepository implements ServerRepository {
@override
Future<String> createHost(ServerFormData form) async {
final response = await _api.postJson('/host-save', _prepareHostPayload(form));
final response = await _api.postJson(
'/host-save',
_prepareHostPayload(form),
);
return response['msg']?.toString() ?? 'success';
}
@override
Future<String> updateHost(ServerFormData form) async {
final response = await _api.putJson('/host-save', _prepareHostPayload(form));
final response = await _api.putJson(
'/host-save',
_prepareHostPayload(form),
);
return response['msg']?.toString() ?? 'success';
}
@@ -147,6 +155,17 @@ class ApiServerRepository implements ServerRepository {
return SshConnectionConfig.fromJson(plaintext);
}
@override
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async {
final response = await _api.getJson('/mobile/sftp-favorites/$hostId');
final raw = response['data'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(SftpFavorite.fromJson)
.toList(growable: false);
}
Uint8List _randomBytes(int length) {
final bytes = Uint8List(length);
for (var i = 0; i < length; i++) {
@@ -0,0 +1,438 @@
import 'dart:async';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/foundation.dart';
import '../servers/server_model.dart';
import '../terminal/ssh_connection_config.dart';
import '../terminal/ssh_transport.dart';
enum SftpConnectionStatus { disconnected, connecting, connected, error }
class SftpFileEntry {
const SftpFileEntry({
required this.name,
required this.type,
required this.size,
required this.modifyTime,
required this.permissions,
required this.ownerName,
required this.groupName,
});
final String name;
final String type;
final int? size;
final DateTime? modifyTime;
final String permissions;
final String ownerName;
final String groupName;
bool get isDirectory => type == 'd';
bool get isLink => type == 'l';
}
class SftpFavorite {
const SftpFavorite({
required this.hostId,
required this.path,
required this.name,
required this.type,
});
final String hostId;
final String path;
final String name;
final String type;
factory SftpFavorite.fromJson(Map<String, dynamic> json) {
return SftpFavorite(
hostId: (json['hostId'] ?? '').toString(),
path: (json['path'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
type: (json['type'] ?? '').toString(),
);
}
}
class SftpSessionState extends ChangeNotifier {
SftpSessionState({required this.server});
final ServerModel server;
SftpConnectionStatus status = SftpConnectionStatus.disconnected;
String currentPath = '/';
List<SftpFileEntry> entries = const [];
List<SftpFavorite> favorites = const [];
Set<String> selectedNames = <String>{};
bool loadingDirectory = false;
bool showHidden = false;
String? lastError;
bool get isConnected => status == SftpConnectionStatus.connected;
bool get hasSelection => selectedNames.isNotEmpty;
bool get allVisibleSelected =>
entries.isNotEmpty &&
entries.every((entry) => selectedNames.contains(entry.name));
void update({
SftpConnectionStatus? status,
String? currentPath,
List<SftpFileEntry>? entries,
List<SftpFavorite>? favorites,
Set<String>? selectedNames,
bool? loadingDirectory,
bool? showHidden,
String? lastError,
}) {
if (status != null) this.status = status;
if (currentPath != null) this.currentPath = currentPath;
if (entries != null) this.entries = entries;
if (favorites != null) this.favorites = favorites;
if (selectedNames != null) this.selectedNames = selectedNames;
if (loadingDirectory != null) this.loadingDirectory = loadingDirectory;
if (showHidden != null) this.showHidden = showHidden;
this.lastError = lastError;
notifyListeners();
}
void toggleSelection(String name) {
final next = Set<String>.from(selectedNames);
if (!next.add(name)) next.remove(name);
selectedNames = next;
notifyListeners();
}
void addSelection(String name) {
if (selectedNames.contains(name)) return;
selectedNames = {...selectedNames, name};
notifyListeners();
}
void selectOnly(String name) {
selectedNames = {name};
notifyListeners();
}
void toggleAllVisible() {
if (allVisibleSelected) {
selectedNames = <String>{};
} else {
selectedNames = entries.map((entry) => entry.name).toSet();
}
notifyListeners();
}
void clearSelection() {
if (selectedNames.isEmpty) return;
selectedNames = <String>{};
notifyListeners();
}
}
class SftpSessionManager extends ChangeNotifier {
SftpSessionManager({SshTransportFactory? transportFactory})
: _transportFactory = transportFactory ?? SshTransportFactory();
final SshTransportFactory _transportFactory;
final Map<String, _SftpConnection> _connections = {};
final Map<String, SftpSessionState> _states = {};
String? _activeHostId;
Iterable<SftpSessionState> get sessions => List.unmodifiable(_states.values);
String? get activeHostId => _activeHostId;
SftpSessionState? get activeSession {
final id = _activeHostId;
return id == null ? null : _states[id];
}
bool isConnected(String hostId) {
return _states[hostId]?.status == SftpConnectionStatus.connected;
}
SftpSessionState? sessionFor(String hostId) => _states[hostId];
Future<SftpSessionState> connect({
required ServerModel server,
required SshConnectionConfig config,
Future<List<SftpFavorite>> Function(String hostId)? loadFavorites,
}) async {
final existing = _states[server.id];
if (existing?.status == SftpConnectionStatus.connected) {
_activeHostId = server.id;
notifyListeners();
return existing!;
}
final state = existing ?? SftpSessionState(server: server);
_states[server.id] = state;
_activeHostId = server.id;
state.update(
status: SftpConnectionStatus.connecting,
loadingDirectory: true,
lastError: null,
selectedNames: <String>{},
);
notifyListeners();
try {
await _connections.remove(server.id)?.close();
final transport = await _transportFactory.open(config);
final identities = config.authType == 'privateKey'
? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase)
: null;
final client = SSHClient(
transport.socket,
username: config.username,
onPasswordRequest: config.authType == 'password'
? () => config.password
: null,
identities: identities,
);
final sftp = await client.sftp();
_connections[server.id] = _SftpConnection(
client: client,
sftp: sftp,
transport: transport,
);
final initial = await _resolveInitialPath(sftp, config.username);
final entries = await _listDirectory(sftp, initial, state.showHidden);
final favorites = await loadFavorites?.call(server.id) ?? const [];
state.update(
status: SftpConnectionStatus.connected,
currentPath: initial,
entries: entries,
favorites: favorites,
loadingDirectory: false,
lastError: null,
selectedNames: <String>{},
);
notifyListeners();
return state;
} catch (error) {
await _connections.remove(server.id)?.close();
state.update(
status: SftpConnectionStatus.error,
loadingDirectory: false,
lastError: error.toString(),
);
notifyListeners();
rethrow;
}
}
void activate(String hostId) {
if (_states[hostId] == null) return;
_activeHostId = hostId;
notifyListeners();
}
Future<void> disconnectActive() async {
final hostId = _activeHostId;
if (hostId == null) return;
final connection = _connections.remove(hostId);
final state = _states.remove(hostId);
await connection?.close();
state?.dispose();
_activeHostId = _states.isEmpty ? null : _states.keys.first;
notifyListeners();
}
Future<void> openPath(String path) async {
final state = activeSession;
if (state == null) return;
final connection = _connections[state.server.id];
if (connection == null) return;
state.update(loadingDirectory: true, lastError: null);
try {
final entries = await _listDirectory(
connection.sftp,
path,
state.showHidden,
);
state.update(
currentPath: path,
entries: entries,
loadingDirectory: false,
selectedNames: <String>{},
);
} catch (error) {
state.update(loadingDirectory: false, lastError: error.toString());
}
}
Future<void> refreshActive() async {
final path = activeSession?.currentPath;
if (path == null) return;
await openPath(path);
}
Future<void> createDirectory(String name) async {
final state = activeSession;
if (state == null) return;
final connection = _connections[state.server.id];
if (connection == null) return;
final targetPath = joinPath(state.currentPath, name.trim());
await connection.sftp.mkdir(targetPath);
await refreshActive();
}
Future<void> createFile(String name) async {
final state = activeSession;
if (state == null) return;
final connection = _connections[state.server.id];
if (connection == null) return;
final targetPath = joinPath(state.currentPath, name.trim());
final file = await connection.sftp.open(
targetPath,
mode:
SftpFileOpenMode.write |
SftpFileOpenMode.create |
SftpFileOpenMode.exclusive,
);
try {
await file.writeBytes(Uint8List(0));
} finally {
await file.close();
}
await refreshActive();
}
Future<void> goParent() async {
final state = activeSession;
if (state == null) return;
await openPath(parentPath(state.currentPath));
}
Future<void> setShowHidden(bool value) async {
final state = activeSession;
if (state == null || state.showHidden == value) return;
state.update(showHidden: value);
await refreshActive();
}
String entryPath(SftpSessionState state, SftpFileEntry entry) {
return joinPath(state.currentPath, entry.name);
}
static String joinPath(String base, String name) {
if (base == '/' || base == '~') return '$base/$name'.replaceAll('//', '/');
if (base.endsWith('/')) return '$base$name';
return '$base/$name';
}
static String parentPath(String path) {
if (path == '/' || path == '~') return path;
final trimmed = path.endsWith('/') && path.length > 1
? path.substring(0, path.length - 1)
: path;
final index = trimmed.lastIndexOf('/');
if (index <= 0) return '/';
return trimmed.substring(0, index);
}
@override
void dispose() {
for (final connection in _connections.values) {
unawaited(connection.close());
}
for (final state in _states.values) {
state.dispose();
}
_connections.clear();
_states.clear();
super.dispose();
}
Future<String> _resolveInitialPath(SftpClient sftp, String username) async {
if (username == 'root') {
try {
await sftp.listdir('/root');
return '/root';
} catch (_) {
return '/';
}
}
try {
await sftp.listdir('/');
return '/';
} catch (_) {
try {
return await sftp.absolute('.');
} catch (_) {
return '~';
}
}
}
Future<List<SftpFileEntry>> _listDirectory(
SftpClient sftp,
String path,
bool showHidden,
) async {
final names = await sftp.listdir(path == '~' ? '.' : path);
final entries = names
.where((item) => item.filename != '.' && item.filename != '..')
.where((item) => showHidden || !item.filename.startsWith('.'))
.map(_entryFromName)
.toList(growable: false);
entries.sort((a, b) {
if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
return entries;
}
SftpFileEntry _entryFromName(SftpName name) {
final type = _typeFromLongName(name.longname);
final parts = name.longname.trim().split(RegExp(r'\s+'));
final permissions = parts.isNotEmpty ? parts.first.skip(1) : '';
final ownerName = parts.length >= 3 ? parts[2] : '';
final groupName = parts.length >= 4 ? parts[3] : '';
return SftpFileEntry(
name: name.filename,
type: type,
size: name.attr.size,
modifyTime: _dateFromSeconds(name.attr.modifyTime),
permissions: permissions,
ownerName: ownerName,
groupName: groupName,
);
}
String _typeFromLongName(String longName) {
if (longName.startsWith('d')) return 'd';
if (longName.startsWith('l')) return 'l';
return '-';
}
DateTime? _dateFromSeconds(int? seconds) {
if (seconds == null || seconds <= 0) return null;
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
}
}
class _SftpConnection {
_SftpConnection({
required this.client,
required this.sftp,
required this.transport,
});
final SSHClient client;
final SftpClient sftp;
final SshTransportHandle transport;
Future<void> close() async {
sftp.close();
client.close();
await transport.close();
}
}
extension on String {
String skip(int count) {
if (length <= count) return '';
return substring(count);
}
}
File diff suppressed because it is too large Load Diff
+28 -6
View File
@@ -60,7 +60,8 @@ const Map<String, String> stringsEn = {
'servers.addServer': 'Add server',
'servers.editServer': 'Edit server',
'servers.searchHint': 'Search by name, host, user, tag, or group',
'servers.emptyHint': 'No servers yet. Pull to refresh after adding hosts on web.',
'servers.emptyHint':
'No servers yet. Pull to refresh after adding hosts on web.',
'servers.emptyFiltered': 'No matching servers.',
'servers.notConfigured': 'Not configured',
'servers.fetchSshFailed': 'Failed to get SSH config: {0}',
@@ -113,12 +114,17 @@ const Map<String, String> stringsEn = {
'servers.proxy.proxyServerShort': 'Proxy',
'servers.proxy.socksShort': 'Proxy',
'servers.jumpHosts.empty': 'No configured servers can be used as jump hosts.',
'servers.jumpHosts.orderHint': 'Connection order follows the selection order.',
'servers.jumpHosts.orderHint':
'Connection order follows the selection order.',
'servers.jumpHosts.placeholder': 'Select jump hosts',
'servers.proxyServerUnsupported': 'Proxy server selection is reserved for later.',
'servers.proxy.empty': 'No Socks service data. Manage proxy services on web first.',
'servers.credentials.empty': 'No credential data. Manage credentials on web first.',
'servers.credentials.hint': 'Credentials are maintained centrally in credential management.',
'servers.proxyServerUnsupported':
'Proxy server selection is reserved for later.',
'servers.proxy.empty':
'No Socks service data. Manage proxy services on web first.',
'servers.credentials.empty':
'No credential data. Manage credentials on web first.',
'servers.credentials.hint':
'Credentials are maintained centrally in credential management.',
'servers.validation.group': 'Select a group',
'servers.validation.name': 'Enter a server name',
'servers.validation.host': 'Enter an IP address or domain',
@@ -130,6 +136,22 @@ const Map<String, String> stringsEn = {
// SFTP / Scripts placeholders
'sftp.placeholder': 'Coming soon: browse and manage remote files',
'sftp.emptyTitle': 'No server connected',
'sftp.emptyBody': 'Choose a server to start browsing remote files over SFTP.',
'sftp.chooseServer': 'Choose server',
'sftp.switchServer': 'Switch server',
'sftp.sheetTitle': 'Select server',
'sftp.sheetSubtitle': 'Connected servers are shown first.',
'sftp.searchHint': 'Search by name, host, or user',
'sftp.connectedSection': 'Connected',
'sftp.disconnectedSection': 'Not connected',
'sftp.statusConnected': 'Connected',
'sftp.statusDisconnected': 'Not connected',
'sftp.connecting': 'Connecting SFTP...',
'sftp.emptyDirectory': 'This directory is empty.',
'sftp.upload': 'Upload',
'sftp.newItem': 'New',
'sftp.favorites': 'Favorites',
'scripts.placeholder': 'Coming soon: script library',
// Settings
+16
View File
@@ -126,6 +126,22 @@ const Map<String, String> stringsZh = {
// Placeholders
'sftp.placeholder': '即将上线:浏览和管理远程文件',
'sftp.emptyTitle': '还没有选择服务器',
'sftp.emptyBody': '请选择一台服务器,开始使用 SFTP 浏览远程文件。',
'sftp.chooseServer': '选择服务器',
'sftp.switchServer': '切换服务器',
'sftp.sheetTitle': '选择服务器',
'sftp.sheetSubtitle': '已连接的服务器会优先显示。',
'sftp.searchHint': '按名称 / 主机 / 用户名搜索',
'sftp.connectedSection': '已连接',
'sftp.disconnectedSection': '未连接',
'sftp.statusConnected': '已连接',
'sftp.statusDisconnected': '未连接',
'sftp.connecting': '正在连接 SFTP...',
'sftp.emptyDirectory': '当前目录为空',
'sftp.upload': '上传',
'sftp.newItem': '新建',
'sftp.favorites': '收藏',
'scripts.placeholder': '即将上线:脚本库',
// Settings
+7
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/shell/sftp_session_manager.dart';
import '../features/terminal/terminal_session_manager.dart';
/// Single TerminalSessionManager for the whole app. Used to be passed
@@ -10,3 +11,9 @@ final terminalSessionManagerProvider = Provider<TerminalSessionManager>((ref) {
ref.onDispose(manager.dispose);
return manager;
});
final sftpSessionManagerProvider = Provider<SftpSessionManager>((ref) {
final manager = SftpSessionManager();
ref.onDispose(manager.dispose);
return manager;
});
@@ -7,6 +7,7 @@ import 'package:mobile/features/servers/server_form_page.dart';
import 'package:mobile/features/servers/server_group_model.dart';
import 'package:mobile/features/servers/server_model.dart';
import 'package:mobile/features/servers/server_repository.dart';
import 'package:mobile/features/shell/sftp_session_manager.dart';
import 'package:mobile/features/terminal/ssh_connection_config.dart';
import 'package:mobile/l10n/app_localizations.dart';
import 'package:mobile/state/api_providers.dart';
@@ -19,12 +20,12 @@ class _FakeRepository implements ServerRepository {
@override
Future<List<ServerGroupModel>> fetchGroups() async => [
ServerGroupModel.fromJson({
'id': 'default',
'name': 'Default group',
'index': 1,
}),
];
ServerGroupModel.fromJson({
'id': 'default',
'name': 'Default group',
'index': 1,
}),
];
@override
Future<String> createHost(ServerFormData form) async {
@@ -42,6 +43,10 @@ class _FakeRepository implements ServerRepository {
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
throw UnimplementedError();
}
@override
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async =>
const [];
}
Widget _wrap(ServerRepository repo) {
@@ -77,7 +82,9 @@ Future<void> _fillRequiredFields(WidgetTester tester) async {
}
void main() {
testWidgets('requires credential when auth type is credential', (tester) async {
testWidgets('requires credential when auth type is credential', (
tester,
) async {
final repo = _FakeRepository();
await _pumpForm(tester, repo);
await _fillRequiredFields(tester);
@@ -91,7 +98,9 @@ void main() {
expect(repo.createCalls, 0);
});
testWidgets('requires proxy target when proxy type is not none', (tester) async {
testWidgets('requires proxy target when proxy type is not none', (
tester,
) async {
final repo = _FakeRepository();
await _pumpForm(tester, repo);
await _fillRequiredFields(tester);
@@ -7,6 +7,7 @@ import 'package:mobile/features/servers/server_form_data.dart';
import 'package:mobile/features/servers/server_model.dart';
import 'package:mobile/features/servers/server_group_model.dart';
import 'package:mobile/features/servers/server_repository.dart';
import 'package:mobile/features/shell/sftp_session_manager.dart';
import 'package:mobile/features/servers/servers_tab.dart';
import 'package:mobile/features/terminal/ssh_connection_config.dart';
import 'package:mobile/l10n/app_localizations.dart';
@@ -84,6 +85,10 @@ class _FakeRepository implements ServerRepository {
if (sshError != null) throw sshError!;
return config;
}
@override
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async =>
const [];
}
class _RecordingAuthNotifier extends AuthNotifier {
+20 -1
View File
@@ -1,11 +1,12 @@
const { RSADecryptAsync } = require('../utils/encrypt')
const { encryptJsonForMobile } = require('../utils/mobile-crypto')
const { HostListDB } = require('../utils/db-class')
const { HostListDB, FavoriteSftpDB } = require('../utils/db-class')
// `getConnectionOptions` and `getProxyConfig` are lazily required inside
// functions. Loading `../socket/terminal` at module scope pulls in
// `terminal-session`, which expects `global.logger` to exist after app boot.
const hostListDB = new HostListDB().getInstance()
const favoriteSftpDB = new FavoriteSftpDB().getInstance()
function normalizePort(port) {
const numericPort = Number(port)
@@ -154,8 +155,26 @@ async function getMobileSshConnection({ request, res }) {
}
}
async function getMobileSftpFavorites({ params, request, res }) {
try {
const hostId = params?.hostId || request.query?.hostId
if (!hostId) {
return res.fail({ msg: 'missing hostId' })
}
const favorites = await favoriteSftpDB.findAsync(
{ hostId },
{ sort: { createTime: -1 } }
)
return res.success({ data: favorites, msg: 'success' })
} catch (error) {
logger.error('getMobileSftpFavorites error:', error.message)
return res.fail({ msg: error.message || 'mobile sftp favorites failed' })
}
}
module.exports = {
getMobileSshConnection,
getMobileSftpFavorites,
getMobileConnectionTopology,
normalizePort,
normalizeMobileAuthPayload,
+6 -1
View File
@@ -12,7 +12,7 @@ const { getProxyList, addProxy, updateProxy, removeProxy } = require('../control
const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config')
const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config')
const { getSuspendedSessions, getTerminalSessionConfig, updateTerminalSessionConfig } = require('../controller/terminal')
const { getMobileSshConnection } = require('../controller/mobile')
const { getMobileSshConnection, getMobileSftpFavorites } = require('../controller/mobile')
const ssh = [
{
@@ -398,6 +398,11 @@ const mobile = [
method: 'post',
path: '/mobile/ssh-connection',
controller: getMobileSshConnection
},
{
method: 'get',
path: '/mobile/sftp-favorites/:hostId',
controller: getMobileSftpFavorites
}
]