first commit

This commit is contained in:
MCHost
2025-06-23 22:59:27 -04:00
commit b7c2fa6d19
18 changed files with 7675 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/test*
/node_modules
/dist

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 CyberGen49
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

309
README.md Normal file
View File

@ -0,0 +1,309 @@
# SFTP Browser
A web-based SFTP file browser for easy server file management.
Forked from: https://github.com/kaysting/sftp-browser
## Features
- Connect to SFTP servers hosted on any platform
- Import and export server connections
- Navigate directories with ease
- Sort files by different attributes
- Show or hide hidden (dot) files
- Switch between list and tile views
- Download multiple files/directories as a single zip file
- Upload files by dragging and dropping or using the button
- Create new files and directories
- Rename and delete files
- Cut/copy/paste files between directories
- Use "Move to" and "Copy to" dialogs for contextual organization
- Edit file permissions
- Recursively search for files by name within a directory
- View images, videos, and audio files in the browser
- File size limitations may apply
- Edit text files in the browser and save directly to the server
- Syntax highlighting supported for certain languages
- Edit Markdown files with a live preview
- Full mobile support with a responsive UI
## Connection Management
SFTP Browser supports seamless connection management through unique connection IDs. When a connection is established via the auto-connection endpoint, a unique `connectionId` is generated, and a URL in the format `/connect/:connectionId` is provided. Accessing this URL allows users to connect to the SFTP server without manually entering credentials, as the connection details are retrieved from the server using the `connectionId`. The connection details are stored temporarily on the server (typically for 12 hours) and in the client's `localStorage` for persistence across sessions. Duplicate connections are prevented by clearing existing connections when a new `/connect/:connectionId` URL is accessed.
## API Basics
### Authentication
All API endpoints (except `/auto-connection` and `/connect/:connectionId`) require the following request headers for connecting to the target server:
- `sftp-host`: The hostname of the server
- `sftp-port`: The port of the server
- `sftp-username`: The username to log into
- `sftp-password`: The password, if using password authentication
- `sftp-key`: The private key, if using public key authentication
### Response Format
All API responses are in JSON format and include a boolean `success` property. This is `true` if no errors were encountered, or `false` otherwise. If an error occurs, a string `error` property is included with a description of the error.
**Successful Response Example:**
```json
{
"success": true,
"...": "..."
}
```
**Failed Response Example:**
```json
{
"success": false,
"error": "[error description]",
"...": "..."
}
```
Failed responses use a 400 or 500 level HTTP status code.
## API Endpoints
### Auto-Connection
**Endpoint:** `POST /auto-connection`
Creates a temporary connection to an SFTP server and generates a unique `connectionId` and URL for accessing the connection without re-entering credentials.
#### Request Body
- **Required** `host` (string): The hostname of the server
- **Required** `username` (string): The username to log into
- **Optional** `port` (number): The port of the server (defaults to 22)
- **Optional** `password` (string): The password, if using password authentication
- **Optional** `privateKey` (string): The private key, if using public key authentication
*Note: Either `password` or `privateKey` must be provided.*
#### Successful Response
- `success` (boolean): `true` if the connection was established
- `connectionId` (string): A unique identifier for the connection
- `connectionUrl` (string): A URL in the format `https://<host>/connect/:connectionId` for accessing the connection
**Example Response:**
```json
{
"success": true,
"connectionId": "9795d599219c783637efdecc6a91edd8",
"connectionUrl": "https://sftp.my-mc.link/connect/9795d599219c783637efdecc6a91edd8"
}
```
#### Failed Response
- `success` (boolean): `false` if the connection failed
- `error` (string): Description of the error (e.g., "Missing host")
### Fetch Connection Details
**Endpoint:** `GET /api/connect/:connectionId`
Retrieves the connection details for a given `connectionId`, typically used when accessing a `/connect/:connectionId` URL.
#### Path Parameters
- **Required** `connectionId` (string): The unique identifier for the connection
#### Successful Response
- `success` (boolean): `true` if the connection details were found
- `connection` (object): The connection details
- `host` (string): The hostname of the server
- `port` (number): The port of the server
- `username` (string): The username
- `password` (string, optional): The password, if provided
- `privateKey` (string, optional): The private key, if provided
**Example Response:**
```json
{
"success": true,
"connection": {
"host": "my-mc.link",
"port": 39125,
"username": "mc",
"password": "IJhPT15nlOIoJm53"
}
}
```
#### Failed Response
- `success` (boolean): `false` if the connection was not found
- `error` (string): Description of the error (e.g., "Connection not found")
### List Files in a Directory
**Endpoint:** `GET /api/sftp/directories/list`
#### Query Parameters
- **Required** `path` (string): The target directory path
- **Optional** `dirsOnly` (boolean): If `true`, only directories are returned
#### Successful Response
- `path` (string): The normalized path
- `list` (object[]): An array of file objects
- `list[].name` (string): The name of the file
- `list[].accessTime` (number): Timestamp of the last access time
- `list[].modifyTime` (number): Timestamp of the last modification time
- `list[].size` (number): File size in bytes
- `list[].type` (string): A 1-character string representing the file type
- `list[].group` (number): ID of the group the file belongs to
- `list[].owner` (number): ID of the user the file belongs to
- `list[].rights` (object): Permissions for the file
- `list[].rights.user` (string): Permissions for the owner (`r`, `w`, `x`)
- `list[].rights.group` (string): Permissions for the group (`r`, `w`, `x`)
- `list[].rights.other` (string): Permissions for others (`r`, `w`, `x`)
- `list[].longname` (string): Raw SFTP output for the file
### Create a Directory
**Endpoint:** `POST /api/sftp/directories/create`
#### Query Parameters
- **Required** `path` (string): The new directory path
#### Successful Response
- `path` (string): The normalized path
### Delete a Directory
**Endpoint:** `DELETE /api/sftp/directories/delete`
#### Query Parameters
- **Required** `path` (string): The path of the directory to delete
#### Successful Response
- `path` (string): The normalized path
### Check if a Path Exists
**Endpoint:** `GET /api/sftp/files/exists`
#### Query Parameters
- **Required** `path` (string): The path to check
#### Successful Response
- `path` (string): The normalized path
- `exists` (boolean): `true` if the path exists, `false` otherwise
- `type` (string|boolean): File type character if the path exists, `false` otherwise
### Get Information About a File
**Endpoint:** `GET /api/sftp/files/stat`
#### Query Parameters
- **Required** `path` (string): The path to stat
#### Successful Response
- `path` (string): The normalized path
- `stats` (object): File statistics
- `accessTime` (number): Last accessed time
- `modifyTime` (number): Last modified time
- `size` (number): File size in bytes
- `uid` (number): ID of the file's owner
- `gid` (number): ID of the file's group
- `mode` (number): Integer representing file type and permissions
- `isBlockDevice` (boolean): `true` if a block device
- `isCharacterDevice` (boolean): `true` if a character device
- `isDirectory` (boolean): `true` if a directory
- `isFIFO` (boolean): `true` if a first-in first-out file
- `isSocket` (boolean): `true` if a socket
- `isSymbolicLink` (boolean): `true` if a symlink
### Get Raw File Data
**Endpoint:** `GET /api/sftp/files/get/single`
#### Query Parameters
- **Required** `path` (string): The path of the file
#### Successful Response
- *Raw file data*
#### Failed Response
- *JSON error response*
### Get File Download Link
Gets a temporary URL to download a single file without connection headers. URLs last 24 hours or until the server restarts.
**Endpoint:** `GET /api/sftp/files/get/single/url`
#### Query Parameters
- **Required** `path` (string): The path of the file
#### Successful Response
- `path` (string): The normalized path
- `download_url` (string): The download URL
### Get Zipped Download Link
Gets a temporary URL for downloading files and directories as a zip archive without connection headers.
**Endpoint:** `GET /api/sftp/files/get/multi`
#### Query Parameters
- **Required** `paths` (string[]): JSON-formatted array of file or directory paths
#### Successful Response
- `path` (string): The normalized path
- `download_url` (string): The download URL
### Create a File
**Endpoint:** `POST /api/sftp/files/create`
#### Query Parameters
- **Required** `path` (string): The path of the new file
#### Request Body
- *Raw data to insert into the new file*
#### Successful Response
- `path` (string): The normalized path
### Append Data to a File
**Endpoint:** `PUT /api/sftp/files/append`
#### Query Parameters
- **Required** `path` (string): The path of the file
#### Request Body
- *Raw data to append to the file*
#### Successful Response
- `path` (string): The normalized path
### Move a File
**Endpoint:** `PUT /api/sftp/files/move`
#### Query Parameters
- **Required** `pathOld` (string): The current path
- **Required** `pathNew` (string): The new path
#### Successful Response
- `pathOld` (string): The normalized old path
- `pathNew` (string): The normalized new path
### Copy a File
Directories not supported.
**Endpoint:** `PUT /api/sftp/files/copy`
#### Query Parameters
- **Required** `pathSrc` (string): The source path
- **Required** `pathDest` (string): The destination path
#### Successful Response
- `pathSrc` (string): The normalized source path
- `pathDest` (string): The normalized destination path
### Edit a File's Permissions
Directories supported, but without recursion.
**Endpoint:** `PUT /api/sftp/files/chmod`
#### Query Parameters
- **Required** `path` (string): The path of the file
- **Required** `mode` (string): The new mode in the form `xyz`, where `x`, `y`, and `z` are integers from 0 to 7
#### Successful Response
- `path` (string): The normalized path
- `mode` (string): The supplied mode
### Delete a File
**Endpoint:** `DELETE /api/sftp/files/delete`
#### Query Parameters
- **Required** `path` (string): The path of the file
#### Successful Response
- `path` (string): The normalized path

3
config.json Normal file
View File

@ -0,0 +1,3 @@
{
"port": 8261
}

