diff --git a/README.md b/README.md index a19d850..c67673e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # yetAnotherElectronTerm -This project is based on angular + electron to create a remote connection tool supporting SSH RDP SCP VNC +YAET is a remote connection tool based on angular + electron supporting SSH RDP SCP/SFTP FTP VNC + ## Prerequisites - Node - angular cli diff --git a/TestPlan.md b/TestPlan.md index 9c286ce..d2c8476 100644 --- a/TestPlan.md +++ b/TestPlan.md @@ -4,6 +4,7 @@ - multiple message can be displayed one by one (for ex, when you change master key and re-encrypt all settings) - dev mode has menu - prod mode no menu +- mouse middle-click the title of the tab trigger close of the tab ## Package - local dev mode ok @@ -71,6 +72,7 @@ - vnc - rdp - scp + - ftp - icon ok - clone works - custom @@ -78,6 +80,7 @@ - vnc - rdp - scp + - ftp - with same group info cloned - with same tags info cloned - edit @@ -155,6 +158,44 @@ ## SCP - all ok, use a third party lib - init path pb fixed via patch-package +- [x] scp form +- [x] connect +- [x] list +- [x] cd +- [x] download single +- [x] download multiple +- [x] details +- [x] upload +- [x] drag and drop file to upload +- [x] copy paste file +- [x] cut paste file +- [x] create folder +- [] create file +- [x] rename folder +- [x] rename file +- [x] delete file +- [x] delete folder +- [x] double click open the file + - if you update the file, the file will be uploaded to scp + +## Ftp +- [x] profile form +- [x] connect +- [x] list +- [x] cd +- [x] download single +- [x] download multiple +- [x] upload +- [x] drag and drop file to upload +- [x] init path +- [x] create folder +- [] create file +- [x] rename folder +- [x] rename file +- [x] delete file +- [x] delete folder +- [x] double click open the file + - if you update the file, the file will be uploaded to ftp # Custom - custom command can start, for ex for realvnc diff --git a/package-lock.json b/package-lock.json index 8c6f6dc..90c7ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yet-another-electron-term", - "version": "1.0.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yet-another-electron-term", - "version": "1.0.2", + "version": "1.0.0", "hasInstallScript": true, "dependencies": { "@angular/animations": "^18.2.0", @@ -28,6 +28,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "basic-ftp": "^5.0.5", "cors": "^2.8.5", "crypto-js": "^4.2.0", "electron-log": "^5.2.4", @@ -5779,6 +5780,15 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "dev": true, diff --git a/package.json b/package.json index fcb5582..19a2ee8 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "basic-ftp": "^5.0.5", "cors": "^2.8.5", "crypto-js": "^4.2.0", "electron-log": "^5.2.4", diff --git a/src-electron/electronMain.js b/src-electron/electronMain.js index a6455b6..cf2236f 100644 --- a/src-electron/electronMain.js +++ b/src-electron/electronMain.js @@ -15,6 +15,7 @@ const {initCustomSessionHandler} = require("./ipc/custom"); const {initScpSftpHandler} = require("./ipc/scp"); const {initAutoUpdater} = require("./ipc/autoUpdater"); const {initBackend} = require("./ipc/backend"); +const {initFtpHandler} = require("./ipc/ftp"); let tray; let expressApp; @@ -22,8 +23,10 @@ let mainWindow; let terminalMap = new Map(); let vncMap = new Map(); let scpMap = new Map(); +let ftpMap = new Map(); const log = require("electron-log") + const logPath = `${__dirname}/logs/main.log`; console.log(logPath); log.transports.file.resolvePathFn = () => logPath; @@ -110,9 +113,13 @@ app.on('ready', () => { initRdpHandler(log); initVncHandler(log, vncMap); initScpSftpHandler(log, scpMap, expressApp); + initFtpHandler(log, ftpMap, expressApp); initClipboard(log, mainWindow); initCustomSessionHandler(log); + // Start API + expressApp.listen(13012, () => log.info('API listening on port 13012')); + }); @@ -139,6 +146,14 @@ app.on('window-all-closed', () => { } }); + + ftpMap.forEach((ftpClient) => { + // value?.end(); + if (ftpClient) { + ftpClient.close(); // WebSocket server for this vnc client closed + } + }); + app.quit(); } }); diff --git a/src-electron/ipc/ftp.js b/src-electron/ipc/ftp.js new file mode 100644 index 0000000..038415e --- /dev/null +++ b/src-electron/ipc/ftp.js @@ -0,0 +1,296 @@ +const { ipcMain, shell } = require('electron'); +const ftp = require('basic-ftp'); +const multer = require('multer'); +const upload = multer(); +const path = require('path'); +const yazl = require('yazl'); +const { Writable, Readable } = require('stream'); +const fs = require('fs'); +const fsPromise = require('fs/promises'); +const os = require('os'); +const uuid = require('uuid'); + +function initFtpHandler(log, ftpMap, expressApp) { + + ipcMain.handle('session.fe.ftp.register', async (event, { id, config }) => { + ftpMap.set(id, config); + }); + + //==================== Utils ================================================= + async function withFtpClient(configId, callback) { + const config = ftpMap.get(configId); + if (!config) { + throw new Error('Error connection config not found'); + } + + const client = new ftp.Client(); + client.ftp.verbose = true; // Enable verbose logging for debugging + + try { + await client.access(config); + return await callback(client); + } finally { + client.close(); + } + } + + async function avoidDuplicateName(client, targetFilePathOg) { + let targetFilePath = targetFilePathOg; + const parseFilePath = (filePath) => { + const fileName = path.basename(filePath, path.extname(filePath)); // Extract file name without extension + const fileExt = path.extname(filePath); // Extract extension + const dir = path.dirname(filePath); // Extract directory path + return { dir, fileName, fileExt }; + }; + + const { dir, fileName, fileExt } = parseFilePath(targetFilePathOg); + let index = 1; // Start indexing from 1 for suffix + + // Check if file exists and generate new target path + while (await exists(client, targetFilePath)) { + targetFilePath = `${dir}/${fileName}_${index}${fileExt}`; + index++; + } + return targetFilePath; + } + + async function list(client, pathParam) { + const files = await client.list(pathParam); + + return files.map(file => ({ + name: file.name, + type: file.isDirectory ? 'folder' : 'file', + isFile: !file.isDirectory, + size: file.size, + dateModified: file.modifiedAt, + })); + } + + async function exists(client, remoteFilePath) { + const directoryPath = remoteFilePath.substring(0, remoteFilePath.lastIndexOf('/') + 1); + const fileName = remoteFilePath.substring(remoteFilePath.lastIndexOf('/') + 1); + + try { + // List files in the directory + const files = await client.list(directoryPath); + + // Check if the file exists in the directory + return files.some(file => file.name === fileName); + } catch (error) { + console.error('Error checking file existence:', error.message); + return false; + } + } + + //==================== API ========================================================== + expressApp.post('/api/v1/ftp/:id', async (req, res) => { + const action = req.body.action || 'read'; + const pathParam = req.body.path || '/'; + const configId = req.params['id']; + + try { + const result = await withFtpClient(configId, async (client) => { + switch (action) { + case 'read': + return { cwd: { name: pathParam, type: 'folder' }, files: await list(client, pathParam) }; + case 'search': { + const files = await client.list(pathParam); + const regexFlags = req.body.caseSensitive ? '' : 'i'; + const searchRegex = new RegExp(req.body.searchString.replace(/\*/g, '.*'), regexFlags); + + const formattedFiles = files.filter(item => { + const isHidden = item.name.startsWith('.'); + return (req.body.showHiddenItems || !isHidden) && searchRegex.test(item.name); + }).map(item => ({ + name: item.name, + type: item.isDirectory ? 'folder' : 'file', + size: item.size, + modifyTime: item.modifiedAt, + })); + + return { cwd: { name: pathParam, type: 'folder' }, files: formattedFiles }; + } + case 'delete': { + const data = req.body.data || []; + for (const oneData of data) { + const fileAbsPath = `${pathParam}${oneData.name}`; + if (oneData.type === 'folder') { + await client.removeDir(fileAbsPath); + } else { + await client.remove(fileAbsPath); + } + } + return { cwd: { name: pathParam, type: 'folder' }, files: await list(client, pathParam) }; + } + case 'rename': { + const name = req.body.name; + const newName = req.body.newName; + await client.rename(`${pathParam}${name}`, `${pathParam}${newName}`); + return { cwd: { name: pathParam, type: 'folder' }, files: await list(client, pathParam) }; + } + case 'create': { + const name = req.body.name; + const newFolderPath = `${pathParam}${name}`; + if (await exists(client, newFolderPath)) { + return { cwd: { name: pathParam, type: 'folder' }, error: { code: 416, message: 'folder already exists' } }; + } else { + await client.ensureDir(newFolderPath); + return { cwd: { name: pathParam, type: 'folder' }, files: await list(client, newFolderPath) }; + } + } + case 'details': { + return { cwd: { name: pathParam, type: 'folder' }, details: await list(client, pathParam) }; + } + default: + throw new Error(`Unknown action: ${action}`); + } + }); + + res.json(result); + } catch (error) { + log.error('Error handling FTP request:', error); + res.status(500).send({ error: { code: 500, message: error.message } }); + } + }); + + expressApp.post('/api/v1/ftp/upload/:id', upload.single('uploadFiles'), async (req, res) => { + const { data } = req.body; + const path = JSON.parse(data).name; + const configId = req.params['id']; + + if (!req.file) { + log.error('Error: No file uploaded'); + res.status(400).send({ error: { code: 400, message: 'No file uploaded' } }); + return; + } + + try { + const result = await withFtpClient(configId, async (client) => { + const remotePath = await avoidDuplicateName(client, `${path}/${req.file.originalname}`); + const bufferStream = new Readable(); + bufferStream.push(req.file.buffer); + bufferStream.push(null); + await client.uploadFrom(bufferStream, remotePath); + return { success: true, message: `File uploaded to ${remotePath}` }; + }); + + res.json(result); + } catch (error) { + log.error('Error uploading file:', error); + res.status(400).send({ error: { code: 400, message: 'Error uploading file: ' + error.message } }); + } + }); + + expressApp.post('/api/v1/ftp/download/:id', upload.none(), async (req, res) => { + const downloadInput = JSON.parse(req.body.downloadInput); + const path = downloadInput.path; + const names = downloadInput.names; + const configId = req.params['id']; + + try { + await withFtpClient(configId, async (client) => { + if (names.length === 1) { + const fullPath = path + names[0]; + const chunks = []; + const writableStream = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + await client.downloadTo(writableStream, fullPath); + const buffer = Buffer.concat(chunks); + res.set('Content-Disposition', `attachment; filename=${names[0]}`); + res.send(buffer); + } else if (names.length > 1) { + res.setHeader('Content-Disposition', 'attachment; filename="download.zip"'); + res.setHeader('Content-Type', 'application/zip'); + const zipfile = new yazl.ZipFile(); + for (const name of names) { + const fullRemotePath = `${path}${name}`; + try { + const chunks = []; + const writableStream = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + await client.downloadTo(writableStream, fullRemotePath); + const buffer = Buffer.concat(chunks); + zipfile.addBuffer(buffer, name); + } catch (fileError) { + log.error(`Error fetching file ${fullRemotePath}:`, fileError.message); + } + } + zipfile.outputStream.pipe(res).on('close', () => { + log.info('ZIP file successfully sent.'); + }); + zipfile.end(); + } + }); + } catch (error) { + log.error('Error downloading file:', error); + res.status(400).send({ error: { code: 400, message: 'Error downloading file: ' + error.message } }); + } + }); + + expressApp.post('/api/v1/ftp/open/:id', upload.none(), async (req, res) => { + const downloadInput = JSON.parse(req.body.downloadInput); + const remotePath = downloadInput.path; + const fileName = downloadInput.names[0]; + const configId = req.params['id']; + + try { + await withFtpClient(configId, async (client) => { + const fullRemotePath = remotePath + fileName; + const tempDir = path.join(os.tmpdir(), 'ftp-temp-files'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir); + const tempFilePath = path.join(tempDir, uuid.v4() + fileName); + const writableStream = fs.createWriteStream(tempFilePath); + await client.downloadTo(writableStream, fullRemotePath); + + writableStream.on('finish', () => { + log.info(`File downloaded to ${tempFilePath}`); + res.send({ message: 'File downloaded successfully', localFilePath: tempFilePath, fileName }); + }); + + writableStream.on('error', (error) => { + log.error('Error writing file locally:', error); + res.status(500).send({ error: { code: 500, message: 'Error writing file locally' } }); + }); + + const result = await shell.openPath(tempFilePath); + if (result) { + log.error(`Error opening file: ${result}`); + } + + let watcher = fs.watch(tempFilePath, async (eventType) => { + if (eventType === 'change') { + log.info(`File modified: ${tempFilePath}`); + await withFtpClient(configId, async (clientUpdate) => { + const updatedBuffer = await fsPromise.readFile(tempFilePath); + const bufferStream = new Readable(); + bufferStream.push(updatedBuffer); + bufferStream.push(null); + await clientUpdate.uploadFrom(bufferStream, fullRemotePath); + log.info(`File updated successfully: ${fullRemotePath}`); + }); + } + }); + + setTimeout(() => { + watcher.close(); + fsPromise.unlink(tempFilePath) + .then(() => log.info('Temporary file deleted:', tempFilePath)) + .catch((err) => log.info('Error deleting temp file:', err)); + }, 10 * 60 * 1000); + }); + } catch (error) { + log.error('Error open file:', error); + res.status(400).send({ error: { code: 400, message: 'Error open file: ' + error.message } }); + } + }); +} + +module.exports = { initFtpHandler }; diff --git a/src-electron/ipc/scp.js b/src-electron/ipc/scp.js index cd2429f..4128ef9 100644 --- a/src-electron/ipc/scp.js +++ b/src-electron/ipc/scp.js @@ -1,313 +1,323 @@ -const { ipcMain } = require('electron'); +const { ipcMain, shell } = require('electron'); const SftpClient = require('ssh2-sftp-client'); const multer = require('multer'); const upload = multer(); const path = require('path'); const yazl = require('yazl'); +const fs = require('fs'); +const fsPromise = require('fs/promises'); +const os = require('os'); +const uuid = require('uuid'); +const { Readable } = require('stream'); + function initScpSftpHandler(log, scpMap, expressApp) { - ipcMain.handle('session.fe.scp.register', async (event, {id, config}) => { + ipcMain.handle('session.fe.scp.register', async (event, { id, config }) => { scpMap.set(id, config); }); -//==================== API ========================================================== - - expressApp.post('/api/v1/scp/:id', async (req, res) => { - const action = req.body.action || 'read'; - const pathParam = req.body.path || '/'; - - const configId = req.params['id']; + //==================== Utils ================================================= + async function withSftpClient(configId, callback) { const config = scpMap.get(configId); if (!config) { - res.status(400).send({ error: {code: 400, message: 'Error connection config not found'} }); + throw new Error('Error connection config not found'); } + + const sftp = new SftpClient(); try { - const sftp = new SftpClient(); await sftp.connect(config); - switch (action) { - case 'read': { - const files = await sftp.list(pathParam); + return await callback(sftp); + } finally { + await sftp.end(); + } + } + + async function avoidDuplicateName(sftp, targetFilePathOg) { + let targetFilePath = targetFilePathOg; + const parseFilePath = (filePath) => { + const fileName = path.basename(filePath, path.extname(filePath)); // Extract file name without extension + const fileExt = path.extname(filePath); // Extract extension + const dir = path.dirname(filePath); // Extract directory path + return { dir, fileName, fileExt }; + }; - const formattedFiles = files.map(file => ({ - name: file.name, - type: file.type === 'd' ? 'folder' : 'file', - isFile: file.type !== 'd', - size: file.size, - dateModified: file.modifyTime, - })); + const { dir, fileName, fileExt } = parseFilePath(targetFilePathOg); + let index = 1; // Start indexing from 1 for suffix - res.json({ cwd: { name: pathParam, type: 'folder' }, files: formattedFiles }); - break; - } - case 'search': { - const files = await sftp.list(pathParam); + // Check if file exists and generate new target path + while (await sftp.exists(targetFilePath)) { + targetFilePath = `${dir}/${fileName}_${index}${fileExt}`; + index++; + } + return targetFilePath; + } - const regexFlags = req.body.caseSensitive ? '' : 'i'; - const searchRegex = new RegExp(req.body.searchString.replace(/\*/g, '.*'), regexFlags); + async function getDetails(sftp, path, names = []) { + if (names.length === 0) { + const stats = await sftp.stat(path); + return { + name: path, + type: stats.isDirectory ? 'folder' : 'file', + size: stats.size, + accessTime: stats.atime, + modifyTime: stats.mtime, + createTime: stats.birthtime || null, + }; + } else { + const details = []; + for (const name of names) { + const fullPath = `${path}${name}`; + const stats = await sftp.stat(fullPath); + details.push({ + name, + type: stats.isDirectory ? 'folder' : 'file', + size: stats.size, + location: path, + modified: stats.mtime, + }); + } + return details; + } + } - const formattedFiles = []; - for (const item of files) { - const isHidden = item.name.startsWith('.'); - if (!req.body.showHiddenItems && isHidden) continue; + //==================== API ========================================================== + expressApp.post('/api/v1/scp/:id', async (req, res) => { + const action = req.body.action || 'read'; + const pathParam = req.body.path || '/'; + const configId = req.params['id']; - if (searchRegex.test(item.name)) { - formattedFiles.push({ - name: item.name, - type: item.type === '-' ? 'file' : 'folder', - size: item.size, - modifyTime: item.modifyTime, - accessTime: item.accessTime, - }); - } + try { + const result = await withSftpClient(configId, async (sftp) => { + switch (action) { + case 'read': { + const files = await sftp.list(pathParam); + const formattedFiles = files.map(file => ({ + name: file.name, + type: file.type === 'd' ? 'folder' : 'file', + isFile: file.type !== 'd', + size: file.size, + dateModified: file.modifyTime, + })); + return { cwd: { name: pathParam, type: 'folder' }, files: formattedFiles }; } - - res.json({ cwd: { name: pathParam, type: 'folder' }, files: formattedFiles }); - break; - } - case 'delete': { - const data = req.body.data || []; - const details = []; - for (let i = 0; i < data.length; i++) { - const oneData = data[i]; - const name = oneData.name; - const fileAbsPath = `${pathParam}${name}`; - details.push(await getDetails(sftp, pathParam, [name])); - if (oneData.type === 'folder') { - await sftp.rmdir(fileAbsPath, true); - } else { - await sftp.delete(fileAbsPath); + case 'search': { + const files = await sftp.list(pathParam); + const regexFlags = req.body.caseSensitive ? '' : 'i'; + const searchRegex = new RegExp(req.body.searchString.replace(/\*/g, '.*'), regexFlags); + + const formattedFiles = files.filter(item => { + const isHidden = item.name.startsWith('.'); + return (req.body.showHiddenItems || !isHidden) && searchRegex.test(item.name); + }).map(item => ({ + name: item.name, + type: item.type === '-' ? 'file' : 'folder', + size: item.size, + modifyTime: item.modifyTime, + accessTime: item.accessTime, + })); + + return { cwd: { name: pathParam, type: 'folder' }, files: formattedFiles }; + } + case 'delete': { + const data = req.body.data || []; + for (const oneData of data) { + const fileAbsPath = `${pathParam}${oneData.name}`; + if (oneData.type === 'folder') { + await sftp.rmdir(fileAbsPath, true); + } else { + await sftp.delete(fileAbsPath); + } } + return { cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, pathParam) }; } - res.json({ cwd: { name: pathParam, type: 'folder' }, files: details}); - break; - } - case 'rename': { - const name = req.body.name; - const newName = req.body.newName; - await sftp.rename(`${pathParam}${name}`, `${pathParam}${newName}`); - res.json({ cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, pathParam, newName) }); - break; - } - case 'copy': { - const names = req.body.names || []; - const targetPath = req.body.targetPath; - for (const name of names) { - const sourceFilePath = `${pathParam}${name}`; - const targetFilePathOg = `${targetPath}${name}`; - const targetFilePath = await avoidDuplicateName(sftp, targetFilePathOg); - // Copy file - await sftp.rcopy(sourceFilePath, targetFilePath); + case 'rename': { + const name = req.body.name; + const newName = req.body.newName; + await sftp.rename(`${pathParam}${name}`, `${pathParam}${newName}`); + return { cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, pathParam, [newName]) }; } - res.json({ cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, targetPath, names) }); - break; - } - case 'move': { - const names = req.body.names || []; - const targetPath = req.body.targetPath; - if (targetPath === pathParam) { - break; + case 'copy': { + const names = req.body.names || []; + const targetPath = req.body.targetPath; + for (const name of names) { + const sourceFilePath = `${pathParam}${name}`; + const targetFilePath = await avoidDuplicateName(sftp, `${targetPath}${name}`); + await sftp.rcopy(sourceFilePath, targetFilePath); + } + return { cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, targetPath, names) }; } - for (const name of names) { - const sourceFilePath = `${pathParam}${name}`; - const targetFilePathOg = `${targetPath}${name}`; - const targetFilePath = await avoidDuplicateName(sftp, targetFilePathOg); - // Copy file - await sftp.rcopy(sourceFilePath, targetFilePath); - await sftp.delete(sourceFilePath, true); + case 'move': { + const names = req.body.names || []; + const targetPath = req.body.targetPath; + if (targetPath !== pathParam) { + for (const name of names) { + const sourceFilePath = `${pathParam}${name}`; + const targetFilePath = await avoidDuplicateName(sftp, `${targetPath}${name}`); + await sftp.rcopy(sourceFilePath, targetFilePath); + await sftp.delete(sourceFilePath, true); + } + } + return { cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, targetPath, names) }; } - res.json({ cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, targetPath, names) }); - break; - } - case 'create': { // for now only mkdir is possible - const name = req.body.name; - const newFolderPath = `${pathParam}${name}`; - if (await sftp.exists(newFolderPath)) { - res.json({ cwd: { name: pathParam, type: 'folder' }, error: {code: 416, message: 'folder already exists'} }); - } else { - // Create the folder - await sftp.mkdir(newFolderPath, true); - - res.json({ cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, newFolderPath) }); + case 'create': { + const name = req.body.name; + const newFolderPath = `${pathParam}${name}`; + if (await sftp.exists(newFolderPath)) { + return { cwd: { name: pathParam, type: 'folder' }, error: { code: 416, message: 'folder already exists' } }; + } else { + await sftp.mkdir(newFolderPath, true); + return { cwd: { name: pathParam, type: 'folder' }, files: await getDetails(sftp, newFolderPath) }; + } } - break; - } - case 'details': { - const names = req.body.names || []; - res.json({ cwd: { name: pathParam, type: 'folder' }, details: await getDetails(sftp, pathParam, names) }); - break; + case 'details': { + const names = req.body.names || []; + return { cwd: { name: pathParam, type: 'folder' }, details: await getDetails(sftp, pathParam, names) }; + } + default: + throw new Error(`Unknown action: ${action}`); } + }); - } - - await sftp.end(); + res.json(result); } catch (error) { - log.error('Error listing files:', error); - res.status(500).send({ error: {code: 500, message: error} }); + log.error('Error handling SCP/SFTP request:', error); + res.status(500).send({ error: { code: 500, message: error.message } }); } }); -// File Upload expressApp.post('/api/v1/scp/upload/:id', upload.single('uploadFiles'), async (req, res) => { - const { data } = req.body; - const path = JSON.parse(data).name; // path is incorrect on req.body + const { data } = req.body; + const path = JSON.parse(data).name; const configId = req.params['id']; - const config = scpMap.get(configId); - if (!config) { - log.error('Error connection config not found'); - res.status(400).send({ error: {code: 400, message: 'Error connection config not found'} }); - } + if (!req.file) { log.error('Error: No file uploaded'); - res.status(400).send({ error: {code: 400, message: 'No file uploaded'} }); + res.status(400).send({ error: { code: 400, message: 'No file uploaded' } }); + return; } try { - const sftp = new SftpClient(); - - // Connect to the SFTP server - await sftp.connect(config); - - // Upload the file - const remotePath = avoidDuplicateName(sftp, `${path}/${req.file.originalname}`); - await sftp.put(req.file.buffer, remotePath); - - res.json({ success: true, message: `File uploaded to ${remotePath}` }); + const result = await withSftpClient(configId, async (sftp) => { + const remotePath = await avoidDuplicateName(sftp, `${path}/${req.file.originalname}`); + await sftp.put(req.file.buffer, remotePath); + return { success: true, message: `File uploaded to ${remotePath}` }; + }); - // End the SFTP connection - await sftp.end(); + res.json(result); } catch (error) { log.error('Error uploading file:', error); - res.status(400).send({ error: {code: 400, message: 'Error uploading file:' + error} }); + res.status(400).send({ error: { code: 400, message: 'Error uploading file: ' + error.message } }); } }); -// File Download expressApp.post('/api/v1/scp/download/:id', upload.none(), async (req, res) => { const downloadInput = JSON.parse(req.body.downloadInput); const path = downloadInput.path; - const names = downloadInput.names; // Assuming a single file download - + const names = downloadInput.names; const configId = req.params['id']; - const config = scpMap.get(configId); - if (!config) { - log.error('Error connection config not found'); - res.status(400).send({ error: {code: 400, message: 'Error connection config not found'} }); - } - try { - const sftp = new SftpClient(); - await sftp.connect(config); - - if (names.length === 1) { - const fullPath = path + names[0]; - const buffer = await sftp.get(fullPath); - - res.set('Content-Disposition', `attachment; filename=${names[0]}`); - res.send(buffer); - - - await sftp.end(); - - } else if (names.length > 1) { - - res.setHeader('Content-Disposition', 'attachment; filename="download.zip"'); - res.setHeader('Content-Type', 'application/zip'); - // Create a new ZIP file instance - const zipfile = new yazl.ZipFile(); - - for (const name of names) { - const fullPath = `${path}${name}`; - - try { - // Fetch the file as a buffer from the server - const buffer = await sftp.get(fullPath); - - // Add the buffer to the ZIP file - zipfile.addBuffer(buffer, name); - } catch (fileError) { - log.error(`Error fetching file ${fullPath}:`, fileError.message); + try { + await withSftpClient(configId, async (sftp) => { + if (names.length === 1) { + const fullRemotePath = path + names[0]; + const buffer = await sftp.get(fullRemotePath); + res.set('Content-Disposition', `attachment; filename=${names[0]}`); + res.send(buffer); + } else if (names.length > 1) { + res.setHeader('Content-Disposition', 'attachment; filename="download.zip"'); + res.setHeader('Content-Type', 'application/zip'); + const zipfile = new yazl.ZipFile(); + for (const name of names) { + const fullRemotePath = `${path}${name}`; + try { + const buffer = await sftp.get(fullRemotePath); + zipfile.addBuffer(buffer, name); + } catch (fileError) { + log.error(`Error fetching file ${fullRemotePath}:`, fileError.message); + } } + zipfile.outputStream.pipe(res).on('close', () => { + log.info('ZIP file successfully sent.'); + }); + zipfile.end(); } - - // Finalize the ZIP and pipe it to the response - zipfile.outputStream.pipe(res).on('close', () => { - log.info('ZIP file successfully sent.'); - }); - zipfile.end(); - - await sftp.end(); - } - + }); } catch (error) { log.error('Error downloading file:', error); - res.status(400).send({ error: {code: 400, message: 'Error download file:' + error} }); + res.status(400).send({ error: { code: 400, message: 'Error downloading file: ' + error.message } }); } }); -//================= Utils ================================================= - async function avoidDuplicateName(sftp, targetFilePathOg) { + expressApp.post('/api/v1/scp/open/:id', upload.none(), async (req, res) => { + const downloadInput = JSON.parse(req.body.downloadInput); + const remotePath = downloadInput.path; + const fileName = downloadInput.names[0]; // Assuming a single file + const configId = req.params['id']; - let targetFilePath = targetFilePathOg; - const parseFilePath = (filePath) => { - const fileName = path.basename(filePath, path.extname(filePath)); // Extract file name without extension - const fileExt = path.extname(filePath); // Extract extension - const dir = path.dirname(filePath); // Extract directory path - return {dir, fileName, fileExt}; - }; + try { + await withSftpClient(configId, async (sftp) => { + const fullRemotePath = `${remotePath}${fileName}`; + const tempDir = path.join(os.tmpdir(), 'scp-temp-files'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } - const {dir, fileName, fileExt} = parseFilePath(targetFilePathOg); - let index = 1; // Start indexing from 1 for suffix + // Generate a unique temporary file path + const tempFilePath = path.join(tempDir, uuid.v4() + fileName); - // Check if file exists and generate new target path - while (await sftp.exists(targetFilePath)) { - targetFilePath = `${dir}/${fileName}_${index}${fileExt}`; - index++; - } - return targetFilePath; - } - async function getDetails(sftp, path, names = undefined) { - if (!names || names.length === 0) { - const fullPath = `${path}`; - const stats = await sftp.stat(fullPath); + // Download the file from the SCP server + await sftp.get(fullRemotePath, tempFilePath); - return { - name: fullPath, - type: stats.isDirectory ? 'folder' : 'file', - size: stats.size, - accessTime: stats.atime, - modifyTime: stats.mtime, - createTime: stats.birthtime || null, // birthtime may not always be available - }; - } else { - let details; - for (const name of names) { - const fullPath = `${path}${name}`; - const stats = await sftp.stat(fullPath); - if (details) { - details.size = details.size + stats.size; - details.name = details.name + ", " + name; - details.multipleFiles = true; - details.type = undefined; - details.modified = undefined; - } else { - details = { - name: name, - type: stats.isDirectory ? 'folder' : 'file', - size: stats.size, - location: path, - modified: stats.modifyTime, - } + // Open the file with the default system application + const result = await shell.openPath(tempFilePath); + if (result) { + log.error(`Error opening file: ${result}`); + res.status(500).send({ error: { code: 500, message: 'Error opening file' } }); + return; } - } - return details; - } - - } + // Watch the file for changes + let watcher = fs.watch(tempFilePath, async (eventType) => { + if (eventType === 'change') { + log.info(`File modified: ${tempFilePath}`); + + await withSftpClient(configId, async (sftpUpdate) => { + try { + // Read the updated file into a buffer + const updatedBuffer = await fsPromise.readFile(tempFilePath); + + // Convert the buffer into a readable stream + const bufferStream = new Readable(); + bufferStream.push(updatedBuffer); + bufferStream.push(null); + + // Re-upload the updated file to the SCP server + await sftpUpdate.put(bufferStream, fullRemotePath); + log.info(`File updated successfully: ${fullRemotePath}`); + } catch (error) { + log.error('Error uploading updated file:', error); + } + }); + } + }); -// Start API - expressApp.listen(13012, () => log.info('API listening on port 13012')); + // Clean up watcher and temporary file after a timeout (optional) + setTimeout(async () => { + watcher.close(); + try { + await fsPromise.unlink(tempFilePath); + log.info('Temporary file deleted:', tempFilePath); + } catch (err) { + log.error('Error deleting temporary file:', err); + } + }, 10 * 60 * 1000); // Stop watching after 10 minutes + }); + } catch (error) { + log.error('Error open file:', error); + res.status(400).send({ error: { code: 400, message: 'Error open file: ' + error.message } }); + } + }); } -module.exports = {initScpSftpHandler}; +module.exports = { initScpSftpHandler }; diff --git a/src/app/app.component.html b/src/app/app.component.html index 21954bc..c27ab5c 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -26,11 +26,15 @@ - + @for (tab of tabService.tabs ; let i = $index; track tab.id) { -
+
@if (!tab.connected) { + } + + + + Port + + + + Secured + + + Init Path + + @if (form.get('path')?.value) { + + } + + + + + @for (oneType of AUTH_OPTIONS | keyvalue : unordered; track oneType.value) { + {{ oneType.value }} + } + + + @switch (form.get('authType')?.value) { + @case (AUTH_OPTIONS.LOGIN) { + + Login + + @if (form.get('login')?.value) { + + } + + + + Password + + + + + Confirm Password + + + } + + @case (AUTH_OPTIONS.SECRET) { + + Secret + + + add + Add New... + + @for (oneSecret of secretStorageService.dataCopy.secrets; track oneSecret.id) { + + @if (oneSecret.icon) { + {{oneSecret.icon}} + } + {{ secretService.displaySecretOptionName(oneSecret) }} + + } + + @if (form.get('secretId')?.value) { + + } + + } + } +
diff --git a/src/app/components/menu/profile-form/ftp-profile-form/ftp-profile-form.component.ts b/src/app/components/menu/profile-form/ftp-profile-form/ftp-profile-form.component.ts new file mode 100644 index 0000000..f5b3f51 --- /dev/null +++ b/src/app/components/menu/profile-form/ftp-profile-form/ftp-profile-form.component.ts @@ -0,0 +1,147 @@ +import {Component, forwardRef, Input} from '@angular/core'; +import {CdkTextareaAutosize} from "@angular/cdk/text-field"; +import { + FormBuilder, FormGroup, + FormsModule, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validators +} from "@angular/forms"; +import {CommonModule} from "@angular/common"; +import {MatFormFieldModule} from "@angular/material/form-field"; +import {MatIcon} from "@angular/material/icon"; +import {MatIconButton} from "@angular/material/button"; +import {MatInput} from "@angular/material/input"; +import {MatRadioModule} from "@angular/material/radio"; +import {MatSelectChange, MatSelectModule} from "@angular/material/select"; +import {ChildFormAsFormControl} from '../../../enhanced-form-mixin'; +import {MenuComponent} from '../../menu.component'; +import {AuthType, SecretType} from '../../../../domain/Secret'; +import { + FormFieldWithPrecondition, + ModelFieldWithPrecondition, + ModelFormController +} from '../../../../utils/ModelFormController'; +import {SecretStorageService} from '../../../../services/secret-storage.service'; +import {SecretService} from '../../../../services/secret.service'; +import {SettingStorageService} from '../../../../services/setting-storage.service'; +import {MatDialog} from '@angular/material/dialog'; +import {SecretQuickFormComponent} from '../../../dialog/secret-quick-form/secret-quick-form.component'; +import {FTPProfile} from '../../../../domain/profile/FTPProfile'; +import {MatSlideToggle} from '@angular/material/slide-toggle'; + +@Component({ + selector: 'app-ftp-profile-form', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatSelectModule, + MatRadioModule, + MatFormFieldModule, + + CdkTextareaAutosize, + + MatInput, + MatIcon, + MatIconButton, + MatSlideToggle, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FtpProfileFormComponent), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FtpProfileFormComponent), + multi: true, + }, + ], + templateUrl: './ftp-profile-form.component.html', + styleUrl: './ftp-profile-form.component.css' +}) +export class FtpProfileFormComponent extends ChildFormAsFormControl(MenuComponent) { + + AUTH_OPTIONS = AuthType; + + @Input() type!: String; + private modelFormController : ModelFormController; + constructor( + private fb: FormBuilder, + public secretStorageService: SecretStorageService, // in html + + public secretService: SecretService, // in html + public settingStorage: SettingStorageService, + + public dialog: MatDialog, + ) { + super(); + + let mappings = new Map(); + mappings.set('host' , {name: 'host', formControlOption: ['', [Validators.required]]}); + mappings.set('port' , {name: 'port', formControlOption: ['', [Validators.required]]}); + mappings.set('secured' ,'secured'); + mappings.set('initPath' , 'path'); + mappings.set('authType' , {name: 'authType', formControlOption: ['', [Validators.required]]}); + mappings.set({name: 'login', precondition: form => this.form.get('authType')?.value == 'login'} , 'login'); + mappings.set({name: 'password', precondition: form => this.form.get('authType')?.value == 'login'} , 'password'); + mappings.set({name: 'password', precondition: form => false } , 'confirmPassword'); // we don't set model.password via confirmPassword control + mappings.set({name: 'secretId', precondition: form => this.form.get('authType')?.value == 'secret' } , 'secretId'); + + this.modelFormController = new ModelFormController(mappings); + } + + onInitForm(): FormGroup { + return this.modelFormController.onInitForm(this.fb, {validators: [this.secretOrPasswordMatchValidator]}); // we shall avoid use ngModel and formControl at same time + } + + + secretOrPasswordMatchValidator(group: FormGroup) { + // let authType = group.get('authType')?.value; + // if (authType == 'login') { + // group.get('password')?.addValidators(Validators.required); + // group.get('confirmPassword')?.addValidators(Validators.required); + // group.get('secretId')?.removeValidators(Validators.required); + // const password = group.get('password')?.value; + // const confirmPassword = group.get('confirmPassword')?.value; + // return password === confirmPassword ? null : { passwordMismatch: true }; + // } else if (authType == 'secret') { + // group.get('password')?.removeValidators(Validators.required); + // group.get('confirmPassword')?.removeValidators(Validators.required); + // group.get('secretId')?.addValidators(Validators.required); + // return group.get('secretId')?.value ? null : {secretRequired: true}; + // } else { + // + // return {authTypeRequired: true}; + // } + return null; + } + + onSelectSecret($event: MatSelectChange) { + this.form.get('password')?.setValue(null); + this.form.get('confirmPassword')?.setValue(null); + } + + override refreshForm(ssh: any) { + if (this.form) { + this.modelFormController.refreshForm(ssh, this.form); + } + } + + override formToModel(): FTPProfile { + return this.modelFormController.formToModel(new FTPProfile(), this.form); + } + + quickCreateSecret() { + this.dialog.open(SecretQuickFormComponent, { + width: '650px', + data: { + secretTypes: [SecretType.LOGIN_PASSWORD, SecretType.PASSWORD_ONLY, SecretType.SSH_KEY] + } + }); + } +} diff --git a/src/app/components/menu/profile-form/profile-form.component.html b/src/app/components/menu/profile-form/profile-form.component.html index e24add2..f9b888d 100644 --- a/src/app/components/menu/profile-form/profile-form.component.html +++ b/src/app/components/menu/profile-form/profile-form.component.html @@ -65,7 +65,7 @@ - Terminal Category + Category @for ( oneCat of CATEGORY_OPTIONS | keyvalue : unordered; track oneCat.value) { {{oneCat.value}} @@ -74,7 +74,7 @@ @if (form.get('category')?.value) { - Terminal Type + Connection Type {{oneType.value}} @@ -118,8 +118,8 @@ } - @case('CUSTOM') { - + @case('FTP_FILE_EXPLORER') { + } } } diff --git a/src/app/components/menu/profile-form/profile-form.component.ts b/src/app/components/menu/profile-form/profile-form.component.ts index fdd7c67..c21960c 100644 --- a/src/app/components/menu/profile-form/profile-form.component.ts +++ b/src/app/components/menu/profile-form/profile-form.component.ts @@ -28,6 +28,8 @@ import {CustomProfileFormComponent} from './custom-profile-form/custom-profile-f import {CustomProfile} from '../../../domain/profile/CustomProfile'; import {LogService} from '../../../services/log.service'; import {NotificationService} from '../../../services/notification.service'; +import {FtpProfileFormComponent} from './ftp-profile-form/ftp-profile-form.component'; +import {FTPProfile} from '../../../domain/profile/FTPProfile'; @Component({ selector: 'app-profile-form', @@ -52,6 +54,7 @@ import {NotificationService} from '../../../services/notification.service'; RdpProfileFormComponent, VncProfileFormComponent, CustomProfileFormComponent, + FtpProfileFormComponent, ], templateUrl: './profile-form.component.html', styleUrl: './profile-form.component.scss' @@ -80,6 +83,7 @@ export class ProfileFormComponent extends IsAChildForm(MenuComponent) implements @ViewChild(RdpProfileFormComponent) rdpChild!: RdpProfileFormComponent; @ViewChild(VncProfileFormComponent) vncChild!: VncProfileFormComponent; @ViewChild(CustomProfileFormComponent) customChild!: CustomProfileFormComponent; + @ViewChild(FtpProfileFormComponent) ftpChild!: FtpProfileFormComponent; constructor( private log: LogService, @@ -116,24 +120,23 @@ export class ProfileFormComponent extends IsAChildForm(MenuComponent) implements onInitForm(): FormGroup { - let form = this.fb.group( + return this.fb.group( { - name: [this._profile.name, [Validators.required, Validators.minLength(3)]], // we shall avoid use ngModel and formControl at same time - comment: [this._profile.comment], - category: [this._profile.category, Validators.required], - group: [], - tags: [[]], - profileType: [this._profile.profileType, Validators.required], - sshProfileForm: [this._profile.sshProfile], - rdpProfileForm: [this._profile.rdpProfile], - vncProfileForm: [this._profile.vncProfile], - customProfileForm: [this._profile.customProfile], + name: [this._profile.name, [Validators.required, Validators.minLength(3)]], // we shall avoid use ngModel and formControl at same time + comment: [this._profile.comment], + category: [this._profile.category, Validators.required], + group: [], + tags: [[]], + profileType: [this._profile.profileType, Validators.required], + sshProfileForm: [this._profile.sshProfile], + ftpProfileForm: [this._profile.ftpProfile], + rdpProfileForm: [this._profile.rdpProfile], + vncProfileForm: [this._profile.vncProfile], + customProfileForm: [this._profile.customProfile], }, {validators: []} ); - - return form; } onSelectCategory($event: MatSelectChange) { @@ -157,6 +160,9 @@ export class ProfileFormComponent extends IsAChildForm(MenuComponent) implements case ProfileType.SCP_FILE_EXPLORER: this.form.get('sshProfileForm')?.setValue(new SSHProfile()); break; + case ProfileType.FTP_FILE_EXPLORER: + this.form.get('ftpProfileForm')?.setValue(new FTPProfile()); + break; case ProfileType.RDP_REMOTE_DESKTOP: this.form.get('rdpProfileForm')?.setValue(new RdpProfile()); @@ -204,6 +210,9 @@ export class ProfileFormComponent extends IsAChildForm(MenuComponent) implements case ProfileType.SCP_FILE_EXPLORER: this.updateFormValue('sshProfileForm', profile?.sshProfile); break; + case ProfileType.FTP_FILE_EXPLORER: + this.updateFormValue('ftpProfileForm', profile?.ftpProfile); + break; case ProfileType.RDP_REMOTE_DESKTOP: this.updateFormValue('rdpProfileForm', profile?.rdpProfile); break; @@ -238,6 +247,7 @@ export class ProfileFormComponent extends IsAChildForm(MenuComponent) implements this._profile.rdpProfile = this.rdpChild?.formToModel(); this._profile.vncProfile = this.vncChild?.formToModel(); this._profile.customProfile = this.customChild?.formToModel(); + this._profile.ftpProfile = this.ftpChild?.formToModel(); return this._profile; diff --git a/src/app/domain/electronConstant.ts b/src/app/domain/electronConstant.ts index 4457d44..91b2e5c 100644 --- a/src/app/domain/electronConstant.ts +++ b/src/app/domain/electronConstant.ts @@ -20,6 +20,7 @@ export const SESSION_DISCONNECT_VNC = 'session.disconnect.rd.vnc'; export const SESSION_OPEN_CUSTOM = 'session.open.custom'; export const SESSION_SCP_REGISTER= 'session.fe.scp.register'; +export const SESSION_FTP_REGISTER= 'session.fe.ftp.register'; //#endregion "Sessions" diff --git a/src/app/domain/profile/FTPProfile.ts b/src/app/domain/profile/FTPProfile.ts new file mode 100644 index 0000000..50dd1ed --- /dev/null +++ b/src/app/domain/profile/FTPProfile.ts @@ -0,0 +1,15 @@ +import {AuthType} from '../Secret'; + +export class FTPProfile { + public host: string = ''; + public port: number = 21; + + public initPath?: string; + public secured: boolean = false; + + public authType?: AuthType; + public login: string = ''; + public password: string = ''; + public secretId!: string; + +} diff --git a/src/app/domain/profile/Profile.ts b/src/app/domain/profile/Profile.ts index 1fc5471..a11c244 100644 --- a/src/app/domain/profile/Profile.ts +++ b/src/app/domain/profile/Profile.ts @@ -5,6 +5,7 @@ import {Secret} from '../Secret'; import {RdpProfile} from './RdpProfile'; import {VncProfile} from './VncProfile'; import {CustomProfile} from './CustomProfile'; +import {FTPProfile} from './FTPProfile'; export enum ProfileCategory { TERMINAL = 'TERMINAL', @@ -21,6 +22,7 @@ export enum ProfileType { VNC_REMOTE_DESKTOP = 'VNC_REMOTE_DESKTOP', RDP_REMOTE_DESKTOP = 'RDP_REMOTE_DESKTOP', SCP_FILE_EXPLORER = 'SCP_FILE_EXPLORER', + FTP_FILE_EXPLORER = 'FTP_FILE_EXPLORER', SMB_FILE_EXPLORER = 'SMB_FILE_EXPLORER', CUSTOM = 'CUSTOM', @@ -40,6 +42,7 @@ export const ProfileCategoryTypeMap = new Map([ [ProfileCategory.FILE_EXPLORER, [ ProfileType.SCP_FILE_EXPLORER, + ProfileType.FTP_FILE_EXPLORER, // ProfileType.SMB_FILE_EXPLORER, ]], @@ -98,6 +101,7 @@ export class Profile { public profileType!: ProfileType; public localTerminal!: LocalTerminalProfile; public sshProfile!: SSHProfile; + public ftpProfile!: FTPProfile; public rdpProfile!: RdpProfile; public vncProfile!: VncProfile; public customProfile!: CustomProfile; @@ -111,6 +115,7 @@ export class Profile { constructor() { this.localTerminal = new LocalTerminalProfile(); this.sshProfile = new SSHProfile(); + this.ftpProfile = new FTPProfile(); this.rdpProfile = new RdpProfile(); this.vncProfile = new VncProfile(); this.customProfile = new CustomProfile(); @@ -125,6 +130,7 @@ export class Profile { cloned.profileType = base.profileType; cloned.localTerminal = base.localTerminal; cloned.sshProfile = base.sshProfile; + cloned.ftpProfile = base.ftpProfile; cloned.vncProfile = base.vncProfile; cloned.rdpProfile = base.rdpProfile; cloned.customProfile = base.customProfile; @@ -144,6 +150,7 @@ export class Profile { switch (profile.profileType) { case ProfileType.SCP_FILE_EXPLORER: case ProfileType.SSH_TERMINAL: return profile.sshProfile.secretId == secret.id; + case ProfileType.FTP_FILE_EXPLORER: return profile.ftpProfile.secretId == secret.id; case ProfileType.VNC_REMOTE_DESKTOP: return profile.vncProfile.secretId == secret.id; case ProfileType.CUSTOM: return profile.customProfile.secretId == secret.id; @@ -159,7 +166,8 @@ export class Profile { case ProfileType.SCP_FILE_EXPLORER: case ProfileType.SSH_TERMINAL: profile.sshProfile.secretId = ''; break; - + case ProfileType.FTP_FILE_EXPLORER: + profile.ftpProfile.secretId = ''; break; case ProfileType.VNC_REMOTE_DESKTOP: profile.vncProfile.secretId = ''; break; case ProfileType.CUSTOM: diff --git a/src/app/domain/session/TerminalSession.ts b/src/app/domain/session/FtpSession.ts similarity index 59% rename from src/app/domain/session/TerminalSession.ts rename to src/app/domain/session/FtpSession.ts index 5ae7c7a..2929325 100644 --- a/src/app/domain/session/TerminalSession.ts +++ b/src/app/domain/session/FtpSession.ts @@ -2,24 +2,27 @@ import {Session} from './Session'; import {Profile, ProfileType} from '../profile/Profile'; import {ElectronService} from '../../services/electron.service'; import {TabService} from '../../services/tab.service'; +import {ScpService} from '../../services/scp.service'; +import {FtpService} from '../../services/ftp.service'; -export class TerminalSession extends Session { +export class FtpSession extends Session { constructor(profile: Profile, profileType: ProfileType, tabService: TabService, - private electron: ElectronService + private ftpService: FtpService, ) { super(profile, profileType, tabService); } override close(): void { - this.electron.closeTerminalSession(this); super.close(); } override open(): void { - this.electron.openTerminalSession(this); + this.ftpService.connect(this.id, this.profile.ftpProfile).then( + () => this.tabService.connected(this.id) + ); super.open(); } diff --git a/src/app/domain/session/LocalTerminalSession.ts b/src/app/domain/session/LocalTerminalSession.ts index 36ba126..a9dbf57 100644 --- a/src/app/domain/session/LocalTerminalSession.ts +++ b/src/app/domain/session/LocalTerminalSession.ts @@ -1,5 +1,25 @@ -import {TerminalSession} from './TerminalSession'; -export class LocalTerminalSession extends TerminalSession { +import {Profile, ProfileType} from '../profile/Profile'; +import {TabService} from '../../services/tab.service'; +import {ElectronService} from '../../services/electron.service'; +import {Session} from './Session'; +export class LocalTerminalSession extends Session { + + constructor(profile: Profile, profileType: ProfileType, + tabService: TabService, + private electron: ElectronService + ) { + super(profile, profileType, tabService); + } + + override close(): void { + this.electron.closeLocalTerminalSession(this); + super.close(); + } + + override open(): void { + this.electron.openLocalTerminalSession(this); + super.open(); + } } diff --git a/src/app/domain/session/SSHSession.ts b/src/app/domain/session/SSHSession.ts index 2a362b8..89dbc6d 100644 --- a/src/app/domain/session/SSHSession.ts +++ b/src/app/domain/session/SSHSession.ts @@ -1,5 +1,24 @@ -import {TerminalSession} from './TerminalSession'; +import {Profile, ProfileType} from '../profile/Profile'; +import {TabService} from '../../services/tab.service'; +import {ElectronService} from '../../services/electron.service'; +import {Session} from './Session'; -export class SSHSession extends TerminalSession { +export class SSHSession extends Session { + constructor(profile: Profile, profileType: ProfileType, + tabService: TabService, + private electron: ElectronService + ) { + super(profile, profileType, tabService); + } + + override close(): void { + this.electron.closeSSHTerminalSession(this); + super.close(); + } + + override open(): void { + this.electron.openSSHTerminalSession(this); + super.open(); + } } diff --git a/src/app/services/electron.service.ts b/src/app/services/electron.service.ts index 7333ff8..66f41e1 100644 --- a/src/app/services/electron.service.ts +++ b/src/app/services/electron.service.ts @@ -26,7 +26,7 @@ import { CLIPBOARD_PASTE, TRIGGER_NATIVE_CLIPBOARD_PASTE, SESSION_OPEN_CUSTOM, - SESSION_SCP_REGISTER, SESSION_CLOSE_LOCAL_TERMINAL, SESSION_CLOSE_SSH_TERMINAL, LOG + SESSION_SCP_REGISTER, SESSION_CLOSE_LOCAL_TERMINAL, SESSION_CLOSE_SSH_TERMINAL, LOG, SESSION_FTP_REGISTER } from '../domain/electronConstant'; import {LocalTerminalProfile} from '../domain/profile/LocalTerminalProfile'; import {Profile, ProfileType} from '../domain/profile/Profile'; @@ -42,7 +42,7 @@ import {SSHProfile} from '../domain/profile/SSHProfile'; import {Session} from '../domain/session/Session'; import {Log} from '../domain/Log'; import {NotificationService} from './notification.service'; -import {GeneralSettings} from '../domain/setting/GeneralSettings'; +import {FTPProfile} from '../domain/profile/FTPProfile'; @Injectable({ @@ -137,30 +137,23 @@ export class ElectronService { }); } - openTerminalSession(session: Session) { + closeSSHTerminalSession(session: Session) { if (this.ipc) { - switch (session.profileType) { - case ProfileType.LOCAL_TERMINAL: this.openLocalTerminalSession(session); break; - case ProfileType.SSH_TERMINAL: this.openSSHTerminalSession(session); break; - - - } + this.ipc.send(SESSION_CLOSE_SSH_TERMINAL, {terminalId: session.id}); } } - closeTerminalSession(session: Session) { + + closeLocalTerminalSession(session: Session) { if (this.ipc) { - switch (session.profileType) { - case ProfileType.LOCAL_TERMINAL: - this.ipc.send(SESSION_CLOSE_LOCAL_TERMINAL, {terminalId: session.id}); - break; - case ProfileType.SSH_TERMINAL: - this.ipc.send(SESSION_CLOSE_SSH_TERMINAL, {terminalId: session.id}); - break; - } + this.ipc.send(SESSION_CLOSE_LOCAL_TERMINAL, {terminalId: session.id}); } } - private openLocalTerminalSession(session: Session) { + openLocalTerminalSession(session: Session) { + if (!this.ipc) { + this.log({level: 'error', message : "Invalid configuration"}); + return; + } if (!session.profile) { session.profile = new Profile(); } @@ -171,8 +164,8 @@ export class ElectronService { this.ipc.send(SESSION_OPEN_LOCAL_TERMINAL, {terminalId: session.id, terminalExec: localProfile.execPath}); } - private openSSHTerminalSession(session: Session) { - if (!session.profile || !session.profile.sshProfile) { + openSSHTerminalSession(session: Session) { + if (!this.ipc || !session.profile || !session.profile.sshProfile) { this.log({level: 'error', message : "Invalid configuration"}); return; } @@ -354,6 +347,51 @@ export class ElectronService { await this.ipc.invoke(SESSION_SCP_REGISTER, {id: id, config: sshConfig}); } + + async registerFtpSession(id: string, ftpProfile: FTPProfile) { + if (!id || !ftpProfile) { + this.log({level: 'error', message : "Invalid configuration"}); + return; + } + let ftpConfig: any = { + }; + + ftpConfig.host = ftpProfile.host; + ftpConfig.port = ftpProfile.port; + ftpConfig.secured = ftpProfile.secured; + if (ftpProfile.authType == AuthType.LOGIN) { + ftpConfig.user = ftpProfile.login; + ftpConfig.password = ftpProfile.password; + } else if (ftpProfile.authType == AuthType.SECRET) { + let secret = this.secretStorage.findById(ftpProfile.secretId); + if (!secret) { + this.log({level: 'error', message : "Invalid secret " + ftpProfile.secretId}); + return; + } + switch (secret.secretType) { + case SecretType.LOGIN_PASSWORD: { + ftpConfig.user = secret.login; + ftpConfig.password = secret.password; + break; + } + case SecretType.SSH_KEY: { + ftpConfig.user = secret.login; + ftpConfig.privateKey = secret.key.replace(/\\n/g, '\n'); + if (secret.passphrase) { + ftpConfig.passphrase = secret.passphrase; + } + break; + } + case SecretType.PASSWORD_ONLY: { + // todo + break; + } + } + } + await this.ipc.invoke(SESSION_FTP_REGISTER, {id: id, config: ftpConfig}); + } + + //#endregion "Sessions" diff --git a/src/app/services/ftp.service.ts b/src/app/services/ftp.service.ts new file mode 100644 index 0000000..2f993c1 --- /dev/null +++ b/src/app/services/ftp.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import {ElectronService} from './electron.service'; +import {SSHProfile} from '../domain/profile/SSHProfile'; +import {Session} from '../domain/session/Session'; +import {FTPProfile} from '../domain/profile/FTPProfile'; + +@Injectable({ + providedIn: 'root' +}) +export class FtpService { + + private apiUrl = 'http://localhost:13012/api/v1/ftp'; + + constructor(private electron: ElectronService) { } + + async connect(id:string , ftpProfile: FTPProfile) { + return this.electron.registerFtpSession(id , ftpProfile); + } + + setup(session: Session) { + return { + url: this.apiUrl + '/' + session.id, // Action api + uploadUrl: this.apiUrl + '/upload/' + session.id , + downloadUrl: this.apiUrl + '/download/' + session.id, + openUrl: this.apiUrl + '/open/' + session.id, + }; + } +} diff --git a/src/app/services/profile.service.ts b/src/app/services/profile.service.ts index 7b66b29..51ed5b0 100644 --- a/src/app/services/profile.service.ts +++ b/src/app/services/profile.service.ts @@ -115,6 +115,7 @@ export class ProfileService implements OnDestroy{ case ProfileType.SCP_FILE_EXPLORER: case ProfileType.SMB_FILE_EXPLORER: + case ProfileType.FTP_FILE_EXPLORER: one.icon = 'folder'; break; case ProfileType.CUSTOM: one.icon = 'star'; break; diff --git a/src/app/services/scp.service.ts b/src/app/services/scp.service.ts index c80cb6b..c1bd00a 100644 --- a/src/app/services/scp.service.ts +++ b/src/app/services/scp.service.ts @@ -8,7 +8,7 @@ import {Session} from '../domain/session/Session'; }) export class ScpService { - private apiUrl = 'http://localhost:13012/api/v1/scp/'; + private apiUrl = 'http://localhost:13012/api/v1/scp'; constructor(private electron: ElectronService) { } @@ -18,9 +18,10 @@ export class ScpService { setup(session: Session) { return { - url: this.apiUrl + session.id, // Custom backend API - uploadUrl: this.apiUrl + '/upload/' + session.id , // Custom upload endpoint - downloadUrl: this.apiUrl + '/download/' + session.id, // Custom download endpoint + url: this.apiUrl + '/' + session.id, // Action api + uploadUrl: this.apiUrl + '/upload/' + session.id , + downloadUrl: this.apiUrl + '/download/' + session.id, + openUrl: this.apiUrl + '/open/' + session.id, }; } } diff --git a/src/app/services/secret-storage.service.ts b/src/app/services/secret-storage.service.ts index 9eb91c8..c76837d 100644 --- a/src/app/services/secret-storage.service.ts +++ b/src/app/services/secret-storage.service.ts @@ -14,6 +14,10 @@ export class SecretStorageService { this._data = data; } + get data(): Secrets { + return this._data; + } + get dataCopy(): Secrets { let result = new Secrets(); // to avoid if this._data is deserialized we don't have fn on it if (this._data) { diff --git a/src/app/services/session.service.ts b/src/app/services/session.service.ts index ed8c39f..9fa8f51 100644 --- a/src/app/services/session.service.ts +++ b/src/app/services/session.service.ts @@ -12,6 +12,8 @@ import {ScpSession} from '../domain/session/ScpSession'; import {ScpService} from './scp.service'; import {NotificationService} from './notification.service'; import {TabInstance} from '../domain/TabInstance'; +import {FtpSession} from '../domain/session/FtpSession'; +import {FtpService} from './ftp.service'; @Injectable({ providedIn: 'root' @@ -24,6 +26,7 @@ export class SessionService { private vncService: VncService, private scpService: ScpService, + private ftpService: FtpService, private spinner: NgxSpinnerService, private notification: NotificationService, ) { } @@ -40,7 +43,8 @@ export class SessionService { case ProfileType.SCP_FILE_EXPLORER: return new ScpSession(profile, profileType, this.tabService, this.scpService); - + case ProfileType.FTP_FILE_EXPLORER: + return new FtpSession(profile, profileType, this.tabService, this.ftpService); } return new Session(profile, profileType, this.tabService);