first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/test*
|
||||
/node_modules
|
||||
/dist
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
309
README.md
Normal 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
3
config.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"port": 8261
|
||||
}
|
8
ecosystem.config.js
Normal file
8
ecosystem.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'sftp-browser',
|
||||
script: './server.js',
|
||||
watch: [ 'server.js' ]
|
||||
}]
|
||||
};
|
2090
package-lock.json
generated
Normal file
2090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal 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
797
sftp-browser.js
Normal 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
488
web/assets/file.js
Normal 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
2591
web/assets/index.js
Normal file
File diff suppressed because it is too large
Load Diff
406
web/assets/main.css
Normal file
406
web/assets/main.css
Normal 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
325
web/assets/main.js
Normal 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
325
web/assets/main.js_
Normal 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
60
web/file.html
Normal 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
BIN
web/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
143
web/index.html
Normal file
143
web/index.html
Normal 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
18
web/manifest.json
Normal 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
29
web/worker.js
Normal 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;
|
||||
})());
|
||||
});
|
Reference in New Issue
Block a user