8
ecosystem.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
apps: [{
name: 'sftp-browser',
script: './server.js',
watch: [ 'server.js' ]
}]
};

2090
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "sftp-browser",
"version": "1.0.0",
"description": "A web-based SFTP file browser that makes managing your server files easy!",
"main": "server.js",
"scripts": {
"startserver": "node server.js",
"startapp": "electron app.js",
"package": "electron-builder -lw"
},
"build": {
"appId": "org.simplecyber.sftp-browser",
"productName": "SFTP-Browser",
"files": [
"web/**/*",
"*.js",
"*.json"
],
"linux": {
"target": [
"deb"
],
"category": "Network",
"icon": "web/icon.png"
},
"win": {
"target": [
"zip"
],
"icon": "web/icon.png"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/CyberGen49/sftp-browser.git"
},
"author": "Kayla <kayla@cybah.me> (https://cybah.me)",
"license": "ISC",
"bugs": {
"url": "https://github.com/CyberGen49/sftp-browser/issues"
},
"homepage": "https://github.com/CyberGen49/sftp-browser#readme",
"dependencies": {
"archiver": "^6.0.1",
"body-parser": "^1.20.2",
"cyber-express-logger": "^1.0.5",
"dayjs": "^1.11.10",
"electron-squirrel-startup": "^1.0.0",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
"express-ws": "^5.0.2",
"mime": "^3.0.0",
"ssh2-sftp-client": "^9.1.0",
"web-resources": "^2.3.0"
},
"devDependencies": {
"electron": "^26.2.4"
}
}

797
sftp-browser.js Normal file
View File

@ -0,0 +1,797 @@
const path = require('path');
const express = require('express');
const expressWs = require('express-ws');
const asyncHandler = require('express-async-handler');
const logger = require('cyber-express-logger');
const sftp = require('ssh2-sftp-client');
const crypto = require('crypto');
const mime = require('mime');
const cors = require('cors'); // Added CORS package
const bodyParser = require('body-parser');
const archiver = require('archiver');
const rawBodyParser = bodyParser.raw({
limit: '16mb',
type: '*/*'
});
const dayjs = require('dayjs');
const dayjsAdvancedFormat = require('dayjs/plugin/advancedFormat');
dayjs.extend(dayjsAdvancedFormat);
const utils = require('web-resources');
const Electron = require('electron');
const config = require('./config.json');
const normalizeRemotePath = remotePath => {
remotePath = path.normalize(remotePath).replace(/\\/g, '/');
const split = remotePath.split('/').filter(String);
const joined = `/${split.join('/')}`;
return joined;
};
const sessions = {};
const sessionActivity = {};
const connections = {}; // Store connection details
const getObjectHash = obj => {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify(obj));
return hash.digest('hex');
}
/**
* @param {sftp.ConnectOptions} opts
* @returns {Promise<sftp>|null}
* */
const getSession = async (res, opts) => {
const hash = getObjectHash(opts);
const address = `${opts.username}@${opts.host}:${opts.port}`;
if (sessions[hash]) {
console.log(`Using existing connection to ${address}`);
sessionActivity[hash] = Date.now();
return sessions[hash];
}
console.log(`Creating new connection to ${address}`);
const session = new sftp();
sessions[hash] = session;
session.on('end', () => delete sessions[hash]);
session.on('close', () => delete sessions[hash]);
try {
await session.connect(opts);
sessionActivity[hash] = Date.now();
} catch (error) {
delete sessions[hash];
console.log(`Connection to ${address} failed`);
return res ? res.sendError(error) : null;
}
return session;
};
const srv = express();
expressWs(srv, undefined, {
wsOptions: {
maxPayload: 1024 * 1024 * 4
}
});
// Enable CORS for all routes
srv.use(cors({
origin: '*', // Allow all origins; can be restricted to specific origins if needed
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allow necessary methods
allowedHeaders: ['Content-Type', 'sftp-host', 'sftp-port', 'sftp-username', 'sftp-password', 'sftp-key'], // Allow relevant headers
credentials: false // Set to true if cookies or auth headers are needed
}));
srv.use(logger());
const staticDir = path.join(__dirname, 'web');
srv.use(express.static(staticDir));
console.log(`Serving static files from ${staticDir}`);
const initApi = asyncHandler(async (req, res, next) => {
res.sendData = (status = 200) => res.status(status).json(res.data);
res.sendError = (error, status = 400) => {
res.data.success = false;
res.data.error = `${error}`.replace('Error: ', '');
res.sendData(status);
}
res.data = {
success: true
};
req.connectionOpts = {
host: req.headers['sftp-host'],
port: req.headers['sftp-port'] || 22,
username: req.headers['sftp-username'],
password: decodeURIComponent(req.headers['sftp-password'] || '') || undefined,
privateKey: decodeURIComponent(req.headers['sftp-key'] || '') || undefined,
};
if (!req.connectionOpts.host)
return res.sendError('Missing host header');
if (!req.connectionOpts.username)
return res.sendError('Missing username header');
if (!req.connectionOpts.password && !req.connectionOpts.privateKey)
return res.sendError('Missing password or key header');
req.session = await getSession(res, req.connectionOpts);
if (!req.session) return;
next();
});
// API endpoint to fetch connection details by connectionId
srv.get('/api/connect/:connectionId', asyncHandler(async (req, res) => {
const connectionId = req.params.connectionId;
const connection = connections[connectionId];
if (!connection) {
return res.status(404).json({ success: false, error: 'Connection not found' });
}
res.json({
success: true,
connection: {
host: connection.host,
port: connection.port,
username: connection.username,
password: connection.password,
privateKey: connection.privateKey
}
});
}));
// Auto-connection endpoint
srv.post('/auto-connection', bodyParser.json(), asyncHandler(async (req, res) => {
const connectionDetails = {
host: req.body.host,
port: req.body.port || 22,
username: req.body.username,
password: req.body.password || undefined,
privateKey: req.body.privateKey || undefined
};
// Validate required fields
if (!connectionDetails.host) {
return res.status(400).json({ success: false, error: 'Missing host' });
}
if (!connectionDetails.username) {
return res.status(400).json({ success: false, error: 'Missing username' });
}
if (!connectionDetails.password && !connectionDetails.privateKey) {
return res.status(400).json({ success: false, error: 'Missing password or key' });
}
// Generate connection ID
const connectionId = utils.randomHex(32);
// Store connection details
connections[connectionId] = {
...connectionDetails,
created: Date.now()
};
// Test connection
const session = await getSession(res, connectionDetails);
if (!session) {
delete connections[connectionId];
return res.status(400).json({ success: false, error: 'Failed to establish connection' });
}
session.end();
// Return connection URL
const connectionUrl = `https://${req.get('host')}/connect/${connectionId}`;
res.json({
success: true,
connectionId,
connectionUrl
});
}));
// Handle connection URL access
srv.get('/connect/:connectionId', asyncHandler(async (req, res) => {
const connectionId = req.params.connectionId;
if (!connections[connectionId]) {
return res.status(404).send('Connection not found');
}
// Serve the main application page (index.html)
res.sendFile(path.join(staticDir, 'index.html'));
}));
const keyedRequests = {};
srv.get('/api/sftp/key', initApi, async (req, res) => {
res.data.key = utils.randomHex(32);
keyedRequests[res.data.key] = req;
res.sendData();
});
srv.get('/api/sftp/directories/list', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
res.data.includesFiles = req.query.dirsOnly === 'true' ? false : true;
if (!res.data.path) return res.sendError('Missing path', 400);
try {
res.data.list = await session.list(res.data.path);
if (res.data.list && !res.data.includesFiles) {
res.data.list = res.data.list.filter(item => item.type === 'd');
}
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.ws('/api/sftp/directories/search', async (ws, wsReq) => {
if (!wsReq.query.key) return ws.close();
const req = keyedRequests[wsReq.query.key];
if (!req) return ws.close();
// Add uniqueness to the connection opts
// This forces a new connection to be created
req.connectionOpts.ts = Date.now();
// Create the session and throw an error if it fails
/** @type {sftp} */
const session = await getSession(null, req.connectionOpts);
const sessionHash = getObjectHash(req.connectionOpts);
if (!session) {
ws.send(JSON.stringify({
success: false,
error: 'Failed to create session!'
}));
return ws.close();
}
// Normalize the file path or throw an error if it's missing
const filePath = normalizeRemotePath(wsReq.query.path);
if (!filePath) {
ws.send(JSON.stringify({
success: false,
error: 'Missing path'
}));
return ws.close();
}
// Get the query
const query = wsReq.query.query;
if (!query) {
ws.send(JSON.stringify({
success: false,
error: 'Missing query'
}));
return ws.close();
}
// Update the session activity periodically
let interval;
const updateActivity = () => {
sessionActivity[sessionHash] = Date.now();
};
interval = setInterval(updateActivity, 1000 * 1);
// Handle websocket closure
let isClosed = false;
ws.on('close', () => {
console.log(`Directory search websocket closed`);
session.end();
clearInterval(interval);
delete sessionActivity[sessionHash];
isClosed = true;
});
// Listen for messages
console.log(`Websocket opened to search directory ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`);
// Function to get a directory listing
const scanDir = async (dirPath) => {
try {
const list = await session.list(dirPath);
return [...list].sort((a, b) => {
// Sort by name
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
} catch (error) {
return null;
}
};
// Function to send a list when there are enough files
let matchedFiles = [];
let lastSend = 0;
const sendList = () => {
if (matchedFiles.length > 0) {
ws.send(JSON.stringify({
success: true,
status: 'list',
list: matchedFiles
}));
matchedFiles = [];
lastSend = Date.now();
}
};
// Function to recursively search a directory
const recurse = async dirPath => {
if (isClosed) return;
ws.send(JSON.stringify({
success: true,
status: 'scanning',
path: dirPath
}));
const list = await scanDir(dirPath);
if (!list) {
ws.send(JSON.stringify({
success: false,
error: `Failed to scan directory ${dirPath}`
}));
return;
}
for (const file of list) {
if (isClosed) return;
file.path = `${dirPath}/${file.name}`;
if (file.name.toLowerCase().includes(query.toLowerCase())) {
matchedFiles.push(file);
}
if ((Date.now() - lastSend) > 1000) sendList();
if (file.type == 'd') {
await recurse(file.path);
}
}
};
// Start the search
await recurse(filePath);
if (isClosed) return;
sendList();
// Send a complete message
ws.send(JSON.stringify({ success: true, status: 'complete' }));
// Close the websocket
ws.close();
});
srv.post('/api/sftp/directories/create', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.mkdir(res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.delete('/api/sftp/directories/delete', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.rmdir(res.data.path, true);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.get('/api/sftp/files/exists', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
const type = await session.exists(res.data.path);
res.data.exists = type !== false;
res.data.type = type;
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.post('/api/sftp/files/create', initApi, rawBodyParser, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.put(req.body, res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/append', initApi, rawBodyParser, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.append(req.body, res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.ws('/api/sftp/files/append', async (ws, wsReq) => {
if (!wsReq.query.key) return ws.close();
const req = keyedRequests[wsReq.query.key];
if (!req) return ws.close();
// Add uniqueness to the connection opts
// This forces a new connection to be created
req.connectionOpts.ts = Date.now();
// Create the session and throw an error if it fails
/** @type {sftp} */
const session = await getSession(null, req.connectionOpts);
const sessionHash = getObjectHash(req.connectionOpts);
if (!session) {
ws.send(JSON.stringify({
success: false,
error: 'Failed to create session!'
}));
return ws.close();
}
// Normalize the file path or throw an error if it's missing
const filePath = normalizeRemotePath(wsReq.query.path);
if (!filePath) {
ws.send(JSON.stringify({
success: false,
error: 'Missing path'
}));
return ws.close();
}
// Handle websocket closure
ws.on('close', () => {
console.log(`File append websocket closed`);
session.end();
delete sessionActivity[sessionHash];
});
// Listen for messages
console.log(`Websocket opened to append to ${req.connectionOpts.username}@${req.connectionOpts.host}:${req.connectionOpts.port} ${filePath}`);
let isWriting = false;
ws.on('message', async (data) => {
// If we're already writing, send an error
if (isWriting) {
return ws.send(JSON.stringify({
success: false,
error: 'Writing in progress'
}));
}
try {
// Append the data to the file
isWriting = true;
await session.append(data, filePath);
ws.send(JSON.stringify({ success: true }));
} catch (error) {
ws.send(JSON.stringify({
success: false,
error: error.toString()
}));
return ws.close();
}
isWriting = false;
// Update the session activity
sessionActivity[sessionHash] = Date.now();
});
// Send a ready message
ws.send(JSON.stringify({ success: true, status: 'ready' }));
});
srv.delete('/api/sftp/files/delete', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
try {
await session.delete(res.data.path);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/move', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.pathOld = normalizeRemotePath(req.query.pathOld);
res.data.pathNew = normalizeRemotePath(req.query.pathNew);
if (!res.data.pathOld) return res.sendError('Missing source path', 400);
if (!res.data.pathNew) return res.sendError('Missing destination path', 400);
try {
await session.rename(res.data.pathOld, res.data.pathNew);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/copy', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.pathSrc = normalizeRemotePath(req.query.pathSrc);
res.data.pathDest = normalizeRemotePath(req.query.pathDest);
if (!res.data.pathSrc) return res.sendError('Missing source path', 400);
if (!res.data.pathDest) return res.sendError('Missing destination path', 400);
try {
await session.rcopy(res.data.pathSrc, res.data.pathDest);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.put('/api/sftp/files/chmod', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
res.data.mode = req.query.mode;
try {
await session.chmod(res.data.path, res.data.mode);
res.sendData();
} catch (error) {
res.sendError(error);
}
});
srv.get('/api/sftp/files/stat', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
let stats = null;
try {
stats = await session.stat(res.data.path);
} catch (error) {
return res.sendError(error, 404);
}
res.data.stats = stats;
res.sendData();
});
const downloadSingleFileHandler = async (connectionOpts, res, remotePath, stats) => {
let interval;
// Gracefully handle any errors
try {
// Throw an error if it's not a file
if (!stats.isFile) throw new Error('Not a file');
// Add uniqueness to the connection opts
// This forces a new connection to be created
connectionOpts.ts = Date.now();
// Create the session and throw an error if it fails
const session = await getSession(res, connectionOpts);
if (!session) throw new Error('Failed to create session');
// Continuously update the session activity
interval = setInterval(() => {
const hash = getObjectHash(connectionOpts);
sessionActivity[hash] = Date.now();
}, 1000 * 1);
// When the response closes, end the session
const handleClose = () => {
clearInterval(interval);
session.end();
};
res.on('end', handleClose);
res.on('close', handleClose);
res.on('error', handleClose);
// Set response headers
res.setHeader('Content-Type', mime.getType(remotePath) || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(remotePath)}"`);
res.setHeader('Content-Length', stats.size);
// Start the download
console.log(`Starting download: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`);
await session.get(remotePath, res);
// Force-end the response
res.end();
// On error, clear the interval and send a 400 response
} catch (error) {
clearInterval(interval);
res.status(400).end();
}
};
const downloadMultiFileHandler = async (connectionOpts, res, remotePaths, rootPath = '/') => {
rootPath = normalizeRemotePath(rootPath);
let interval;
// Gracefully handle any errors
try {
// Add uniqueness to the connection opts
// This forces a new connection to be created
connectionOpts.ts = Date.now();
// Create the session and throw an error if it fails
const session = await getSession(res, connectionOpts);
if (!session) throw new Error('Failed to create session');
// Continuously update the session activity
setInterval(() => {
const hash = getObjectHash(connectionOpts);
sessionActivity[hash] = Date.now();
}, 1000 * 1);
// Set response headers
let fileName = `Files (${path.basename(rootPath) || 'Root'})`;
if (remotePaths.length == 1)
fileName = path.basename(remotePaths[0]);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}.zip"`);
// Create the archive and start piping to the response
const archive = archiver('zip');
archive.pipe(res);
// When the response closes, end the session
const handleClose = () => {
clearInterval(interval);
archive.end();
session.end();
};
res.on('end', handleClose);
res.on('close', handleClose);
res.on('error', handleClose);
// Add file to the archive
const addToArchive = async (remotePath) => {
const archivePath = normalizeRemotePath(remotePath.replace(rootPath, ''));
console.log(`Zipping: ${connectionOpts.username}@${connectionOpts.host}:${connectionOpts.port} ${remotePath}`);
// Get file read stream
const stream = session.createReadStream(remotePath);
const waitToEnd = new Promise(resolve => {
stream.on('end', resolve);
});
// Add file to archive
archive.append(stream, {
name: archivePath
});
// Wait for the stream to end
await waitToEnd;
};
// Recurse through directories and archive files
const recurse = async (remotePath) => {
try {
const stats = await session.stat(remotePath);
if (stats.isFile) {
await addToArchive(remotePath);
} else if (stats.isDirectory) {
const list = await session.list(remotePath);
for (const item of list) {
const subPath = `${remotePath}/${item.name}`;
if (item.type === '-') {
await addToArchive(subPath);
} else {
await recurse(subPath);
}
}
}
} catch (error) { }
};
for (const remotePath of remotePaths) {
await recurse(remotePath);
}
// Finalize the archive
archive.on('close', () => res.end());
archive.finalize();
// On error, clear the interval and send a 400 response
} catch (error) {
clearInterval(interval);
res.status(400).end();
}
};
srv.get('/api/sftp/files/get/single', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
// Get the normalized path and throw an error if it's missing
const remotePath = normalizeRemotePath(req.query.path);
if (!remotePath) return res.sendError('Missing path', 400);
try {
const stats = await session.stat(remotePath);
// Handle the download
await downloadSingleFileHandler(req.connectionOpts, res, remotePath, stats);
} catch (error) {
res.status(400).end();
}
});
const rawDownloads = {};
srv.get('/api/sftp/files/get/single/url', initApi, async (req, res) => {
/** @type {sftp} */
const session = req.session;
// Get the normalized path and throw an error if it's missing
res.data.path = normalizeRemotePath(req.query.path);
if (!res.data.path) return res.sendError('Missing path', 400);
// Get path stats and throw an error if it's not a file
let stats = null;
try {
stats = await session.stat(res.data.path);
if (!stats?.isFile) throw new Error('Not a file');
} catch (error) {
return res.sendError(error);
}
// Generate download URL
const id = utils.randomHex(8);
res.data.download_url = `https://${req.get('host')}/dl/${id}`;
// Create download handler
rawDownloads[id] = {
created: Date.now(),
paths: [res.data.path],
handler: async (req2, res2) => {
// Handle the download
await downloadSingleFileHandler(req.connectionOpts, res2, res.data.path, stats);
}
}
res.sendData();
});
srv.get('/api/sftp/files/get/multi/url', initApi, async (req, res) => {
try {
// Get the normalized path and throw an error if it's missing
res.data.paths = JSON.parse(req.query.paths);
if (!res.data.paths) throw new Error('Missing path(s)');
} catch (error) {
return res.sendError(error);
}
// Generate download URL
const id = utils.randomHex(8);
res.data.download_url = `https://${req.get('host')}/dl/${id}`;
// Create download handler
rawDownloads[id] = {
created: Date.now(),
paths: res.data.paths,
isZip: true,
handler: async (req2, res2) => {
// Handle the download
await downloadMultiFileHandler(req.connectionOpts, res2, res.data.paths, req.query.rootPath);
}
}
res.sendData();
});
srv.get('/dl/:id', async (req, res) => {
// Get the download handler
const entry = rawDownloads[req.params.id];
if (!entry) return res.status(404).end();
// If the user agent looks like a bot
if (req.get('user-agent').match(/(bot|scrape)/)) {
// Send some HTML
res.setHeader('Content-Type', 'text/html');
const html = /*html*/`
<html>
<head>
<title>Download shared files</title>
<meta property="og:site_name" content="SFTP Browser" />
<meta property="og:title" content="Shared ${entry.isZip ? 'files' : 'file'}" />
<meta property="og:description" content="Click to download ${entry.isZip ? `these files compressed into a zip.` : `${path.basename(entry.paths[0])}.`} This link will expire on ${dayjs(entry.created + (1000 * 60 * 60 * 24)).format('YYYY-MM-DD [at] hh:mm:ss ([GMT]Z)')}." />
<meta name="theme-color" content="#1f2733">
<meta property="og:image" content="https://${req.get('host')}/icon.png" />
</head>
<body>
<p>Click <a href="${req.originalUrl}">here</a> to download the file.</p>
</body>
</html>
`;
res.send(html);
} else {
entry.handler(req, res);
}
});
srv.use((req, res) => res.status(404).end());
setInterval(() => {
// Delete inactive sessions
for (const hash in sessions) {
const lastActive = sessionActivity[hash];
if (!lastActive) continue;
if ((Date.now() - lastActive) > 1000 * 60 * 5) {
console.log(`Deleting inactive session`);
sessions[hash].end();
delete sessions[hash];
delete sessionActivity[hash];
}
}
// Delete unused downloads
for (const id in rawDownloads) {
const download = rawDownloads[id];
if ((Date.now() - download.created) > 1000 * 60 * 60 * 12) {
console.log(`Deleting unused download`);
delete rawDownloads[id];
}
}
// Delete expired connections
for (const id in connections) {
const connection = connections[id];
if ((Date.now() - connection.created) > 1000 * 60 * 60 * 12) {
console.log(`Deleting expired connection ${id}`);
delete connections[id];
}
}
}, 1000 * 30);
if (Electron.app) {
Electron.app.whenReady().then(async () => {
// Start the server
let port = 8001 + Math.floor(Math.random() * 999);
await new Promise(resolve => {
srv.listen(port, () => {
console.log(`App server listening on port ${port}`)
resolve();
});
});
// Open the window
const window = new Electron.BrowserWindow({
width: 1100,
height: 720,
autoHideMenuBar: true,
minWidth: 320,
minHeight: 200
});
window.loadURL(`http://localhost:${port}`);
// Quit the app when all windows are closed
// unless we're on macOS
Electron.app.on('window-all-closed', () => {
if (process.platform !== 'darwin') Electron.app.quit();
});
});
} else {
srv.listen(config.port, () => console.log(`Standalone server listening on port ${config.port}`));
}

488
web/assets/file.js Normal file
View File

@ -0,0 +1,488 @@
const elNavBar = $('#navbar');
const elControls = $('#controls');
const elPreview = $('#preview');
const btnDownload = $('#download');
const query = new URLSearchParams(window.location.search);
let path = query.get('path');
activeConnection = connections[query.get('con')];
let fileStats = null;
let editor;
const updatePreview = async() => {
// Make sure the file is viewable
const extInfo = getFileExtInfo(path);
if (!extInfo.isViewable) {
return setStatus(`Error: File isn't viewable!`, true);
}
let fileUrl;
try {
const startTime = Date.now();
let lastUpdate = 0;
const blob = await api.request('get', 'files/get/single', {
path: path
}, null, e => {
if ((Date.now()-lastUpdate) < 100) return;
lastUpdate = Date.now();
const progress = Math.round((e.loaded / fileStats.size) * 100);
const bps = Math.round(e.loaded / ((Date.now() - startTime) / 1000));
setStatus(`Downloaded ${formatSize(e.loaded)} of ${formatSize(fileStats.size)} (${formatSize(bps)}/s)`, false, progress);
}, 'blob');
fileUrl = URL.createObjectURL(blob);
} catch (error) {
return setStatus(`Error: ${error}`, true);
}
elPreview.classList.add(extInfo.type);
const statusHtmlSegments = [];
switch (extInfo.type) {
case 'image': {
const image = document.createElement('img');
image.src = fileUrl;
await new Promise(resolve => {
image.addEventListener('load', resolve);
});
elControls.insertAdjacentHTML('beforeend', `
<button class="zoomOut btn small secondary iconOnly" title="Zoom out">
<div class="icon">zoom_out</div>
</button>
<div class="zoom">0%</div>
<button class="zoomIn btn small secondary iconOnly" title="Zoom in">
<div class="icon">zoom_in</div>
</button>
<div class="sep"></div>
<button class="fit btn small secondary iconOnly" title="Fit">
<div class="icon">fit_screen</div>
</button>
<button class="real btn small secondary iconOnly" title="Actual size">
<div class="icon">fullscreen</div>
</button>
`);
const btnZoomOut = $('.btn.zoomOut', elControls);
const btnZoomIn = $('.btn.zoomIn', elControls);
const btnFit = $('.btn.fit', elControls);
const btnReal = $('.btn.real', elControls);
const elZoom = $('.zoom', elControls);
let fitPercent = 100;
const setZoom = percent => {
const minZoom = fitPercent;
const maxZoom = 1000;
const newZoom = Math.min(Math.max(percent, minZoom), maxZoom);
elZoom.innerText = `${Math.round(newZoom)}%`;
const scaledSize = {
width: image.naturalWidth * (newZoom/100),
height: image.naturalHeight * (newZoom/100)
};
image.style.width = `${scaledSize.width}px`;
image.style.height = `${scaledSize.height}px`;
};
const changeZoom = percentChange => {
const zoom = parseInt(elZoom.innerText.replace('%', ''));
setZoom(zoom+percentChange);
};
const fitImage = () => {
const previewRect = elPreview.getBoundingClientRect();
const previewRatio = previewRect.width / previewRect.height;
const imageRatio = image.naturalWidth / image.naturalHeight;
fitPercent = 100;
if (imageRatio > previewRatio) {
fitPercent = (previewRect.width / image.naturalWidth) * 100;
} else {
fitPercent = (previewRect.height / image.naturalHeight) * 100;
}
fitPercent = Math.min(fitPercent, 100);
setZoom(fitPercent);
image.style.marginTop = '';
image.style.marginLeft = '';
};
btnZoomIn.addEventListener('click', () => {
changeZoom(10);
});
btnZoomOut.addEventListener('click', () => {
changeZoom(-10);
});
btnFit.addEventListener('click', () => {
fitImage();
});
btnReal.addEventListener('click', () => {
setZoom(100);
});
elPreview.addEventListener('wheel', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
const previewRect = elPreview.getBoundingClientRect();
const relativePos = {
x: (e.clientX - previewRect.left) + elPreview.scrollLeft,
y: (e.clientY - previewRect.top) + elPreview.scrollTop
};
const percentage = {
x: relativePos.x / elPreview.scrollWidth,
y: relativePos.y / elPreview.scrollHeight
};
changeZoom(e.deltaY > 0 ? -10 : 10);
const newScroll = {
x: (elPreview.scrollWidth * percentage.x) - relativePos.x,
y: (elPreview.scrollHeight * percentage.y) - relativePos.y
};
elPreview.scrollLeft += newScroll.x;
elPreview.scrollTop += newScroll.y;
});
/*
let startTouchDistance = 0;
elPreview.addEventListener('touchstart', e => {
if (!getIsMobileDevice()) return;
if (e.touches.length == 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
startTouchDistance = distance;
}
});
elPreview.addEventListener('touchmove', e => {
if (!getIsMobileDevice()) return;
if (e.touches.length == 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
const percentChange = (distance - startTouchDistance) / 10;
changeZoom(percentChange);
startTouchDistance = distance;
}
});
elPreview.addEventListener('touchend', e => {
if (!getIsMobileDevice()) return;
startTouchDistance = 0;
});
*/
let startCoords = {};
let startScroll = {};
let isMouseDown = false;
elPreview.addEventListener('mousedown', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
startCoords = { x: e.clientX, y: e.clientY };
startScroll = { x: elPreview.scrollLeft, y: elPreview.scrollTop };
isMouseDown = true;
elPreview.style.cursor = 'grabbing';
});
elPreview.addEventListener('dragstart', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
});
elPreview.addEventListener('mousemove', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
if (!isMouseDown) return;
const newScroll = {
x: startCoords.x - e.clientX + startScroll.x,
y: startCoords.y - e.clientY + startScroll.y
};
// Update preview scroll
elPreview.scrollLeft = newScroll.x;
elPreview.scrollTop = newScroll.y;
});
elPreview.addEventListener('mouseup', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
isMouseDown = false;
elPreview.style.cursor = '';
});
elPreview.addEventListener('mouseleave', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
isMouseDown = false;
elPreview.style.cursor = '';
});
elControls.style.display = '';
elPreview.innerHTML = '';
elPreview.appendChild(image);
statusHtmlSegments.push(`<span>${image.naturalWidth}x${image.naturalHeight}</span>`);
fitImage();
window.addEventListener('resize', fitImage);
break;
}
case 'video': {
const video = document.createElement('video');
video.src = fileUrl;
await new Promise(resolve => {
video.addEventListener('loadedmetadata', resolve);
});
video.controls = true;
elPreview.innerHTML = '';
elPreview.appendChild(video);
video.play();
statusHtmlSegments.push(`<span>${formatSeconds(video.duration)}</span>`);
statusHtmlSegments.push(`<span>${video.videoWidth}x${video.videoHeight}</span>`);
break;
}
case 'audio': {
const audio = document.createElement('audio');
audio.src = fileUrl;
await new Promise(resolve => {
audio.addEventListener('loadedmetadata', resolve);
});
audio.controls = true;
elPreview.innerHTML = '';
elPreview.appendChild(audio);
audio.play();
statusHtmlSegments.push(`<span>${formatSeconds(audio.duration)}</span>`);
break;
}
case 'markdown':
case 'text': {
// Initialize the textarea
const text = await (await fetch(fileUrl)).text();
//const textarea = document.createElement('textarea');
// Initialize CodeMirror
elPreview.innerHTML = '';
editor = CodeMirror(elPreview, {
value: text,
lineNumbers: true,
lineWrapping: true,
scrollPastEnd: true,
styleActiveLine: true,
autoCloseBrackets: true,
mode: extInfo.codeMirrorMode
});
const elEditor = $('.CodeMirror', elPreview);
// Load CodeMirror mode
if (extInfo.codeMirrorMode) {
let mode;
CodeMirror.requireMode(extInfo.codeMirrorMode, () => {}, {
path: determinedMode => {
mode = determinedMode;
return `https://codemirror.net/5/mode/${determinedMode}/${determinedMode}.js`;
}
});
CodeMirror.autoLoadMode(editor, mode);
}
// Add HTML
elControls.insertAdjacentHTML('beforeend', `
<button class="save btn small secondary" disabled>
<div class="icon">save</div>
<span>Save</span>
</button>
<button class="view btn small secondary" style="display: none">
<div class="icon">visibility</div>
<span>View</span>
</button>
<button class="edit btn small secondary" style="display: none">
<div class="icon">edit</div>
<span>Edit</span>
</button>
<div class="sep"></div>
<button class="textSmaller btn small secondary iconOnly">
<div class="icon">text_decrease</div>
</button>
<div class="textSize">18</div>
<button class="textBigger btn small secondary iconOnly">
<div class="icon">text_increase</div>
</button>
<div class="sep"></div>
<label class="selectOption">
<input type="checkbox">
<span>Word wrap</span>
</label>
`);
// Set up the save button
const btnSave = $('.btn.save', elControls);
const btnSaveText = $('span', btnSave);
btnSave.addEventListener('click', async() => {
if (btnSave.disabled) return;
btnSaveText.innerText = 'Saving...';
btnSave.disabled = true;
btnSave.classList.remove('info');
// const res1 = await api.delete('files/delete', {
// path: path
// });
const res1 = {};
const res2 = await api.post('files/create', {
path: path
//}, textarea.value);
}, editor.getValue());
if (res1.error || res2.error) {
setStatus(`Error: ${res2.error || res1.error}`, true);
btnSaveText.innerText = 'Save';
btnSave.disabled = false;
btnSave.classList.add('info');
} else {
btnSaveText.innerText = 'Saved!';
await getUpdatedStats();
setStatusWithDetails();
}
});
//textarea.addEventListener('input', () => {
editor.on('change', () => {
btnSave.disabled = false;
btnSave.classList.add('info');
btnSaveText.innerText = 'Save';
});
window.addEventListener('keydown', e => {
if (e.ctrlKey && e.code == 'KeyS') {
e.preventDefault();
btnSave.click();
}
if (e.ctrlKey && e.code == 'Minus') {
e.preventDefault();
btnTextSmaller.click();
}
if (e.ctrlKey && e.code == 'Equal') {
e.preventDefault();
btnTextBigger.click();
}
});
window.addEventListener('beforeunload', e => {
if (!btnSave.disabled) {
e.preventDefault();
e.returnValue = '';
}
});
// Set up the word wrap checkbox
const wrapCheckbox = $('input[type="checkbox"]', elControls);
wrapCheckbox.addEventListener('change', () => {
const isChecked = wrapCheckbox.checked;
//textarea.style.whiteSpace = isChecked ? 'pre-wrap' : 'pre';
editor.setOption('lineWrapping', isChecked);
window.localStorage.setItem('wrapTextEditor', isChecked);
});
wrapCheckbox.checked = window.localStorage.getItem('wrapTextEditor') == 'true';
wrapCheckbox.dispatchEvent(new Event('change'));
// Set up markdown controls
let elRendered;
if (extInfo.type == 'markdown') {
elRendered = document.createElement('div');
elRendered.classList = 'rendered';
elRendered.style.display = 'none';
const btnPreview = $('.btn.view', elControls);
const btnEdit = $('.btn.edit', elControls);
// Set up the markdown preview button
btnPreview.addEventListener('click', async() => {
btnPreview.style.display = 'none';
btnEdit.style.display = '';
elRendered.style.display = '';
//textarea.style.display = 'none';
elEditor.style.display = 'none';
//elRendered.innerHTML = marked.parse(textarea.value);
elRendered.innerHTML = marked.parse(editor.getValue());
// Make all links open in a new tab
const links = $$('a', elRendered);
for (const link of links) {
link.target = '_blank';
}
});
// Set up the markdown edit button
btnEdit.addEventListener('click', async() => {
btnPreview.style.display = '';
btnEdit.style.display = 'none';
elRendered.style.display = 'none';
//textarea.style.display = '';
elEditor.style.display = '';
});
// View file by default
btnEdit.click();
}
// Set up text size buttons
const btnTextSmaller = $('.btn.textSmaller', elControls);
const btnTextBigger = $('.btn.textBigger', elControls);
const elTextSize = $('.textSize', elControls);
let size = parseInt(window.localStorage.getItem('textEditorSize')) || 18;
const updateTextSize = () => {
//textarea.style.fontSize = `${size}px`;
elEditor.style.fontSize = `${size}px`;
elTextSize.innerText = size;
window.localStorage.setItem('textEditorSize', size);
}
updateTextSize();
btnTextSmaller.addEventListener('click', () => {
size--;
updateTextSize();
});
btnTextBigger.addEventListener('click', () => {
size++;
updateTextSize();
});
// Finalize elements
elControls.style.display = '';
//elPreview.appendChild(textarea);
if (extInfo.type == 'markdown')
elPreview.appendChild(elRendered);
break;
}
default: {
elPreview.innerHTML = `<h1 class="text-danger">Error!</h1>`;
break;
}
}
const setStatusWithDetails = () => {
setStatus(`
<div class="row flex-wrap" style="gap: 2px 20px">
<span>${formatSize(fileStats.size)}</span>
${statusHtmlSegments.join('\n')}
<span>${extInfo.mime}</span>
<span>${getRelativeDate(fileStats.modifyTime)}</span>
</div>
`)
};
setStatusWithDetails();
setTimeout(setStatusWithDetails, 60*1000);
}
const getUpdatedStats = async() => {
// Stat file
const res = await api.get('files/stat', {
path: path
});
fileStats = res.stats;
return res;
}
window.addEventListener('load', async() => {
const res = await getUpdatedStats();
if (!res.error) {
// Update navbar
path = res.path;
document.title = `${activeConnection.name} - ${path}`;
const pathSplit = path.split('/');
const folderPath = `${pathSplit.slice(0, pathSplit.length - 1).join('/')}/`;
const fileName = pathSplit[pathSplit.length - 1];
$('.path', elNavBar).innerText = folderPath;
$('.name', elNavBar).innerText = fileName;
updatePreview(fileName);
} else {
return setStatus(`Error: ${res.error}`, true);
}
});
btnDownload.addEventListener('click', async() => {
const fileName = $('.name', elNavBar).innerText;
const elSrc = $('img, video, audio', elPreview);
const elText = $('textarea', elPreview);
if (elSrc) {
console.log(`Starting download using downloaded blob`);
return downloadUrl(elSrc.src, fileName);
} else if (elText && editor) {
console.log(`Starting download using text editor value`);
const value = editor.getValue();
const dataUrl = `data:text/plain;base64,${btoa(value)}`;
return downloadUrl(dataUrl, fileName);
} else {
console.log(`Starting download using URL API`);
const url = await getFileDownloadUrl(path)
downloadUrl(url);
}
});
// Let the window finish displaying itself before saving size
setTimeout(() => {
window.addEventListener('resize', () => {
window.localStorage.setItem('viewerWidth', window.innerWidth);
window.localStorage.setItem('viewerHeight', window.innerHeight);
});
}, 2000);

2591
web/assets/index.js Normal file

File diff suppressed because it is too large Load Diff

406
web/assets/main.css Normal file
View File

@ -0,0 +1,406 @@
* {
min-width: 0px;
min-height: 0px;
}
.darkmuted {
/* Background and foreground */
--b0: hsl(215, 25%, 8%);
--b1: hsl(215, 25%, 12%);
--b2: hsl(215, 25%, 16%);
--b3: hsl(215, 25%, 20%);
--b4: hsl(215, 25%, 30%);
--b5: hsl(215, 25%, 40%);
--f4: hsl(215, 25%, 55%);
--f3: hsl(215, 25%, 70%);
--f2: hsl(215, 25%, 85%);
--f1: white;
}
.btn, .textbox {
border-radius: 8px;
}
.btn.iconOnly .icon {
margin: 0px;
}
.btn:focus-visible {
outline: 2px solid var(--f2);
}
.textbox,
.textbox.textarea > textarea {
padding: 0px 12px;
padding-top: 2px;
}
.textbox.textarea {
padding: 8px 0px;
}
.popup {
border-radius: 16px;
}
.context {
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.context > .item {
border-radius: 8px;
}
label.selectOption input[type="radio"],
label.selectOption input[type="checkbox"] {
margin-top: -0.05em;
}
.tooltip {
/* padding: 6px 12px; */
padding: 8px 12px 5px 12px;
border-radius: 8px;
}
.toastOverlay > .toast > .body {
/* padding: 15px 5px; */
padding-top: 18px;
}
.popup > .body {
padding-top: 5px;
}
body {
--fontDefault: 'Comfortaa';
}
#main {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
overflow: hidden;
}
#navbar {
padding: 8px 16px;
border-bottom: 1px solid var(--b3);
}
#fileHeader .icon {
font-family: 'Material Symbols Filled Rounded';
font-size: 28px;
color: var(--f3);
user-select: none;
}
#fileHeader .path {
font-size: 14px;
color: var(--f4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#fileHeader .name {
font-size: 18px;
color: var(--f1);
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#controls {
padding: 8px 16px;
border: 1px solid var(--b3);
border-width: 0px 0px 1px 0px;
overflow-x: auto;
overflow-y: hidden;
}
#controls::-webkit-scrollbar {
height: 3px;
background: transparent;
}
#controls::-webkit-scrollbar-thumb {
background: var(--b4);
}
#controls::-webkit-scrollbar-thumb:hover {
background: var(--b5);
}
#controls .sep {
width: 1px;
height: 20px;
margin: 0px 5px;
background-color: var(--b3);
}
#controls .selectOption {
font-size: 14px;
color: var(--f2);
margin-top: 5px;
}
#controls .selectOption input {
font-size: 28px;
}
#fileColHeadings {
padding: 10px 24px 6px 24px;
font-weight: bold;
color: var(--b5);
font-size: 14px;
user-select: none;
overflow-y: scroll;
scrollbar-gutter: stable;
}
#fileColHeadings.tiles {
display: none;
}
#files {
overflow-x: hidden;
overflow-y: auto;
height: 0px;
padding: 4px;
padding-top: 2px;
scrollbar-gutter: stable;
}
#files:not(.tiles) > .heading {
display: none;
}
#files.tiles > .heading {
display: block;
padding: 12px 20px 4px 20px;
font-weight: bold;
color: var(--b5);
font-size: 14px;
user-select: none;
flex-shrink: 0;
}
#files > .section {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 2px;
}
#files.tiles > .section {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.fileEntry {
height: auto;
padding: 6px 20px 5px 20px;
justify-content: flex-start;
--bg: transparent;
--fg: var(--f2);
--bgHover: var(--b2);
--bgActive: var(--b3);
font-weight: normal;
text-align: left;
gap: 10px;
}
.fileEntry.search {
padding-top: 9px;
padding-bottom: 7px;
}
.fileEntry.search .nameCont .path {
margin-bottom: -2px;
}
#files.tiles .fileEntry {
height: auto;
padding-top: 11px;
padding-bottom: 9px;
gap: 12px;
}
#files:not(.showHidden) .fileEntry.hidden {
display: none;
}
.fileEntry > .icon {
color: var(--f3);
font-family: 'Material Symbols Filled Rounded';
}
#files.tiles .fileEntry > .icon {
font-size: 32px;
}
.fileEntry > .nameCont {
gap: 4px;
}
.fileEntry > .nameCont .name {
color: var(--f1);
}
.fileEntry > .nameCont .lower {
display: none;
font-size: 14px;
color: var(--f3);
}
#files.tiles .fileEntry > .nameCont .lower {
display: block;
}
.fileEntry * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#files.tiles .fileEntry > :not(.icon):not(.nameCont) {
display: none;
}
.fileEntry > .date,
#fileColHeadings > .date {
width: 150px;
}
.fileEntry > .size,
#fileColHeadings > .size {
width: 100px;
}
.fileEntry > .perms,
#fileColHeadings > .perms {
width: 100px;
}
.fileEntry.selected {
--bg: var(--blue0);
--fg: var(--f1);
--bgHover: var(--blue1);
--bgActive: var(--blue2);
}
.fileEntry.selected > .icon,
.fileEntry.selected > .nameCont .lower {
color: var(--f1);
}
.fileEntry.hidden:not(.selected) > * {
opacity: 0.5;
}
.fileEntry.cut:not(.selected) {
opacity: 0.5;
}
.permsMatrix .header,
.permsMatrix .cell {
width: 70px;
height: 40px;
}
.permsMatrix .cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.permsMatrix .header {
font-size: 14px;
color: var(--f3);
display: flex;
}
.permsMatrix .header.top {
height: 20px;
text-align: center;
justify-content: center;
padding-bottom: 3px;
}
.permsMatrix .header.left {
width: 50px;
text-align: right;
justify-content: flex-end;
align-items: center;
}
#preview {
overflow: auto;
}
#preview.image,
#preview.video {
background: black;
}
#preview.audio {
padding: 10px;
}
#preview.image {
justify-content: initial;
align-items: initial;
cursor: grab;
}
#preview img {
flex-shrink: 0;
margin: auto;
}
#preview video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
#preview audio {
width: 500px;
}
#preview .CodeMirror {
width: 1200px;
height: 100%;
border: none;
border-left: 1px solid var(--b2);
border-right: 1px solid var(--b2);
}
#preview.markdown .rendered {
width: 1200px;
padding: 20px;
margin: auto;
}
#progressBar {
border-radius: 0px;
margin: 0px;
height: 3px;
display: none;
}
#progressBar.visible {
display: block;
}
#statusBar {
padding: 8px 10px 6px 10px;
font-size: 15px;
color: var(--f4);
border-top: 1px solid var(--b3);
line-height: 1.2;
}
#statusBar.error {
color: var(--red2);
}
#connectionManager .entry > .icon {
font-family: 'Material Symbols Outlined Rounded';
font-size: 32px;
color: var(--f3);
user-select: none;
}
#connectionManager .entry > .row {
gap: 8px 20px;
}
.moveFilesPicker .folders {
border-radius: 12px;
padding: 4px;
gap: 2px;
border: 1px solid var(--b3);
height: 300px;
overflow-y: auto;
}
/* 540px */
@media (max-width: 640px) {
.atLeast640px {
display: none;
}
}
@media (min-width: 641px) {
.atMost640px {
display: none;
}
}
/* 800px */
@media (max-width: 800px) {
.atLeast800px {
display: none;
}
}
@media (min-width: 801px) {
.atMost800px {
display: none;
}
}
/* 1000px */
@media (max-width: 1000px) {
.atLeast1000px {
display: none;
}
}
@media (min-width: 1001px) {
.atMost1000px {
display: none;
}
}

