This commit is contained in:
parent
f25d1f1a05
commit
eb954e7470
307 changed files with 24443 additions and 14699 deletions
257
services/app.js
Normal file
257
services/app.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Creates Express app, for all the server-side routes + middleware
|
||||
* Which gets imported by the server.js in the root
|
||||
* */
|
||||
|
||||
/* Import built-in Node server modules */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/* Project root — one level up from services/ */
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
|
||||
/* Import NPM dependencies */
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
/* Import Express + middleware functions */
|
||||
const express = require('express');
|
||||
const basicAuth = require('express-basic-auth');
|
||||
const history = require('connect-history-api-fallback');
|
||||
|
||||
/* Kick of some basic checks */
|
||||
require('./update-checker'); // Checks if there are any updates available, prints message
|
||||
|
||||
let config = require('./config-validator'); // Validate config file and load result
|
||||
|
||||
/* Include route handlers for API endpoints */
|
||||
const statusCheck = require('./status-check'); // Used by the status check feature, uses GET
|
||||
const saveConfig = require('./save-config'); // Saves users new conf.yml to file-system
|
||||
const rebuild = require('./rebuild-app'); // A script to programmatically trigger a build
|
||||
const systemInfo = require('./system-info'); // Basic system info, for resource widget
|
||||
const sslServer = require('./ssl-server'); // TLS-enabled web server
|
||||
const corsProxy = require('./cors-proxy'); // Enables API requests to CORS-blocked services
|
||||
const getUser = require('./get-user'); // Enables server side user lookup
|
||||
|
||||
/* Helper functions, and default config */
|
||||
const ENDPOINTS = require('../src/utils/defaults').serviceEndpoints; // API endpoint URL paths
|
||||
|
||||
/* Indicates for the webpack config, that running as a server */
|
||||
process.env.IS_SERVER = 'True';
|
||||
|
||||
/* Just console.warns an error */
|
||||
const printWarning = (msg, error) => {
|
||||
console.warn(`\x1b[103m\x1b[34m${msg}\x1b[0m\n`, error || ''); // eslint-disable-line no-console
|
||||
};
|
||||
|
||||
/* Send a response body if the stream is already closed, with optional status */
|
||||
const safeEnd = (res, body, status) => {
|
||||
if (res.headersSent) return;
|
||||
try {
|
||||
if (status) res.status(status);
|
||||
res.end(body);
|
||||
} catch (e) { /* response stream gone */ }
|
||||
};
|
||||
|
||||
/* Build a serialized JSON error body */
|
||||
const errBody = (e) => JSON.stringify({
|
||||
success: false,
|
||||
message: String(e && e.message ? e.message : e),
|
||||
});
|
||||
|
||||
/* Catch any possible unhandled error. Shouldn't ever happen! */
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
printWarning('Unhandled promise rejection in server', reason);
|
||||
});
|
||||
|
||||
/* Load appConfig.auth from config (if present) for authorization purposes */
|
||||
function loadAuthConfig() {
|
||||
try {
|
||||
const filePath = path.join(rootDir, process.env.USER_DATA_DIR || 'user-data', 'conf.yml');
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||
const data = yaml.load(fileContents);
|
||||
return data?.appConfig?.auth || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadUserConfig() {
|
||||
return loadAuthConfig().users || null;
|
||||
}
|
||||
|
||||
/* Authorizer for ENABLE_HTTP_AUTH: validates credentials against conf.yml users */
|
||||
function customAuthorizer(username, password) {
|
||||
const sha256 = (input) => crypto.createHash('sha256').update(input).digest('hex').toUpperCase();
|
||||
const generateUserToken = (user) => {
|
||||
if (!user.user || (!user.hash && !user.password)) return '';
|
||||
const strAndUpper = (input) => input.toString().toUpperCase();
|
||||
const passwordHash = user.hash || sha256(process.env[user.password]);
|
||||
const sha = sha256(strAndUpper(user.user) + strAndUpper(passwordHash));
|
||||
return strAndUpper(sha);
|
||||
};
|
||||
if (password.startsWith('Bearer ')) {
|
||||
const token = password.slice('Bearer '.length);
|
||||
const users = loadUserConfig();
|
||||
return users.some(user => (
|
||||
user.user.toLowerCase() === username.toLowerCase() && generateUserToken(user) === token
|
||||
));
|
||||
} else {
|
||||
const users = loadUserConfig();
|
||||
const userHash = sha256(password);
|
||||
return users.some(user => (
|
||||
user.user.toLowerCase() === username.toLowerCase() && user.hash.toUpperCase() === userHash
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/* If auth is enabled, setup auth for config access, otherwise skip */
|
||||
function getBasicAuthMiddleware() {
|
||||
const authConfig = loadAuthConfig();
|
||||
const confUsers = authConfig.users || null;
|
||||
const hasConfUsers = confUsers && confUsers.length > 0;
|
||||
const useConfAuth = process.env.ENABLE_HTTP_AUTH && hasConfUsers;
|
||||
const { BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD } = process.env;
|
||||
const hasStaticCreds = BASIC_AUTH_USERNAME && BASIC_AUTH_PASSWORD;
|
||||
|
||||
// Warn if both auth methods are configured - they don't work together
|
||||
if (hasStaticCreds && hasConfUsers) {
|
||||
printWarning(useConfAuth
|
||||
? 'BASIC_AUTH env vars are ignored because ENABLE_HTTP_AUTH is active with conf.yml users.'
|
||||
: 'BASIC_AUTH env vars and appConfig.auth.users are both set but use different credentials.'
|
||||
+ ' This will cause auth failures. Set ENABLE_HTTP_AUTH=true, or remove users from conf.yml.');
|
||||
}
|
||||
|
||||
if (useConfAuth) {
|
||||
return basicAuth({
|
||||
authorizer: customAuthorizer,
|
||||
challenge: true,
|
||||
unauthorizedResponse: () => 'Unauthorized - Incorrect token',
|
||||
});
|
||||
} else if (hasStaticCreds) {
|
||||
return basicAuth({
|
||||
users: { [BASIC_AUTH_USERNAME]: BASIC_AUTH_PASSWORD },
|
||||
challenge: true,
|
||||
unauthorizedResponse: () => 'Unauthorized - Incorrect username or password',
|
||||
});
|
||||
} else if (authConfig.enableHeaderAuth && authConfig.headerAuth) {
|
||||
const { userHeader = 'Remote-User', proxyWhitelist = [] } = authConfig.headerAuth;
|
||||
return (req, res, next) => {
|
||||
if (!proxyWhitelist.includes(req.socket.remoteAddress)) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized - not from trusted proxy' });
|
||||
}
|
||||
const user = req.headers[userHeader.toLowerCase()];
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized - missing user header' });
|
||||
}
|
||||
req.auth = { user };
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
return (req, res, next) => next();
|
||||
}
|
||||
|
||||
const protectConfig = getBasicAuthMiddleware();
|
||||
|
||||
/* Middleware to restrict write endpoints to admin users only */
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.auth) return next();
|
||||
const users = loadUserConfig();
|
||||
if (!users || users.length === 0) return next();
|
||||
const user = users.find(u => u.user.toLowerCase() === req.auth.user.toLowerCase());
|
||||
if (user && user.type === 'admin') return next();
|
||||
return res.status(403).json({ success: false, message: 'Forbidden - Admin access required' });
|
||||
}
|
||||
|
||||
/* A middleware function for Connect, that filters requests based on method type */
|
||||
const method = (m, mw) => (req, res, next) => (req.method === m ? mw(req, res, next) : next());
|
||||
|
||||
const app = express()
|
||||
// Load SSL redirection middleware
|
||||
.use(sslServer.middleware)
|
||||
// Load middlewares for parsing JSON, and supporting HTML5 history routing
|
||||
.use(express.json({ limit: '1mb' }))
|
||||
// GET endpoint to run status of a given URL with GET request
|
||||
.use(ENDPOINTS.statusCheck, protectConfig, method('GET', (req, res) => {
|
||||
try {
|
||||
statusCheck(req.url, (results) => {
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(results);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
printWarning(`Error running status check for ${req.url}\n`, e);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).end(JSON.stringify({ successStatus: false, message: '❌ Status check failed badly' }));
|
||||
}
|
||||
}
|
||||
}))
|
||||
// POST Endpoint used to save config, by writing config file to disk
|
||||
.use(ENDPOINTS.save, protectConfig, requireAdmin, method('POST', (req, res) => {
|
||||
let responded = false;
|
||||
const respond = (jsonBody) => {
|
||||
if (responded || res.headersSent) return;
|
||||
responded = true;
|
||||
try { // Only update in-memory config when disk write succeeds
|
||||
if (JSON.parse(jsonBody).success === true) config = req.body.config;
|
||||
} catch (e) { /* unparseable body, config is unchanged */ }
|
||||
try { res.end(jsonBody); } catch (e) { /* response stream gone */ }
|
||||
};
|
||||
saveConfig(req.body, respond).catch((e) => {
|
||||
printWarning('Error writing config file to disk', e);
|
||||
respond(JSON.stringify({ success: false, message: String(e) }));
|
||||
});
|
||||
}))
|
||||
// GET endpoint to trigger a build, and respond with success status and output
|
||||
.use(ENDPOINTS.rebuild, protectConfig, requireAdmin, method('GET', (req, res) => {
|
||||
rebuild()
|
||||
.then((response) => safeEnd(res, JSON.stringify(response)))
|
||||
.catch((e) => safeEnd(res, errBody(e)));
|
||||
}))
|
||||
// GET endpoint to return system info, for widget
|
||||
.use(ENDPOINTS.systemInfo, protectConfig, method('GET', (req, res) => {
|
||||
try {
|
||||
safeEnd(res, JSON.stringify(systemInfo()));
|
||||
} catch (e) {
|
||||
safeEnd(res, errBody(e));
|
||||
}
|
||||
}))
|
||||
// GET for accessing non-CORS API services
|
||||
.use(ENDPOINTS.corsProxy, protectConfig, (req, res) => {
|
||||
try {
|
||||
corsProxy(req, res);
|
||||
} catch (e) {
|
||||
safeEnd(res, errBody(e));
|
||||
}
|
||||
})
|
||||
// GET endpoint to return user info
|
||||
.use(ENDPOINTS.getUser, protectConfig, method('GET', (req, res) => {
|
||||
try {
|
||||
safeEnd(res, JSON.stringify(getUser(config, req)));
|
||||
} catch (e) {
|
||||
safeEnd(res, errBody(e));
|
||||
}
|
||||
}))
|
||||
// Middleware to serve any .yml files in USER_DATA_DIR with optional protection
|
||||
.get('/*.yml', protectConfig, (req, res) => {
|
||||
const ymlFile = req.path.split('/').pop();
|
||||
const filePath = path.join(rootDir, process.env.USER_DATA_DIR || 'user-data', ymlFile);
|
||||
res.sendFile(filePath, (err) => {
|
||||
if (err) safeEnd(res, errBody(`Could not read ${ymlFile}`), 404);
|
||||
});
|
||||
})
|
||||
// Serves up static files
|
||||
.use(express.static(path.join(rootDir, process.env.USER_DATA_DIR || 'user-data')))
|
||||
.use(express.static(path.join(rootDir, 'dist')))
|
||||
.use(express.static(path.join(rootDir, 'public'), { index: 'initialization.html' }))
|
||||
.use(history())
|
||||
// If no other route is matched, serve up the index.html with a 404 status
|
||||
.use((req, res) => {
|
||||
res.status(404).sendFile(path.join(rootDir, 'dist', 'index.html'), (err) => {
|
||||
if (err) safeEnd(res, errBody('Not Found'));
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
|
@ -11,7 +11,7 @@ const schema = require('../src/utils/ConfigSchema.json');
|
|||
|
||||
/* Tell AJV to use strict mode, and report all errors */
|
||||
const validatorOptions = {
|
||||
strict: true,
|
||||
strict: false,
|
||||
allowUnionTypes: true,
|
||||
allErrors: true,
|
||||
};
|
||||
|
|
@ -98,11 +98,14 @@ const printFileReadError = (e) => {
|
|||
}
|
||||
};
|
||||
|
||||
let config = {};
|
||||
|
||||
try { // Try to open and parse the YAML file
|
||||
const config = yaml.load(fs.readFileSync('./public/conf.yml', 'utf8'));
|
||||
config = yaml.load(fs.readFileSync(`./${process.env.USER_DATA_DIR || 'user-data'}/conf.yml`, 'utf8'));
|
||||
validate(config);
|
||||
} catch (e) { // Something went very wrong...
|
||||
setIsValidVariable(false);
|
||||
logToConsole(bigError());
|
||||
printFileReadError(e);
|
||||
}
|
||||
module.exports = config;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,45 @@
|
|||
* makes request to endpoint, then responds to the frontend with the response
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const request = require('./request');
|
||||
|
||||
// List of hosts to disallow by default, for cloud instances
|
||||
// Covers AWS, Azure, GCP, DO, Hetzner, Oracle, etc on IPv4/6
|
||||
const BLOCKED_HOSTS = new Set([
|
||||
'169.254.169.254',
|
||||
'::ffff:a9fe:a9fe',
|
||||
'fd00:ec2::254',
|
||||
'metadata.google.internal',
|
||||
'100.100.100.200',
|
||||
]);
|
||||
|
||||
// Operator escape hatch, set this env var to bypass all proxy restrictions
|
||||
const restrictionsDisabled = !!process.env.DANGEROUSLY_DISABLE_PROXY_RESTRICTIONS;
|
||||
|
||||
// Validate the target URL against scheme and host policies
|
||||
// Returns { ok: true } on success, or { ok: false, status, error } on rejection
|
||||
const validateTargetUrl = (raw) => {
|
||||
if (restrictionsDisabled) return { ok: true };
|
||||
let url;
|
||||
try { url = new URL(raw); } catch (e) {
|
||||
return { ok: false, status: 400, error: 'Target-URL is not a valid URL' };
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return { ok: false, status: 400, error: 'Target-URL must use http:// or https://' };
|
||||
}
|
||||
// url.hostname includes brackets for IPv6 (e.g. '[fd00:ec2::254]') - strip em
|
||||
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (BLOCKED_HOSTS.has(host)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: `Target-URL host '${host}' is blocked by the CORS proxy. `
|
||||
+ 'This address is reserved for cloud instance metadata services. '
|
||||
+ 'To bypass, set DANGEROUSLY_DISABLE_PROXY_RESTRICTIONS=true.',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
module.exports = (req, res) => {
|
||||
// Apply allow-all response headers
|
||||
|
|
@ -20,28 +58,47 @@ module.exports = (req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Get desired URL, from Target-URL header
|
||||
// Get desired URL, from Target-URL header, and validate it against the policy
|
||||
const targetURL = req.header('Target-URL');
|
||||
if (!targetURL) {
|
||||
res.status(500).send({ error: 'There is no Target-Endpoint header in the request' });
|
||||
res.status(400).send({ error: 'Missing required Target-URL header' });
|
||||
return;
|
||||
}
|
||||
const validation = validateTargetUrl(targetURL);
|
||||
if (!validation.ok) {
|
||||
res.status(validation.status).send({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply any custom headers, if needed
|
||||
const headers = req.header('CustomHeaders') ? JSON.parse(req.header('CustomHeaders')) : {};
|
||||
let headers = {};
|
||||
const rawCustomHeaders = req.header('CustomHeaders');
|
||||
if (rawCustomHeaders) {
|
||||
try {
|
||||
headers = JSON.parse(rawCustomHeaders);
|
||||
} catch (e) {
|
||||
res.status(400).send({ error: 'CustomHeaders header contains malformed JSON' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the request
|
||||
const requestConfig = {
|
||||
method: req.method,
|
||||
url: targetURL,
|
||||
json: req.body,
|
||||
data: req.body,
|
||||
headers,
|
||||
timeout: 30000,
|
||||
maxResponseSize: 10 * 1024 * 1024, // 10 MB
|
||||
};
|
||||
|
||||
// Make the request, and respond with result
|
||||
axios.request(requestConfig)
|
||||
.then((response) => {
|
||||
res.status(200).send(response.data);
|
||||
}).catch((error) => {
|
||||
res.status(500).send({ error });
|
||||
});
|
||||
const send = (status, body) => {
|
||||
if (res.headersSent) return;
|
||||
try { res.status(status).send(body); } catch (e) { /* response stream gone */ }
|
||||
};
|
||||
request(requestConfig).then(
|
||||
(response) => send(200, response.data),
|
||||
(error) => send(500, { error }),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
15
services/get-user.js
Normal file
15
services/get-user.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module.exports = (config, req) => {
|
||||
try {
|
||||
if (config.appConfig?.auth?.enableHeaderAuth) {
|
||||
const { userHeader } = config.appConfig.auth.headerAuth;
|
||||
const { proxyWhitelist } = config.appConfig.auth.headerAuth;
|
||||
if (proxyWhitelist.includes(req.socket.remoteAddress)) {
|
||||
return { success: true, user: req.headers[userHeader.toLowerCase()] };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (e) {
|
||||
console.warn('Error get-user: ', e);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
|
@ -4,13 +4,27 @@
|
|||
* Note that exiting with code 1 indicates failure, and 0 is success
|
||||
*/
|
||||
|
||||
const isSsl = !!process.env.SSL_PRIV_KEY_PATH && !!process.env.SSL_PUB_KEY_PATH;
|
||||
const fs = require('fs');
|
||||
/* setting default paths for public and pvt keys to match of ssl-server.js */
|
||||
const httpsCerts = {
|
||||
private: process.env.SSL_PRIV_KEY_PATH || '/etc/ssl/certs/dashy-priv.key',
|
||||
public: process.env.SSL_PUB_KEY_PATH || '/etc/ssl/certs/dashy-pub.pem',
|
||||
};
|
||||
|
||||
/* Check if either if simular conditions exist that would of had ssl-server.js to enable ssl */
|
||||
const isSsl = !!fs.existsSync(httpsCerts.private) && !!fs.existsSync(httpsCerts.public);
|
||||
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
const http = require(isSsl ? 'https' : 'http');
|
||||
|
||||
/* Location of the server to test */
|
||||
const isDocker = !!process.env.IS_DOCKER;
|
||||
const port = isSsl ? (process.env.SSL_PORT || (isDocker ? 443 : 4001)) : (process.env.PORT || isDocker ? 80 : 4000);
|
||||
|
||||
/* Get the port to use (depending on, if docker, if SSL) */
|
||||
const sslPort = process.env.SSL_PORT || (isDocker ? 443 : 4001);
|
||||
const normalPort = process.env.PORT || (isDocker ? 8080 : 4000);
|
||||
const port = isSsl ? sslPort : normalPort;
|
||||
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
const timeout = 2000;
|
||||
|
||||
|
|
@ -18,7 +32,9 @@ const agent = new http.Agent({
|
|||
rejectUnauthorized: false, // Allow self-signed certificates
|
||||
});
|
||||
|
||||
const requestOptions = { host, port, timeout, agent };
|
||||
const requestOptions = {
|
||||
host, port, timeout, agent,
|
||||
};
|
||||
|
||||
const startTime = new Date(); // Initialize timestamp to calculate time taken
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ module.exports = (ip, port, isDocker) => {
|
|||
} else {
|
||||
// Prepare message for users running app on bare metal
|
||||
msg = `${chars.GREEN}┏${line(75)}┓${chars.BR}`
|
||||
+ `┃ ${chars.CYAN}Welcome to Dashy! 🚀${blanks(55)}${chars.GREEN}┃${chars.BR}`
|
||||
+ `┃ ${chars.CYAN}Welcome to Dashy! 🚀${blanks(54)}${chars.GREEN}┃${chars.BR}`
|
||||
+ `┃ ${chars.CYAN}Your new dashboard is now up and running at ${chars.BRIGHT}`
|
||||
+ `http://${ip}:${port}${chars.RESET}${blanks(18 - ip.length)}${chars.GREEN}┃${chars.BR}`
|
||||
+ `┗${line(75)}┛${chars.BR}${chars.BR}${chars.RESET}`;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
module.exports = () => new Promise((resolve, reject) => {
|
||||
const buildProcess = exec('npm run build'); // Trigger the build command
|
||||
const buildProcess = exec('NODE_OPTIONS="--max-old-space-size=512" npm run build'); // Trigger the build command
|
||||
|
||||
let output = ''; // Will store console output
|
||||
|
||||
|
|
|
|||
234
services/request.js
Normal file
234
services/request.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Lightweight HTTP client for server-side (Node.js) code.
|
||||
* Uses built-in http/https modules - no external dependencies.
|
||||
* Replaces axios for all server-side requests.
|
||||
*
|
||||
* Supports: .get(), .request(), custom httpsAgent options, maxRedirects,
|
||||
* gzip/deflate/brotli decompression, optional timeout,
|
||||
* and exposes the raw socket (needed by status-check.js for servername).
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const zlib = require('zlib');
|
||||
const { URL } = require('url');
|
||||
|
||||
class RequestError extends Error {
|
||||
constructor(message, { response, code, errno } = {}) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
this.response = response || undefined;
|
||||
this.code = code || undefined;
|
||||
this.errno = errno || undefined;
|
||||
}
|
||||
|
||||
// Return a JSON-safe summary, to prevent the any circular references
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
errno: this.errno,
|
||||
status: this.response && this.response.status,
|
||||
statusText: this.response && this.response.statusText,
|
||||
data: this.response && this.response.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request function.
|
||||
* @param {Object} config
|
||||
* @param {string} config.url
|
||||
* @param {string} [config.method='GET']
|
||||
* @param {Object} [config.headers={}]
|
||||
* @param {*} [config.data] - Request body (object will be JSON-stringified)
|
||||
* @param {number} [config.maxRedirects=5]
|
||||
* @param {number} [config.timeout=0] - Request timeout in ms (0 = no timeout)
|
||||
* @param {Object} [config.httpsAgent] - Options for https.Agent (e.g. { rejectUnauthorized })
|
||||
* @returns {Promise<{data, status, statusText, headers, request}>}
|
||||
*/
|
||||
function request(config) {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
data,
|
||||
json,
|
||||
maxRedirects = 5,
|
||||
timeout = 0,
|
||||
maxResponseSize = 0,
|
||||
httpsAgent,
|
||||
} = config;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const makeRequest = (targetUrl, redirectsLeft) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(targetUrl);
|
||||
} catch (e) {
|
||||
reject(new RequestError(`Invalid URL: ${targetUrl}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const reqOptions = {
|
||||
method: method.toUpperCase(),
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
headers: { ...headers },
|
||||
};
|
||||
|
||||
// Advertise supported encodings (matching axios behavior)
|
||||
if (!reqOptions.headers['Accept-Encoding'] && !reqOptions.headers['accept-encoding']) {
|
||||
reqOptions.headers['Accept-Encoding'] = 'gzip, deflate, br';
|
||||
}
|
||||
|
||||
// Support URL-embedded credentials (e.g. https://user:pass@host)
|
||||
if (parsed.username && !reqOptions.headers.Authorization) {
|
||||
const creds = `${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password || '')}`;
|
||||
reqOptions.headers.Authorization = `Basic ${Buffer.from(creds).toString('base64')}`;
|
||||
}
|
||||
|
||||
// Apply httpsAgent options (e.g. rejectUnauthorized)
|
||||
if (isHttps && httpsAgent) {
|
||||
reqOptions.agent = new https.Agent(httpsAgent);
|
||||
}
|
||||
|
||||
// Prepare body
|
||||
let body = null;
|
||||
const payload = data || json;
|
||||
if (payload != null && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||
body = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
if (!reqOptions.headers['Content-Type'] && !reqOptions.headers['content-type']) {
|
||||
reqOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
reqOptions.headers['Content-Length'] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
// Handle redirects
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
// Drain the response body to free the socket
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new RequestError('Max redirects exceeded'));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(res.headers.location, targetUrl).href;
|
||||
makeRequest(redirectUrl, redirectsLeft - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decompress response based on Content-Encoding (matching axios behavior)
|
||||
let stream = res;
|
||||
const encoding = (res.headers['content-encoding'] || '').toLowerCase();
|
||||
if (encoding === 'gzip' || encoding === 'x-gzip') {
|
||||
stream = res.pipe(zlib.createGunzip());
|
||||
} else if (encoding === 'deflate') {
|
||||
stream = res.pipe(zlib.createInflate());
|
||||
} else if (encoding === 'br') {
|
||||
stream = res.pipe(zlib.createBrotliDecompress());
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let totalSize = 0;
|
||||
let aborted = false;
|
||||
stream.on('data', (chunk) => {
|
||||
if (aborted) return;
|
||||
totalSize += chunk.length;
|
||||
if (maxResponseSize > 0 && totalSize > maxResponseSize) {
|
||||
aborted = true;
|
||||
req.destroy();
|
||||
reject(new RequestError(
|
||||
`Response exceeds maximum size of ${maxResponseSize} bytes`,
|
||||
{ code: 'E_RESPONSE_TOO_LARGE' },
|
||||
));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
if (aborted) return;
|
||||
reject(new RequestError(`Decompression failed: ${err.message}`, { code: err.code }));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (aborted) return;
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
let responseData;
|
||||
try { responseData = JSON.parse(raw); } catch (_) { responseData = raw; }
|
||||
|
||||
const response = {
|
||||
data: responseData,
|
||||
status: res.statusCode,
|
||||
statusText: res.statusMessage,
|
||||
headers: res.headers,
|
||||
};
|
||||
// Expose the raw request object for socket access (status-check.js
|
||||
// needs this). Defined as non-enumerable so JSON.stringify() skips
|
||||
// it — the http.ClientRequest has circular socket references that
|
||||
// would otherwise crash any endpoint forwarding the response.
|
||||
Object.defineProperty(response, 'request', {
|
||||
value: req,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new RequestError(
|
||||
`Request failed with status ${res.statusCode}`,
|
||||
{ response, code: res.statusCode },
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Expose socket events for servername access
|
||||
req.on('socket', (socket) => {
|
||||
req.socket = socket;
|
||||
socket.on('secureConnect', () => {
|
||||
req.socket = socket;
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(new RequestError(
|
||||
err.message,
|
||||
{ code: err.code, errno: err.errno },
|
||||
));
|
||||
});
|
||||
|
||||
// Optional request timeout
|
||||
if (timeout > 0) {
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new RequestError(
|
||||
`timeout of ${timeout}ms exceeded`,
|
||||
{ code: 'ECONNABORTED' },
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
};
|
||||
|
||||
makeRequest(url, maxRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET shorthand */
|
||||
request.get = (url, config = {}) => request({ ...config, method: 'GET', url });
|
||||
|
||||
/** POST shorthand */
|
||||
request.post = (url, data, config = {}) => request({ ...config, method: 'POST', url, data });
|
||||
|
||||
/** PUT shorthand */
|
||||
request.put = (url, data, config = {}) => request({ ...config, method: 'PUT', url, data });
|
||||
|
||||
module.exports = request;
|
||||
module.exports.RequestError = RequestError;
|
||||
|
|
@ -5,54 +5,69 @@
|
|||
* Finally, it will call a function with the status message
|
||||
*/
|
||||
const fsPromises = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
|
||||
// Disallow paths having path separators, control chars (NUL/CR/LF), or ..
|
||||
const SAFE_FILENAME = /^(?!\.+$)[^\\/\0\r\n]+\.ya?ml$/i;
|
||||
|
||||
module.exports = async (newConfig, render) => {
|
||||
/* Either returns nothing (if using default path), or strips navigational characters from path */
|
||||
const makeSafeFileName = (configObj) => {
|
||||
if (!configObj || !configObj.filename) return undefined;
|
||||
return configObj.filename.replaceAll('/', '').replaceAll('..', '');
|
||||
};
|
||||
const respond = (success, message) => render(JSON.stringify({ success, message }));
|
||||
|
||||
const usersFileName = makeSafeFileName(newConfig);
|
||||
// Validate request body
|
||||
if (!newConfig || typeof newConfig.config !== 'string' || newConfig.config.length === 0) {
|
||||
respond(false, "Request body is missing or has an invalid 'config' field");
|
||||
return;
|
||||
}
|
||||
if (newConfig.config.length > MAX_CONFIG_BYTES) {
|
||||
respond(false, `Config exceeds maximum size of ${MAX_CONFIG_BYTES / 1024} KB`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Define constants for the config file
|
||||
const settings = {
|
||||
defaultLocation: './public/',
|
||||
defaultFile: 'conf.yml',
|
||||
filename: 'conf',
|
||||
backupDenominator: '.backup.yml',
|
||||
};
|
||||
// If `filename` (for sub-pages) is specified validate and set it
|
||||
let usersFileName;
|
||||
if (typeof newConfig.filename === 'string' && newConfig.filename) {
|
||||
const base = path.basename(newConfig.filename);
|
||||
if (!SAFE_FILENAME.test(base)) {
|
||||
respond(false, 'Invalid filename: must be a basename ending in .yml or .yaml');
|
||||
return;
|
||||
}
|
||||
usersFileName = base;
|
||||
}
|
||||
|
||||
// Make the full file name and path to save the backup config file
|
||||
const backupFilePath = `${settings.defaultLocation}${usersFileName || settings.filename}-`
|
||||
+ `${Math.round(new Date() / 1000)}${settings.backupDenominator}`;
|
||||
// Resolve paths
|
||||
const userDataDirectory = process.env.USER_DATA_DIR || './user-data/';
|
||||
const backupLocation = process.env.BACKUP_DIR || path.join(userDataDirectory, 'config-backups');
|
||||
const targetFile = usersFileName || 'conf.yml';
|
||||
const targetFilePath = path.join(userDataDirectory, targetFile);
|
||||
|
||||
// The path where the main conf.yml should be read and saved to
|
||||
const defaultFilePath = settings.defaultLocation + (usersFileName || settings.defaultFile);
|
||||
const backupBase = targetFile.replace(/\.ya?ml$/i, '');
|
||||
const backupFilePath = path.join(backupLocation, `${backupBase}-${Date.now()}.backup.yml`);
|
||||
|
||||
// Returns a string confirming successful job
|
||||
const getSuccessMessage = () => `Successfully backed up ${settings.defaultFile} to`
|
||||
+ ` ${backupFilePath}, and updated the contents of ${defaultFilePath}`;
|
||||
// Backup current config before proceeding
|
||||
try {
|
||||
await fsPromises.mkdir(backupLocation, { recursive: true });
|
||||
await fsPromises.copyFile(targetFilePath, backupFilePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
respond(false, `Unable to backup ${targetFile}: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Encoding options for writing to conf file
|
||||
const writeFileOptions = { encoding: 'utf8' };
|
||||
|
||||
// Prepare the response returned by the API
|
||||
const getRenderMessage = (success, errorMsg) => JSON.stringify({
|
||||
success,
|
||||
message: !success ? errorMsg : getSuccessMessage(),
|
||||
});
|
||||
|
||||
// Makes a backup of the existing config file
|
||||
await fsPromises
|
||||
.copyFile(defaultFilePath, backupFilePath)
|
||||
.catch((error) => render(getRenderMessage(false, `Unable to backup conf.yml: ${error}`)));
|
||||
|
||||
// Writes the new content to the conf.yml file
|
||||
await fsPromises
|
||||
.writeFile(defaultFilePath, newConfig.config.toString(), writeFileOptions)
|
||||
.catch((error) => render(getRenderMessage(false, `Unable to write to conf.yml: ${error}`)));
|
||||
// Write the new config
|
||||
try {
|
||||
await fsPromises.writeFile(targetFilePath, newConfig.config, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
respond(false, `Unable to write to ${targetFile}: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If successful, then render hasn't yet been called- call it
|
||||
await render(getRenderMessage(true));
|
||||
respond(
|
||||
true,
|
||||
`Successfully backed up ${targetFile} to ${backupFilePath}, `
|
||||
+ `and updated the contents of ${targetFilePath}`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* A Netlify cloud function to handle requests to CORS-disabled services */
|
||||
const axios = require('axios');
|
||||
const request = require('../request');
|
||||
|
||||
exports.handler = (event, context, callback) => {
|
||||
// Get input data
|
||||
|
|
@ -34,12 +34,12 @@ exports.handler = (event, context, callback) => {
|
|||
const requestConfig = {
|
||||
method: 'GET',
|
||||
url: requestUrl,
|
||||
json: body,
|
||||
data: body,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
// Make request
|
||||
axios.request(requestConfig)
|
||||
request(requestConfig)
|
||||
.then((response) => {
|
||||
callback(null, { statusCode: 200, body: JSON.stringify(response.data) });
|
||||
}).catch((error) => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
* It accepts a single url parameter, and will make an empty GET request to that
|
||||
* endpoint, and then resolve the response status code, time taken, and short message
|
||||
*/
|
||||
const axios = require('axios').default;
|
||||
const https = require('https');
|
||||
const request = require('./request');
|
||||
|
||||
/* Determines if successful from the HTTP response code */
|
||||
const getResponseType = (code, validCodes) => {
|
||||
|
|
@ -34,21 +33,19 @@ const makeRequest = (url, options, render) => {
|
|||
} = options;
|
||||
const validCodes = acceptCodes && acceptCodes !== 'null' ? acceptCodes : null;
|
||||
const startTime = new Date();
|
||||
const requestMaker = axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: !enableInsecure,
|
||||
}),
|
||||
});
|
||||
requestMaker.request({
|
||||
request({
|
||||
url,
|
||||
headers,
|
||||
maxRedirects,
|
||||
timeout: 10000,
|
||||
httpsAgent: { rejectUnauthorized: !enableInsecure },
|
||||
})
|
||||
.then((response) => {
|
||||
const statusCode = response.status;
|
||||
const { statusText } = response;
|
||||
const successStatus = getResponseType(statusCode, validCodes);
|
||||
const serverName = response.request.socket.servername;
|
||||
const serverName = response.request && response.request.socket
|
||||
? response.request.socket.servername : undefined;
|
||||
const timeTaken = (new Date() - startTime);
|
||||
const results = {
|
||||
statusCode, statusText, serverName, successStatus, timeTaken,
|
||||
|
|
@ -110,7 +107,10 @@ module.exports = (paramStr, render) => {
|
|||
const maxRedirects = decodeURIComponent(params.get('maxRedirects')) || 0;
|
||||
const headers = decodeHeaders(params.get('headers'));
|
||||
const enableInsecure = !!params.get('enableInsecure');
|
||||
if (!url || url === 'undefined') immediateError(render);
|
||||
if (!url || url === 'undefined') {
|
||||
immediateError(render);
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
headers, enableInsecure, acceptCodes, maxRedirects,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const axios = require('axios').default;
|
||||
const request = require('./request');
|
||||
|
||||
const currentVersion = require('../package.json').version;
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ const makeMsg = (latestVersion) => {
|
|||
return msg;
|
||||
};
|
||||
|
||||
axios.get(packageUrl).then((response) => {
|
||||
request.get(packageUrl).then((response) => {
|
||||
if (response && response.data && response.data.version) {
|
||||
logToConsole(`\nUsing Dashy V-${currentVersion}. Update Check Complete`);
|
||||
logToConsole(makeMsg(response.data.version));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue