WIP: Rewrite Start

This commit is contained in:
Holly Stubbs 2025-01-01 02:18:50 +00:00
parent c89d3db1ed
commit 17b48c92a4
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
18 changed files with 2407 additions and 683 deletions

2
.gitignore vendored
View file

@ -1 +1 @@
testing/ node_modules/

View file

@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at eusprojects@mail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

550
EUS.js
View file

@ -1,550 +0,0 @@
const config = require("../config/config.json"), crypto = require("crypto"), emoji = require("../misc/emoji_list.json"), fs = require("fs");
// Defines the function of this module
const MODULE_FUNCTION = "handle_requests",
// Base path for module folder creation and navigation
BASE_PATH = "/EUS",
API_CACHE_LIFESPAN = 3600000;
let node_modules = {};
let eusConfig = {},
useUploadKey = true,
cacheJSON = "",
timeSinceLastCache = Date.now();
class Database {
constructor(databaseAddress, databasePort = 3306, databaseUsername, databasePassword, databaseName) {
this.connectionPool = node_modules["mysql2"].createPool({
connectionLimit: 128,
host: databaseAddress,
port: databasePort,
user: databaseUsername,
password: databasePassword,
database: databaseName
});
}
dataReceived(resolveCallback, data, limited = false) {
if (limited) resolveCallback(data[0]);
else resolveCallback(data);
}
query(query = "", data) {
const limited = query.includes("LIMIT 1");
return new Promise((resolve, reject) => {
this.connectionPool.getConnection((err, connection) => {
if (err) {
reject(err);
try { connection.release();}
catch (e) {
console.error("Failed to release mysql connection", err);
}
} else {
// Use old query
if (data == null) {
connection.query(query, (err, data) => {
if (err) {
reject(err);
connection.release();
} else {
this.dataReceived(resolve, data, limited);
connection.release();
}
});
}
// Use new prepared statements w/ placeholders
else {
connection.execute(query, data, (err, data) => {
if (err) {
reject(err);
connection.release();
} else {
this.dataReceived(resolve, data, limited);
connection.release();
}
});
}
}
});
});
}
}
let dbConnection;
function init() {
// Require node modules
node_modules["dyetty"] = require("dyetty");
node_modules["busboy"] = require("connect-busboy");
node_modules["randomstring"] = require("randomstring");
node_modules["streamMeter"] = require("stream-meter");
node_modules["mysql2"] = require("mysql2");
// Only ran on startup so using sync functions is fine
// Makes the folder for files of the module
if (!fs.existsSync(__dirname + BASE_PATH)) {
fs.mkdirSync(__dirname + BASE_PATH);
console.log(`[EUS] Made EUS module folder`);
}
// Makes the folder for frontend files
if (!fs.existsSync(__dirname + BASE_PATH + "/files")) {
fs.mkdirSync(__dirname + BASE_PATH + "/files");
console.log(`[EUS] Made EUS web files folder`);
}
// Makes the folder for images
if (!fs.existsSync(__dirname + BASE_PATH + "/i")) {
fs.mkdirSync(__dirname + BASE_PATH + "/i");
console.log(`[EUS] Made EUS images folder`);
}
if (!fs.existsSync("/tmp/EUS_UPLOADS")) {
fs.mkdirSync("/tmp/EUS_UPLOADS");
console.log("[EUS] Made EUS temp upload folder");
}
// Makes the config file
if (!fs.existsSync(__dirname + BASE_PATH + "/config.json")) {
// Config doesn't exist, make it.
fs.writeFileSync(`${__dirname}${BASE_PATH}/config.json`, '{\n\t"baseURL":"http://example.com/",\n\t"acceptedTypes": [\n\t\t".png",\n\t\t".jpg",\n\t\t".jpeg",\n\t\t".gif"\n\t],\n\t"uploadKey": "",\n\t"database": {\n\t\t"databaseAddress": "127.0.0.1",\n\t\t"databasePort": 3306,\n\t\t"databaseUsername": "root",\n\t\t"databasePassword": "password",\n\t\t"databaseName": "EUS"\n\t}\n}');
console.log("[EUS] Made EUS config File!");
console.log("[EUS] Please edit the EUS Config file before restarting.");
// Config has been made, close framework.
process.exit(0);
} else {
eusConfig = require(`${__dirname}${BASE_PATH}/config.json`);
if (validateConfig(eusConfig)) console.log("[EUS] EUS config passed all checks");
}
if (!eusConfig["noUpload"]) {
dbConnection = new Database(eusConfig["database"]["databaseAddress"], eusConfig["database"]["databasePort"], eusConfig["database"]["databaseUsername"], eusConfig["database"]["databasePassword"], eusConfig["database"]["databaseName"]);
}
console.log("[EUS] Finished loading.");
}
// Cache for the file count and space usage, this takes a while to do so it's best to cache the result
let cacheIsReady = false;
function cacheFilesAndSpace() {
timeSinceLastCache = Date.now();
return new Promise(async (resolve, reject) => {
const startCacheTime = Date.now();
cacheIsReady = false;
let cachedFilesAndSpace = {
fileCounts: {},
};
const dbData = await dbConnection.query(`SELECT imageType, COUNT(imageType) AS "count" FROM images GROUP BY imageType`);
let totalFiles = 0;
dbData.forEach(fileType => {
cachedFilesAndSpace["fileCounts"][fileType.imageType] = fileType.count;
totalFiles += fileType.count;
});
cachedFilesAndSpace["filesTotal"] = totalFiles;
cachedFilesAndSpace["size"] = {};
const dbSize = (await dbConnection.query(`SELECT SUM(fileSize) FROM images LIMIT 1`))["SUM(fileSize)"];
const totalSizeBytes = dbSize == null ? 0 : dbSize;
const mbSize = totalSizeBytes / 1024 / 1024;
cachedFilesAndSpace["size"]["mb"] = parseFloat(mbSize.toFixed(4));
cachedFilesAndSpace["size"]["gb"] = parseFloat((mbSize / 1024).toFixed(4));
cachedFilesAndSpace["size"]["string"] = await spaceToLowest(totalSizeBytes, true);
resolve(cachedFilesAndSpace);
global.modules.consoleHelper.printInfo(emoji.folder, `Stats api cache took ${Date.now() - startCacheTime}ms`);
});
}
function validateConfig(json) {
let performShutdownAfterValidation = false;
// URL Tests
if (!json["noUpload"]) {
if (json["baseURL"] == null) {
console.error("EUS baseURL property does not exist!");
performShutdownAfterValidation = true;
} else {
if (json["baseURL"] == "") console.warn("EUS baseURL property is blank");
const bURL = `${json["baseURL"]}`.split("");
if (bURL.length > 1) {
if (bURL[bURL.length-1] != "/") console.warn("EUS baseURL property doesn't have a / at the end, this can lead to unpredictable results!");
}
else {
if (json["baseURL"] != "http://" || json["baseURL"] != "https://") console.warn("EUS baseURL property is possibly invalid!");
}
}
}
// acceptedTypes checks
if (!json["noUpload"]) {
if (json["acceptedTypes"] == null) {
console.error("EUS acceptedTypes list does not exist!");
performShutdownAfterValidation = true;
} else {
if (json["acceptedTypes"].length < 1) console.warn("EUS acceptedTypes array has no extentions in it, users will not be able to upload images!");
}
}
// uploadKey checks
if (!json["noUpload"]) {
if (json["uploadKey"] == null) {
console.error("EUS uploadKey property does not exist!");
performShutdownAfterValidation = true;
} else {
if (json["uploadKey"] == "") useUploadKey = false;
}
}
// database checks
if (!json["noUpload"]) {
if (json["database"] == null) {
console.error("EUS database properties do not exist!");
performShutdownAfterValidation = true;
} else {
// databaseAddress
if (json["database"]["databaseAddress"] == null) {
console.error("EUS database.databaseAddress property does not exist!");
performShutdownAfterValidation = true;
}
// databasePort
if (json["database"]["databasePort"] == null) {
console.error("EUS database.databasePort property does not exist!");
performShutdownAfterValidation = true;
}
// databaseUsername
if (json["database"]["databaseUsername"] == null) {
console.error("EUS database.databaseUsername property does not exist!");
performShutdownAfterValidation = true;
}
// databasePassword
if (json["database"]["databasePassword"] == null) {
console.error("EUS database.databasePassword property does not exist!");
performShutdownAfterValidation = true;
}
// databaseName
if (json["database"]["databaseName"] == null) {
console.error("EUS database.databaseName property does not exist!");
performShutdownAfterValidation = true;
}
}
}
// Check if server needs to be shutdown
if (performShutdownAfterValidation) {
console.error("EUS config properties are missing, refer to example config on GitHub (https://github.com/tgpholly/EUS)");
process.exit(1);
}
else return true;
}
let existanceCache = {};
function fileExists(file) {
return new Promise((resolve) => {
fs.access(file, (error) => {
resolve(!error);
});
});
}
function sendFile(req, res, path, startTime) {
// File does exist, send it back to the client.
res.sendFile(path);
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} ${req.url} ${Date.now() - startTime}ms`);
}
async function regularFile(req, res, urs = "", startTime) {
if (req.url === "/") { urs = "/index.html" } else if (!req.url.includes(".") && !req.url.endsWith("/")) { urs = `${req.url}/index.html` } else { urs = req.url }
if (await fileExists(`${__dirname}${BASE_PATH}/files${urs}`)) {
sendFile(req, res, `${__dirname}${BASE_PATH}/files${decodeURIComponent(urs)}`, startTime);
} else {
let isSuccess = false;
if (!urs.endsWith(".html")) {
if (await fileExists(`${__dirname}${BASE_PATH}/files${decodeURIComponent(urs)}.html`)) {
isSuccess = true;
sendFile(req, res, `${__dirname}${BASE_PATH}/files${decodeURIComponent(urs)}.html`, startTime);
}
} else if (urs.endsWith("/index.html")) {
const path = `${__dirname}${BASE_PATH}/files${decodeURIComponent(req.url)}.html`;
if (await fileExists(path)) {
isSuccess = true;
sendFile(req, res, path, startTime);
}
}
if (!isSuccess) {
// Doesn't exist, send a 404 to the client.
await error404Page(res);
global.modules.consoleHelper.printInfo(emoji.cross, `${req.method}: ${node_modules.dyetty.red("[404]")} ${req.url} ${Date.now() - startTime}ms`);
}
}
}
function imageFile(req, res, file, startTime = 0) {
res.sendFile(`${__dirname}${BASE_PATH}/i/${file}`);
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (ImageReq) ${req.url} ${Date.now() - startTime}ms`);
}
async function doImageLookup(req, res, urs, startTime) {
// Check if we even need to query the DB
const dbAble = (!/[^0-9A-Za-z]/.test(urs)) && urs != "" && urs != "index" && (req.url.split("/").length == 2);
if (dbAble) {
if (urs in existanceCache) {
const cachedFile = existanceCache[urs];
imageFile(req, res, `${cachedFile.fileHash}.${cachedFile.fileType}`, startTime);
} else {
// Try to get what we think is an image's details from the DB
const dbEntry = await dbConnection.query(`SELECT hash, imageType FROM images WHERE imageId = ? LIMIT 1`, [urs]);
// There's an entry in the DB for this, send the file back.
if (dbEntry != null) {
existanceCache[urs] = {
fileHash: dbEntry.hash,
fileType: dbEntry.imageType
};
imageFile(req, res, `${dbEntry.hash}.${dbEntry.imageType}`, startTime);
}
// There's no entry, so treat this as a regular file.
else regularFile(req, res, urs, startTime);
}
}
// We can still serve files if they are not dbable
// since we don't need to check the db
else regularFile(req, res, urs, startTime);
}
const PATH_404 = `${__dirname}${BASE_PATH}/files/404.html`;
async function error404Page(res) {
if (await fileExists(PATH_404)) {
res.status(404).sendFile(PATH_404);
} else {
res.status(404).send("404!<hr>EUS");
}
}
let getHandler = (req, res, urs, startTime) => res.send("EUS is starting up, please try again in a few seconds.");
module.exports = {
init: init,
extras:async function() {
if (!eusConfig["noUpload"]) {
// Setup express to use busboy
global.app.use(node_modules.busboy());
getHandler = doImageLookup;
cacheJSON = JSON.stringify(await cacheFilesAndSpace());
cacheIsReady = true;
} else {
getHandler = regularFile;
}
},
get:async function(req, res) {
/*
req - Request from client
res - Response from server
*/
// Set some headers
res.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
res.set("X-XSS-Protection", "1; mode=block");
res.set("Permissions-Policy", "microphone=(), geolocation=(), magnetometer=(), camera=(), payment=(), usb=(), accelerometer=(), gyroscope=()");
res.set("Referrer-Policy", "strict-origin-when-cross-origin");
res.set("Content-Security-Policy", "block-all-mixed-content;frame-ancestors 'self'");
res.set("X-Frame-Options", "SAMEORIGIN");
res.set("X-Content-Type-Options", "nosniff");
req.url = decodeURIComponent(req.url.split("?")[0]);
// Auto 404 any attempted php / wp access and log it.
// TODO: IP ban based on mass access. Very clearly a scan.
if (req.url.includes(".php") || req.url.includes("/wp-")) {
global.modules.consoleHelper.printWarn(emoji.globe_europe, `${req.method}: SUSSY ${req.headers["cf-connecting-ip"]} ${req.url}`);
return await error404Page(res);
}
if (req.url.includes("/api/")) {
return handleAPI(req, res);
}
const startTime = Date.now();
const urs = req.url.split("/")[1];
getHandler(req, res, urs, startTime);
},
post:async function(req, res) {
/*
req - Request from client
res - Response from server
*/
if (eusConfig["noUpload"]) return res.end("");
if (req.url != "/upload") return res.end("");
res.header("Access-Control-Allow-Origin", "*");
if (useUploadKey && eusConfig["uploadKey"] != req.header("key")) return res.end("Incorrect key provided for upload");
const startTime = Date.now();
req.pipe(req.busboy);
req.busboy.on('file', function (fieldname, file, info) {
fileOutName = node_modules.randomstring.generate(14);
global.modules.consoleHelper.printInfo(emoji.fast_up, `${req.method}: Upload of ${fileOutName} started.`);
// Check the file is within the accepted file types
let fileType = info.filename.split(".").slice(-1);
if (info.filename === "blob") {
fileType = info.mimeType.split("/")[1];
}
var thefe = "";
if (eusConfig.acceptedTypes.includes(`.${fileType}`)) {
// File is accepted, set the extention of the file in thefe for later use.
thefe = fileType;
} else {
// File isn't accepted, send response back to client stating so.
res.status(403).end("This file type isn't accepted.");
return;
}
// Create a write stream for the file
let fstream = fs.createWriteStream("/tmp/EUS_UPLOADS/" + fileOutName);
file.pipe(fstream);
// Get all file data for the file MD5
let fileData = [];
file.on("data", (chunk) => {
fileData.push(chunk);
});
fstream.on('close', async () => {
let md5Buffer = Buffer.concat(fileData);
// bye
fileData = null;
// Create MD5 hash of file
const hash = crypto.createHash("md5");
hash.setEncoding("hex");
hash.write(md5Buffer);
hash.end();
const fileHash = hash.read();
const dataOnHash = await dbConnection.query("SELECT imageId FROM images WHERE hash = ? LIMIT 1", [fileHash]);
if (dataOnHash !== undefined)
{
fs.unlink(`/tmp/EUS_UPLOADS/${fileOutName}`, () => {});
res.end(`${eusConfig.baseURL}${dataOnHash.imageId}`);
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: Hash matched! Sending ${dataOnHash.imageId} instead. Took ${Date.now() - startTime}ms`);
return;
} else {
fs.rename(`/tmp/EUS_UPLOADS/${fileOutName}`, `${__dirname}${BASE_PATH}/i/${fileHash}.${thefe[0]}`, async () => {
// Add to the existance cache
existanceCache[fileOutName] = {
fileHash: fileHash,
fileType: thefe[0]
};
// Store image data in db
await dbConnection.query(`INSERT INTO images (id, imageId, imageType, hash, fileSize) VALUES (NULL, ?, ?, ?, ?)`, [fileOutName, thefe[0], fileHash, md5Buffer.length]);
// Send URL of the uploaded image to the client
res.end(eusConfig.baseURL + fileOutName);
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: Upload of ${fileOutName} finished. Took ${Date.now() - startTime}ms`);
// Update cached files & space
if ((Date.now() - timeSinceLastCache) >= API_CACHE_LIFESPAN) {
cacheJSON = JSON.stringify(await cacheFilesAndSpace());
} else {
const tempJson = JSON.parse(cacheJSON);
tempJson.fileCounts[thefe[0]]++;
cacheJSON = JSON.stringify(tempJson);
global.modules.consoleHelper.printInfo(emoji.folder, `Skiped api cache`);
}
cacheIsReady = true;
});
}
});
});
}
}
async function handleAPI(req, res) {
const startTime = Date.now();
let jsonaa = {}, filesaa = 0, spaceaa = 0;
switch (req.url.split("?")[0]) {
// Status check to see the online status of EUS
// Used by ESL to make sure EUS is online
case "/api/get-server-status":
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
return res.end('{"status":1,"version":"'+global.internals.version+'"}');
/* Stats api endpoint
Query inputs
f : Values [0,1]
s : Values [0,1]
*/
case "/api/get-stats":
filesaa = req.query["f"];
spaceaa = req.query["s"];
if (!cacheIsReady) return res.end("Cache is not ready");
jsonaa = JSON.parse(cacheJSON);
// If total files is asked for
if (filesaa == 1) {
// If getting the space used on the server isn't required send the json
if (spaceaa != 1) {
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
delete jsonaa["space"];
return res.end(JSON.stringify(jsonaa));
}
}
// Getting space is required
if (spaceaa == 1) {
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
if (filesaa != 1) delete jsonaa["files"];
return res.end(JSON.stringify(jsonaa));
}
if (filesaa != 1 && spaceaa != 1) {
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
return res.end("Please add f and or s to your queries to get the files and space");
}
break;
// Information API
case "/api/get-info":
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
return res.end(JSON.stringify({
version: global.internals.version,
instance: config["server"]["instance_type"]
}));
default:
global.modules.consoleHelper.printInfo(emoji.heavy_check, `${req.method}: ${node_modules.dyetty.green("[200]")} (APIReq) ${req.url} ${Date.now() - startTime}ms`);
return res.send(`
<h2>All currently avaliable api endpoints</h2>
<a href="/api/get-server-status">/api/get-server-status</a>
<a href="/api/get-stats">/api/get-stats</a>
<a href="/api/get-info">/api/get-info</a>
`);
}
}
const spaceValues = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; // Futureproofing™
async function spaceToLowest(spaceValue, includeStringValue) {
return new Promise((resolve, reject) => {
// Converts space values to lower values e.g MB, GB, TB etc depending on the size of the number
let i1 = 1;
// Loop through until value is at it's lowest
while (spaceValue >= 1024) {
spaceValue = spaceValue / 1024;
if (spaceValue >= 1024) i1++;
}
if (includeStringValue) resolve(`${spaceValue.toFixed(2)} ${spaceValues[i1]}`);
else resolve(spaceValue);
});
}
module.exports.MOD_FUNC = MODULE_FUNCTION;
module.exports.REQUIRED_NODE_MODULES = [
"dyetty", "connect-busboy", "randomstring",
"stream-meter", "mysql2"
];

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 Holly Stubbs (tgpholly) Copyright (c) 2024 Holly Stubbs (tgpholly)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -1,36 +0,0 @@
<p align="center">
<img width="150" height="150" src="https://eusv.net/images/EUSLossless.webp">
</p>
<p align="center">
EUS is my public screenshot server built using <a href="https://github.com/tgpholly/Revolution">Revolution</a><br>
</p>
<p align="center">
<a href="https://www.codefactor.io/repository/github/tgpethan/eus/overview/master"><img src="https://www.codefactor.io/repository/github/tgpholly/eus/badge/master" alt="CodeFactor" /></a>
<a src="https://discord.gg/BV8QGn6"><img src="https://img.shields.io/discord/477024246959308810?color=7289da&label=Discord&logo=discord&logoColor=ffffff"></a>
</p>
## Setup
EUS has extra dependencies other than those of [Revolution](https://github.com/tgpethan/Revolution), the server EUS is made on, of which include:
- [connect-busboy](https://www.npmjs.com/package/connect-busboy)
- [randomstring](https://www.npmjs.com/package/randomstring)
- [diskusage](https://www.npmjs.com/package/diskusage)
- [stream-meter](https://www.npmjs.com/package/stream-meter)
- [mysql](https://www.npmjs.com/package/mysql)
Simply drop the EUS.js into a Revolution instance's modules folder **(If you still have [example_request_handler.js](https://github.com/tgpethan/Revolution/blob/master/modules/example_request_handler.js) be sure to delete it!)** and the extra required modules should be automatically installed.
## Config
On first startup EUS will create a new config file in the **modules/EUS/** folder, some of these values may need to be changed depending on your use case.
The value of **baseURL** will need to be changed to what you access the server from, for example if the server's ip is 192.168.1.100 and you are not planning to use EUS at a url you would change the value to **http://192.168.1.100/**. **baseURL** is used to construct the response url for file uploads, for example the value of **baseURL** on my instance of EUS is **https://eusv.net/**.
If you want to expand the files that the server allows to be sent to it this can be done in the **allowedTypes** array. By default the array contains **png, jpg and gif**.
The value of **uploadKey** is used to restrict who can upload to your server, set this to something and the server will restrict who can upload depending on if they provided the key or not. If this field is left blank EUS will asume you don't want an upload key and uploads to it will be unrestricted
## API
EUS has 3 api endpoints, they are **[/api/get-stats](https://eusv.net/api/get-stats)**, **[/api/get-info](https://eusv.net/api/get-info)** and **[/api/get-server-status](https://eusv.net/api/get-server-status)**
## Websites that use EUS
[EUS](https://eusv.net)

View file

@ -1,19 +0,0 @@
{
"baseURL":"https://example.com/",
"acceptedTypes": [
".png",
".jpg",
".jpeg",
".gif",
".mp4"
],
"uploadKey":"",
"noUpload": false,
"database": {
"databaseAddress": "127.0.0.1",
"databasePort": 3306,
"databaseUsername": "root",
"databasePassword": "password",
"databaseName": "EUS"
}
}

141
controllers/Controller.ts Normal file
View file

@ -0,0 +1,141 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { Console } from "hsconsole";
import Session from "../objects/Session";
import SessionUser from "../objects/SessionUser";
import RequestCtx from "../objects/RequestCtx";
import UserType from "../enums/UserType";
// prepare for ts-ignore :3
// TODO: figure out some runtime field / type checking so
// can auto badRequest on missing stuff.
export default abstract class Controller {
public static FastifyInstance:FastifyInstance;
public constructor() {
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
const rawControllerParts = this.constructor.name.split("_");
const controllerName = rawControllerParts.splice(0, 1)[0].replace("Controller", "").toLowerCase();
const controllerAuthLevels: Array<UserType> = [];
const actionAuthLevels: { [ key : string ]: Array<UserType> } = {};
for (const prop of rawControllerParts) {
if (prop.startsWith("Auth")) {
const userLevel = prop.split("$")[1];
// @ts-ignore
controllerAuthLevels.push(UserLevel[userLevel]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name} to ${userLevel}`);
}
}
for (const method of methods) {
if (method === "constructor" || method[0] !== method[0].toUpperCase()) { // * Anything that starts with lowercase we'll consider "private"
continue;
}
const params = method.split("_");
const methodNameRaw = params.splice(0, 1)[0];
const methodName = methodNameRaw.toLowerCase();
const doAuth = !params.includes("AllowAnonymous");
// @ts-ignore
const controllerRequestHandler = this[method];
const requestHandler = (req:FastifyRequest, res:FastifyReply) => {
let session = Session.CheckValiditiy(req.cookies);
if (doAuth && session === undefined) {
return res.redirect(`/account/login?returnTo=${encodeURIComponent(req.url)}`);
}
const methodAuthCheck = actionAuthLevels[`${controllerName}_${methodName}_${req.method.toLowerCase()}`];
let wasMethodMatch = false;
if (methodAuthCheck && session !== undefined) {
for (const auth of methodAuthCheck) {
if (auth === session.userType) {
wasMethodMatch = true;
}
}
}
if (!wasMethodMatch && session !== undefined && controllerAuthLevels.length > 0) {
let hasLevelMatch = false;
for (const level of controllerAuthLevels) {
if (level === session.userType) {
hasLevelMatch = true;
}
}
if (!hasLevelMatch) {
return res.status(403).send("Forbidden");
}
}
res.header("X-Powered-By", "MultiProbe");
if (controllerName !== "api") {
res.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
res.header("X-XSS-Protection", "1; mode=block");
res.header("Permissions-Policy", "microphone=(), geolocation=(), magnetometer=(), camera=(), payment=(), usb=(), accelerometer=(), gyroscope=()");
res.header("Referrer-Policy", "strict-origin-when-cross-origin");
res.header("Content-Security-Policy", "block-all-mixed-content;frame-ancestors 'self'");
res.header("X-Frame-Options", "SAMEORIGIN");
res.header("X-Content-Type-Options", "nosniff");
}
const requestCtx = new RequestCtx(req, res, controllerName, methodName, session);
controllerRequestHandler.bind(requestCtx)(req.method === "GET" ? req.query : req.body);
}
let funcMethods:Array<string> = [];
let thisMethodHttpMethod = "";
for (const param of params) {
if (param === "Get" || param === "Post" || param === "Put") {
funcMethods.push(param);
thisMethodHttpMethod = param.toLowerCase();
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName === "index" ? "" : methodName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName === "index" ? "" : methodName}" as ${param}`);
if (methodName === "index") {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName}" as ${param}`);
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}" as ${param}`);
}
} else if (param.startsWith("Auth")) {
const nameWithMethod = `${controllerName}_${methodName}_${thisMethodHttpMethod}`;
const userLevel = param.split("$")[1];
if (!(nameWithMethod in actionAuthLevels)) {
actionAuthLevels[nameWithMethod] = [];
}
// @ts-ignore
actionAuthLevels[nameWithMethod].push(UserLevel[userLevel]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name}.${method} to ${userLevel}`);
}
}
if (controllerName === "home" && methodName === "index") {
for (const httpMethod of funcMethods) {
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/" as ${httpMethod}`);
// @ts-ignore
Controller.FastifyInstance[httpMethod.toLowerCase()](`/`, requestHandler);
}
}
}
}
// not real, these RequestCtx so they autocomplete :)
// yeah, i know. this is terrible.
// Fields
// @ts-ignore
public session:SessionUser;
// @ts-ignore
public req: FastifyRequest;
// @ts-ignore
public res: FastifyReply;
// Methods
view(view?:string | Object, model?: Object) {}
redirectToAction(action:string, controller?:string) {}
ok(message?:string) {}
badRequest(message?:string) {}
unauthorised(message?:string) {}
forbidden(message?:string) {}
}

View file

7
enums/UserType.ts Normal file
View file

@ -0,0 +1,7 @@
enum UserType {
Unknown = 0,
User = 10,
Admin = 999
}
export default UserType;

62
index.ts Normal file
View file

@ -0,0 +1,62 @@
import Fastify from "fastify";
import FastifyFormBody from "@fastify/formbody";
import FastifyMultipart from "@fastify/multipart";
import FastifyCookie from "@fastify/cookie";
import FastifyView from "@fastify/view";
import FastifyStatic from "@fastify/static";
import Config from "./objects/Config";
import EJS from "ejs";
import { Console } from "hsconsole";
import Controller from "./controllers/Controller";
import HomeController from "./controllers/HomeController";
import Database from "./objects/Database";
import { join } from "path";
Console.customHeader(`EUS Hosting Panel server started at ${new Date()}`);
const fastify = Fastify({
logger: false
});
fastify.register(FastifyView, {
engine: {
ejs: EJS
}
});
fastify.register(FastifyFormBody);
fastify.register(FastifyMultipart);
fastify.register(FastifyCookie, {
secret: Config.session.secret,
parseOptions: {
path: "/",
secure: true
}
});
fastify.register(FastifyStatic, {
root: join(__dirname, "wwwroot"),
prefix: `${Config.ports.webroot}/static/`
});
fastify.setNotFoundHandler(async (_req, res) => {
return res.status(404).view("views/404.ejs", { });
});
new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name);
Controller.FastifyInstance = fastify;
new HomeController();
new AccountController();
new FileController();
fastify.listen({ port: Config.ports.http, host: "127.0.0.1" }, (err, address) => {
if (err) {
Console.printError(`Error occured while spinning up fastify:\n${err}`);
process.exit(1);
}
Console.printInfo(`Fastify listening at ${address.replace("0.0.0.0", "localhost").replace("127.0.0.1", "localhost")}`);
});

3
objects/Config.ts Normal file
View file

@ -0,0 +1,3 @@
export default class Config {
}

102
objects/Database.ts Normal file
View file

@ -0,0 +1,102 @@
import { Console } from "hsconsole";
import { createPool, Pool, RowDataPacket } from "mysql2";
export type DBInDataType = string | number | null | undefined;
export default class Database {
private connectionPool:Pool;
private static readonly CONNECTION_LIMIT = 128;
public connected:boolean = false;
public static Instance:Database;
public constructor(databaseAddress:string, databasePort:number = 3306, databaseUsername:string, databasePassword:string, databaseName:string) {
this.connectionPool = createPool({
connectionLimit: Database.CONNECTION_LIMIT,
host: databaseAddress,
port: databasePort,
user: databaseUsername,
password: databasePassword,
database: databaseName
});
Console.printInfo(`DB connection pool created. MAX_CONNECTIONS = ${Database.CONNECTION_LIMIT}`);
Database.Instance = this;
}
public execute(query:string, data?:Array<DBInDataType>) {
return new Promise<boolean>((resolve, reject) => {
this.connectionPool.getConnection((err, connection) => {
if (err) {
return reject(err);
}
if (data == null) {
connection.execute(query, (err, result) => {
if (err) {
connection.release();
return reject(err);
}
resolve(result !== undefined);
});
} else {
connection.execute(query, data, (err, result) => {
if (err) {
connection.release();
return reject(err);
}
resolve(result !== undefined);
});
}
});
});
}
public query(query:string, data?:Array<DBInDataType>) {
return new Promise<RowDataPacket[]>((resolve, reject) => {
this.connectionPool.getConnection((err, connection) => {
if (err) {
return reject(err);
} else {
// Use old query
if (data == null) {
connection.query<RowDataPacket[]>(query, (err, rows) => {
connection.release();
if (err) {
return reject(err);
}
resolve(rows);
connection.release();
});
}
// Use new prepared statements w/ placeholders
else {
connection.execute<RowDataPacket[]>(query, data, (err, rows) => {
connection.release();
if (err) {
return reject(err);
}
resolve(rows);
connection.release();
});
}
}
});
});
}
public async querySingle(query:string, data?:Array<DBInDataType>) {
const dbData = await this.query(query, data);
if (dbData != null && dbData.length > 0) {
return dbData[0];
}
return null;
}
}

67
objects/RequestCtx.ts Normal file
View file

@ -0,0 +1,67 @@
import { FastifyReply, FastifyRequest } from "fastify";
import SessionUser from "./SessionUser";
import UserType from "../enums/UserType";
export default class RequestCtx {
public controllerName:string;
public actionName:string;
public session?:SessionUser;
public req: FastifyRequest;
public res: FastifyReply;
public constructor(req: FastifyRequest, res: FastifyReply, controllerName:string, actionName:string, sessionUser?:SessionUser) {
this.session = sessionUser;
this.req = req;
this.res = res;
this.controllerName = controllerName;
this.actionName = actionName;
}
view(view?:string | Object, model?: Object) {
let viewName: string = this.actionName;
let viewModel: Object = {};
if (typeof(view) === "string") {
viewName = view;
} else if (typeof(view) === "object") {
viewModel = view;
}
if (typeof(model) === "object") {
viewModel = model;
}
// @ts-ignore inject session
viewModel["session"] = this.session;
// @ts-ignore inject enums
viewModel["UserLevel"] = UserLevel;
return this.res.view(`views/${this.controllerName}/${viewName}.ejs`, viewModel);
}
// TODO: query params
redirectToAction(action:string, controller?:string) {
const controllerName = controller ?? this.controllerName;
if (action === "index") {
if (controllerName === "home") {
return this.res.redirect(`/`, 302);
} else {
return this.res.redirect(`/${controllerName}`, 302);
}
} else {
return this.res.redirect(`/${controllerName}/${action}`, 302);
}
}
ok(message?:string) {
return this.res.status(200).send(message ?? "");
}
badRequest(message?:string) {
return this.res.status(400).send(message ?? "");
}
unauthorised(message?:string) {
return this.res.status(401).send(message ?? "");
}
forbidden(message?:string) {
return this.res.status(403).send(message ?? "");
}
}

67
objects/Session.ts Normal file
View file

@ -0,0 +1,67 @@
import { FastifyReply, FastifyRequest } from "fastify";
import SessionUser from "./SessionUser";
import UserType from "../enums/UserType";
export default class RequestCtx {
public controllerName:string;
public actionName:string;
public session?:SessionUser;
public req: FastifyRequest;
public res: FastifyReply;
public constructor(req: FastifyRequest, res: FastifyReply, controllerName:string, actionName:string, sessionUser?:SessionUser) {
this.session = sessionUser;
this.req = req;
this.res = res;
this.controllerName = controllerName;
this.actionName = actionName;
}
view(view?:string | Object, model?: Object) {
let viewName: string = this.actionName;
let viewModel: Object = {};
if (typeof(view) === "string") {
viewName = view;
} else if (typeof(view) === "object") {
viewModel = view;
}
if (typeof(model) === "object") {
viewModel = model;
}
// @ts-ignore inject session
viewModel["session"] = this.session;
// @ts-ignore inject enums
viewModel["UserLevel"] = UserLevel;
return this.res.view(`views/${this.controllerName}/${viewName}.ejs`, viewModel);
}
// TODO: query params
redirectToAction(action:string, controller?:string) {
const controllerName = controller ?? this.controllerName;
if (action === "index") {
if (controllerName === "home") {
return this.res.redirect(`/`, 302);
} else {
return this.res.redirect(`/${controllerName}`, 302);
}
} else {
return this.res.redirect(`/${controllerName}/${action}`, 302);
}
}
ok(message?:string) {
return this.res.status(200).send(message ?? "");
}
badRequest(message?:string) {
return this.res.status(400).send(message ?? "");
}
unauthorised(message?:string) {
return this.res.status(401).send(message ?? "");
}
forbidden(message?:string) {
return this.res.status(403).send(message ?? "");
}
}

13
objects/SessionUser.ts Normal file
View file

@ -0,0 +1,13 @@
import UserType from "../enums/UserType";
export default class SessionUser {
public readonly userId:number;
public readonly userType:UserType;
public readonly validityPeriod:Date;
constructor(userId:number, userType: UserType, validityPeriod:Date) {
this.userId = userId;
this.userType = userType;
this.validityPeriod = validityPeriod;
}
}

1898
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "eus",
"description": "EUS is my public screenshot server",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "https://git.eusv.net/tgpholly/EUS"
},
"keywords": [],
"author": "tgpholly",
"license": "MIT",
"type": "commonjs",
"scripts": {},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^22.10.2",
"@vercel/ncc": "^0.38.3",
"check-outdated": "^2.12.0",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
"@fastify/formbody": "^8.0.1",
"@fastify/multipart": "^9.0.1",
"@fastify/static": "^8.0.3",
"@fastify/view": "^10.0.1",
"ejs": "^3.1.10",
"fastify": "^5.2.0",
"hsconsole": "^1.0.2"
}
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "ES2022",
"esModuleInterop": true,
"resolveJsonModule": true,
"rootDir": "./",
"outDir": "./build",
"strict": true
}
}