325
web/assets/main.js Normal file
View File

@ -0,0 +1,325 @@
const elProgressBar = $('#progressBar');
const elStatusBar = $('#statusBar');
const isElectron = window && window.process && window.process.type;
/**
* The hostname of the API
* @type {string}
*/
let apiHost = window.localStorage.getItem('apiHost') || window.location.host;
let isLocalhost = window.location.hostname == 'localhost';
let httpProtocol = isLocalhost ? 'http' : 'https';
let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss';
/** An object of saved connection information */
let connections = JSON.parse(window.localStorage.getItem('connections')) || {};
/** The current active connection */
let activeConnection = null;
/** The ID of the current active connection */
let activeConnectionId = null;
/**
* Checks if two HTML elements overlap
* @param {HTMLElement} el1 The first element
* @param {HTMLElement} el2 The second element
* @returns {boolean} True if the elements overlap, false otherwise
*/
function checkDoElementsOverlap(el1, el2) {
const rect1 = el1.getBoundingClientRect();
const rect2 = el2.getBoundingClientRect();
const overlap = !(rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom);
return overlap;
}
function permsStringToNum(str) {
let temp;
let result = '';
const user = str.substring(1, 4);
const group = str.substring(4, 7);
const other = str.substring(7, 10);
for (const perm of [user, group, other]) {
temp = 0;
if (perm.includes('r')) temp += 4;
if (perm.includes('w')) temp += 2;
if (perm.includes('x')) temp += 1;
result += temp;
}
return result;
}
const downloadUrl = (url, name) => {
const a = document.createElement('a');
a.href = url;
a.download = name || '';
a.click();
}
const getFileExtInfo = (path, size) => {
const ext = path.split('.').pop().toLowerCase();
const types = {
image: {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg',
webp: 'image/webp'
},
video: {
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg'
},
audio: {
mp3: 'audio/mpeg',
wav: 'audio/wav'
},
text: {
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
json: 'application/json',
py: 'text/x-python',
php: 'text/x-php',
java: 'text/x-java-source',
c: 'text/x-c',
cpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
go: 'text/x-go',
rs: 'text/x-rust',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bat: 'text/x-batch',
ps1: 'text/x-powershell',
sql: 'text/x-sql',
yaml: 'text/yaml',
yml: 'text/yaml',
ts: 'text/typescript',
properties: 'text/x-properties',
toml: 'text/x-toml',
cfg: 'text/x-properties',
conf: 'text/x-properties',
ini: 'text/x-properties',
log: 'text/x-log'
},
markdown: {
md: 'text/markdown',
markdown: 'text/markdown'
}
};
// https://codemirror.net/5/mode/index.html
// https://github.com/codemirror/codemirror5/tree/master/mode
const getKeywordsObject = keywords => {
const obj = {};
for (const word of keywords) obj[word] = true;
return obj;
}
const codeMirrorModes = {
html: 'htmlmixed',
css: 'css',
js: 'javascript',
json: {
name: 'javascript',
json: true
},
py: 'python',
php: 'php',
java: {
name: 'clike',
keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' '))
},
c: {
name: 'clike',
keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' '))
},
cpp: {
name: 'clike',
keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')),
useCPP: true
},
cs: {
name: 'clike',
keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')),
},
rb: 'ruby',
go: 'go',
rs: 'rust',
swift: 'swift',
sh: 'shell',
ps1: 'powershell',
sql: 'sql',
yaml: 'yaml',
yml: 'yaml',
ts: 'javascript',
properties: 'properties',
toml: 'toml',
cfg: 'properties',
conf: 'properties',
ini: 'properties',
md: 'gfm',
markdown: 'gfm'
};
const maxSizes = {
image: 1024*1024*16,
video: 1024*1024*16,
audio: 1024*1024*16,
text: 1024*1024*2,
markdown: 1024*1024*2
};
const data = { isViewable: false, type: null, mime: null };
if (!path.match(/\./g)) {
data.isViewable = true;
data.type = 'text';
data.mime = 'application/octet-stream';
data.codeMirrorMode = null;
} else {
for (const type in types) {
if (types[type][ext]) {
data.isViewable = true;
data.type = type;
data.mime = types[type][ext];
data.codeMirrorMode = codeMirrorModes[ext] || null;
break;
}
}
}
if (data.isViewable && size) {
if (size > maxSizes[data.type]) {
data.isViewable = false;
}
}
return data;
}
/**
* Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer)
*/
const getIsMobileDevice = () => {
const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches;
const isHoverNone = window.matchMedia('(hover: none)').matches;
return isPointerCoarse && isHoverNone;
}
/**
* Returns an object of headers for API requests that interface with the current active server
*/
const getHeaders = () => {
const headers = {
'sftp-host': activeConnection.host,
'sftp-port': activeConnection.port,
'sftp-username': activeConnection.username
};
if (activeConnection.password)
headers['sftp-password'] = encodeURIComponent(activeConnection.password);
if (activeConnection.key)
headers['sftp-key'] = encodeURIComponent(activeConnection.key);
return headers;
}
const api = {
/**
* Makes requests to the API
* @param {'get'|'post'|'put'|'delete'} method The request method
* @param {string} url The sub-URL of an API endpoint
* @param {object|undefined} params An object of key-value query params
* @param {*} body The body of the request, if applicable
* @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event
* @returns {object} An object representing the response data or error info
*/
request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => {
url = `${httpProtocol}://${apiHost}/api/sftp/${url}`;
try {
const opts = {
params, headers: getHeaders(),
onUploadProgress: onProgress,
onDownloadProgress: onProgress,
responseType: responseType
};
let res = null;
if (method == 'get' || method == 'delete') {
res = await axios[method](url, opts);
} else {
res = await axios[method](url, body, opts);
}
//console.log(`Response from ${url}:`, res.data);
return res.data;
} catch (error) {
if (responseType !== 'json') {
console.error(error);
return null;
}
if (error.response?.data) {
console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data);
return error.response.data;
} else {
console.error(error);
return {
success: false,
error: `${error}`
};
}
}
},
get: (url, params) => api.request('get', url, params),
post: (url, params, body) => api.request('post', url, params, body),
put: (url, params, body) => api.request('put', url, params, body),
delete: (url, params) => api.request('delete', url, params)
};
/**
* Updates the bottom status bar.
* @param {string} html The status text
* @param {boolean} isError If `true`, turns the status red
* @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it
* @returns {boolean} The negation of `isError`
*/
const setStatus = (html, isError = false, progress = null) => {
elStatusBar.innerHTML = html;
elStatusBar.classList.toggle('error', isError);
elProgressBar.classList.remove('visible');
if (progress !== null) {
elProgressBar.classList.add('visible');
if (progress >= 0 && progress <= 100)
elProgressBar.value = progress;
else
elProgressBar.removeAttribute('value');
}
return !isError;
}
/**
* Resolves with a download URL for a single file, or `false` if an error occurred.
* @param {string} path The file path
* @returns {Promise<string|boolean>}
*/
const getFileDownloadUrl = async path => {
setStatus(`Getting single file download URL...`);
const res = await api.get('files/get/single/url', {
path: path
});
if (res.error) {
return setStatus(`Error: ${res.error}`, true);
}
if (res.download_url) {
return res.download_url;
}
return false;
}
/**
* Starts a single-file download.
* @param {string} path The file path
*/
const downloadFile = async path => {
const url = await getFileDownloadUrl(path);
if (url) {
downloadUrl(url);
setStatus(`Single file download started`);
}
}

325
web/assets/main.js_ Normal file
View File

@ -0,0 +1,325 @@
const elProgressBar = $('#progressBar');
const elStatusBar = $('#statusBar');
const isElectron = window && window.process && window.process.type;
/**
* The hostname of the API
* @type {string}
*/
let apiHost = window.localStorage.getItem('apiHost') || window.location.host;
let isLocalhost = window.location.hostname == 'localhost';
let httpProtocol = isLocalhost ? 'http' : 'https';
let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss';
/** An object of saved connection information */
let connections = JSON.parse(window.localStorage.getItem('connections')) || {};
/** The current active connection */
let activeConnection = null;
/** The ID of the current active connection */
let activeConnectionId = null;
/**
* Checks if two HTML elements overlap
* @param {HTMLElement} el1 The first element
* @param {HTMLElement} el2 The second element
* @returns {boolean} True if the elements overlap, false otherwise
*/
function checkDoElementsOverlap(el1, el2) {
const rect1 = el1.getBoundingClientRect();
const rect2 = el2.getBoundingClientRect();
const overlap = !(rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom);
return overlap;
}
function permsStringToNum(str) {
let temp;
let result = '';
const user = str.substring(1, 4);
const group = str.substring(4, 7);
const other = str.substring(7, 10);
for (const perm of [user, group, other]) {
temp = 0;
if (perm.includes('r')) temp += 4;
if (perm.includes('w')) temp += 2;
if (perm.includes('x')) temp += 1;
result += temp;
}
return result;
}
const downloadUrl = (url, name) => {
const a = document.createElement('a');
a.href = url;
a.download = name || '';
a.click();
}
const getFileExtInfo = (path, size) => {
const ext = path.split('.').pop().toLowerCase();
const types = {
image: {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg',
webp: 'image/webp'
},
video: {
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg'
},
audio: {
mp3: 'audio/mpeg',
wav: 'audio/wav'
},
text: {
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
json: 'application/json',
py: 'text/x-python',
php: 'text/x-php',
java: 'text/x-java-source',
c: 'text/x-c',
cpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
go: 'text/x-go',
rs: 'text/x-rust',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bat: 'text/x-batch',
ps1: 'text/x-powershell',
sql: 'text/x-sql',
yaml: 'text/yaml',
yml: 'text/yaml',
ts: 'text/typescript',
properties: 'text/x-properties',
toml: 'text/x-toml',
cfg: 'text/x-properties',
conf: 'text/x-properties',
ini: 'text/x-properties',
log: 'text/x-log'
},
markdown: {
md: 'text/markdown',
markdown: 'text/markdown'
}
};
// https://codemirror.net/5/mode/index.html
// https://github.com/codemirror/codemirror5/tree/master/mode
const getKeywordsObject = keywords => {
const obj = {};
for (const word of keywords) obj[word] = true;
return obj;
}
const codeMirrorModes = {
html: 'htmlmixed',
css: 'css',
js: 'javascript',
json: {
name: 'javascript',
json: true
},
py: 'python',
php: 'php',
java: {
name: 'clike',
keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' '))
},
c: {
name: 'clike',
keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' '))
},
cpp: {
name: 'clike',
keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')),
useCPP: true
},
cs: {
name: 'clike',
keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')),
},
rb: 'ruby',
go: 'go',
rs: 'rust',
swift: 'swift',
sh: 'shell',
ps1: 'powershell',
sql: 'sql',
yaml: 'yaml',
yml: 'yaml',
ts: 'javascript',
properties: 'properties',
toml: 'toml',
cfg: 'properties',
conf: 'properties',
ini: 'properties',
md: 'gfm',
markdown: 'gfm'
};
const maxSizes = {
image: 1024*1024*16,
video: 1024*1024*16,
audio: 1024*1024*16,
text: 1024*1024*2,
markdown: 1024*1024*2
};
const data = { isViewable: false, type: null, mime: null };
if (!path.match(/\./g)) {
data.isViewable = true;
data.type = 'text';
data.mime = 'application/octet-stream';
data.codeMirrorMode = null;
} else {
for (const type in types) {
if (types[type][ext]) {
data.isViewable = true;
data.type = type;
data.mime = types[type][ext];
data.codeMirrorMode = codeMirrorModes[ext] || null;
break;
}
}
}
if (data.isViewable && size) {
if (size > maxSizes[data.type]) {
data.isViewable = false;
}
}
return data;
}
/**
* Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer)
*/
const getIsMobileDevice = () => {
const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches;
const isHoverNone = window.matchMedia('(hover: none)').matches;
return isPointerCoarse && isHoverNone;
}
/**
* Returns an object of headers for API requests that interface with the current active server
*/
const getHeaders = () => {
const headers = {
'sftp-host': activeConnection.host,
'sftp-port': activeConnection.port,
'sftp-username': activeConnection.username
};
if (activeConnection.password)
headers['sftp-password'] = encodeURIComponent(activeConnection.password);
if (activeConnection.key)
headers['sftp-key'] = encodeURIComponent(activeConnection.key);
return headers;
}
const api = {
/**
* Makes requests to the API
* @param {'get'|'post'|'put'|'delete'} method The request method
* @param {string} url The sub-URL of an API endpoint
* @param {object|undefined} params An object of key-value query params
* @param {*} body The body of the request, if applicable
* @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event
* @returns {object} An object representing the response data or error info
*/
request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => {
url = `${httpProtocol}://${apiHost}/api/sftp/${url}`;
try {
const opts = {
params, headers: getHeaders(),
onUploadProgress: onProgress,
onDownloadProgress: onProgress,
responseType: responseType
};
let res = null;
if (method == 'get' || method == 'delete') {
res = await axios[method](url, opts);
} else {
res = await axios[method](url, body, opts);
}
//console.log(`Response from ${url}:`, res.data);
return res.data;
} catch (error) {
if (responseType !== 'json') {
console.error(error);
return null;
}
if (error.response?.data) {
console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data);
return error.response.data;
} else {
console.error(error);
return {
success: false,
error: `${error}`
};
}
}
},
get: (url, params) => api.request('get', url, params),
post: (url, params, body) => api.request('post', url, params, body),
put: (url, params, body) => api.request('put', url, params, body),
delete: (url, params) => api.request('delete', url, params)
};
/**
* Updates the bottom status bar.
* @param {string} html The status text
* @param {boolean} isError If `true`, turns the status red
* @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it
* @returns {boolean} The negation of `isError`
*/
const setStatus = (html, isError = false, progress = null) => {
elStatusBar.innerHTML = html;
elStatusBar.classList.toggle('error', isError);
elProgressBar.classList.remove('visible');
if (progress !== null) {
elProgressBar.classList.add('visible');
if (progress >= 0 && progress <= 100)
elProgressBar.value = progress;
else
elProgressBar.removeAttribute('value');
}
return !isError;
}
/**
* Resolves with a download URL for a single file, or `false` if an error occurred.
* @param {string} path The file path
* @returns {Promise<string|boolean>}
*/
const getFileDownloadUrl = async path => {
setStatus(`Getting single file download URL...`);
const res = await api.get('files/get/single/url', {
path: path
});
if (res.error) {
return setStatus(`Error: ${res.error}`, true);
}
if (res.download_url) {
return res.download_url;
}
return false;
}
/**
* Starts a single-file download.
* @param {string} path The file path
*/
const downloadFile = async path => {
const url = await getFileDownloadUrl(path);
if (url) {
downloadUrl(url);
setStatus(`Single file download started`);
}
}

60
web/file.html Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>File Viewer</title>
<meta name="description" content="Connect to and manage files on your SFTP server with ease!">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1f2733">
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.png">
<link rel="stylesheet" href="https://src.simplecyber.org/lib/codemirror5.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/themes.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/base.css">
<link rel="stylesheet" href="/assets/main.css">
<script defer src="https://src.simplecyber.org/lib/axios.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/tabbable.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/focus-trap.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/dayjs.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/marked.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-scrollPastEnd.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-activeLine.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-loadMode.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-closeBrackets.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-overlay.js"></script>
<script defer src="https://src.simplecyber.org/v2/base.js"></script>
<script defer src="https://src.simplecyber.org/utils.js"></script>
<script defer src="/assets/main.js"></script>
<script defer src="/assets/file.js"></script>
</head>
<body class="darkmuted">
<div id="main" class="col">
<div id="navbar" class="row gap-20 align-center flex-no-shrink">
<button class="btn secondary iconOnly" onClick="window.close()" title="Close">
<div class="icon">close</div>
</button>
<div id="fileHeader" class="row gap-10 flex-grow align-center">
<div class="icon flex-no-shrink">insert_drive_file</div>
<div class="col gap-2">
<div class="path"></div>
<div class="name"></div>
</div>
</div>
<button id="download" class="btn iconOnly" title="Download file">
<div class="icon">download</div>
</button>
</div>
<div id="controls" class="row gap-10 align-center flex-no-shrink" style="display: none"></div>
<div id="preview" class="row flex-grow align-center justify-center">
<div class="spinner" style="margin: auto"></div>
</div>
<progress id="progressBar" min="0" max="100" value="0"></progress>
<div id="statusBar" class="row align-center flex-no-shrink">
Loading file...
</div>
</div>
</body>
</html>

BIN
web/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

143
web/index.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>SFTP Browser</title>
<meta name="description" content="Connect to and manage files on your SFTP server with ease!">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1f2733">
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.png">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/themes.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/base.css">
<link rel="stylesheet" href="/assets/main.css">
<script defer src="https://src.simplecyber.org/lib/axios.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/tabbable.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/focus-trap.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/dayjs.min.js"></script>
<script defer src="https://src.simplecyber.org/v2/base.js"></script>
<script defer src="https://src.simplecyber.org/utils.js"></script>
<script defer src="/assets/main.js"></script>
<script defer src="/assets/index.js"></script>
</head>
<body class="darkmuted">
<div id="main" class="col">
<div id="navbar" class="row gap-20 align-center flex-no-shrink">
<button id="connections" class="btn" title="Connections...<br><small>Ctrl + Shift + Space</small>">
<div class="icon">public</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<div id="inputPathCont" class="atLeast640px row gap-10 flex-grow">
<button id="navBack" class="btn iconOnly tertiary" title="Back<br><small>Alt + ArrowLeft</small>" disabled>
<div class="icon">arrow_back</div>
</button>
<button id="navForward" class="btn iconOnly tertiary" title="Forward<br><small>Alt + ArrowRight</small>" disabled>
<div class="icon">arrow_forward</div>
</button>
<input type="text" id="inputNavPath" class="textbox" placeholder="Enter a path...">
<button id="pathGo" class="btn iconOnly secondary" title="Go/Reload<br><small>Ctrl + R</small>">
<div class="icon">refresh</div>
</button>
</div>
<div id="inputSearchCont" class="atLeast1000px row gap-10" style="width: 320px">
<div class="row align-center flex-grow">
<input type="text" id="inputNavSearch" class="textbox" placeholder="Search within folder..." style="padding-right: calc(3px + 34px + 3px)">
<button id="navSearchCancel" class="btn small tertiary iconOnly" style="margin-left: calc(-34px - 3px)">
<div class="icon">close</div>
</button>
</div>
<button id="navSearchGo" class="btn iconOnly" title="Search">
<div class="icon">search</div>
</button>
</div>
<button id="pathPopup" class="btn secondary atMost640px" title="Go to folder...">
<div class="icon">folder_open</div>
<div class="icon" style="margin-top: 0px">expand_more</div>
</button>
</div>
<div class="col flex-grow">
<div id="controls" class="row gap-10 align-center flex-no-shrink">
<div class="row gap-10 align-center flex-no-shrink atLeast800px">
<button id="upload" class="btn small iconOnly secondary" title="Upload files...<br><small>Shift + U</small>" disabled>
<div class="icon">upload</div>
</button>
<button id="dirCreate" class="btn small iconOnly secondary" title="New folder...<br><small>Shift + N</small>" disabled>
<div class="icon">create_new_folder</div>
</button>
<button id="fileCreate" class="btn small iconOnly secondary" title="New file..." disabled>
<div class="icon">post_add</div>
</button>
<div class="sep"></div>
<button id="fileCut" class="btn small iconOnly secondary" title="Cut<br><small>Ctrl + X</small>" disabled>
<div class="icon">cut</div>
</button>
<button id="fileCopy" class="btn small iconOnly secondary" title="Copy<br><small>Ctrl + C</small>" disabled>
<div class="icon">file_copy</div>
</button>
<button id="filePaste" class="btn small iconOnly secondary" title="Paste<br><small>Ctrl + V</small>" disabled>
<div class="icon">content_paste</div>
</button>
<div class="sep"></div>
<button id="fileRename" class="btn small iconOnly secondary" title="Rename...<br><small>F2</small>" disabled>
<div class="icon">edit_note</div>
</button>
<button id="fileMoveTo" class="btn small iconOnly secondary" title="Move to...<br><small>Shift + M</small>" disabled>
<div class="icon">drive_file_move</div>
</button>
<button id="fileCopyTo" class="btn small iconOnly secondary" title="Copy to...<br><small>Shift + C</small>" disabled>
<div class="icon">move_group</div>
</button>
<button id="fileDelete" class="btn small iconOnly secondary" title="Delete...<br><small>Del</small>" disabled>
<div class="icon" style="color: var(--red2)">delete</div>
</button>
<button id="filePerms" class="btn small iconOnly secondary" title="Edit permissions..." disabled>
<div class="icon">admin_panel_settings</div>
</button>
<div class="sep"></div>
<button id="fileDownload" class="btn small iconOnly secondary" title="Download<br><small>Shift + D</small>" disabled>
<div class="icon">download</div>
</button>
<button id="fileShare" class="btn small iconOnly secondary" title="Copy download link..." disabled>
<div class="icon">share</div>
</button>
</div>
<button id="dirMenu" class="btn small secondary atMost800px" title="File...">
File
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<button id="deselectAll" class="btn small iconOnly secondary atMost800px" title="Deselect all" style="display: none">
<div class="icon">close</div>
</button>
<div class="sep"></div>
<button id="dirView" class="btn small secondary" title="View...">
<div class="icon">visibility</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<button id="dirSort" class="btn small secondary" title="Sort..." disabled>
<div class="icon">sort</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<div class="row gap-10 align-center flex-no-shrink atLeast800px">
<button id="dirSelection" class="btn small secondary" title="Selection...">
<div class="icon" style="margin-top: 0px">select</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
</div>
</div>
<div id="fileColHeadings" class="row gap-10">
<div class="name flex-grow">Name</div>
<div class="date">Modified</div>
<div class="size">Size</div>
<div class="perms">Permissions</div>
</div>
<div id="files" class="col flex-grow gap-2"></div>
<progress id="progressBar" min="0" max="100" value="0"></progress>
<div id="statusBar" class="row align-center flex-no-shrink">
Waiting for connection...
</div>
</div>
</div>
</body>
</html>

18
web/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"id": "/",
"start_url": "/",
"scope": "/",
"name": "SFTP Browser",
"short_name": "SFTP",
"description": "Manage files on your SFTP server with ease!",
"categories": [ "utilities", "productivity" ],
"icons": [{
"src": "/icon.png",
"size": "256x256",
"type": "image/png",
"purpose": "maskable any"
}],
"background_color": "#1f2733",
"theme_color": "#1f2733",
"display": "standalone"
}

29
web/worker.js Normal file
View File

@ -0,0 +1,29 @@
self.addEventListener('activate', (e) => {
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
const reqUrl = e.request.url;
e.respondWith((async() => {
// Open asset cache and see if this request is in it
const cache = await caches.open('main');
const match = await caches.match(e.request);
// Request the resource from the network
const netRes = fetch(e.request).then((res) => {
// If the request was successful and this isn't an API call,
// update the cached resource
if (res.ok && !reqUrl.match(/\/api\/sftp\/.*$/)) {
cache.put(e.request, res.clone());
}
// Return the response
return res;
}).catch(e => {
console.error(e);
return match;
});
// Return the cached resource if it exists
// Otherwise, return the network request
return match || netRes;
})());
});