Compare commits

...

9 commits

Author SHA1 Message Date
a0fcc376b7
media requests work! 2025-01-26 04:23:14 +00:00
fd606996fd
send files properly 2025-01-25 12:21:55 +00:00
d612ea9668 WIP: Bun changes 2025-01-08 14:50:10 +00:00
301a3ac595
WIP: It's bun time 2025-01-08 08:19:11 +00:00
24247d938f
WIP: Fastify is drunk 2025-01-06 06:53:13 +00:00
2c5e40b36f
WIP: Accounts & Uploading 2025-01-05 14:22:18 +00:00
d343acc9e5
WIP: Request Logging 2025-01-03 03:11:00 +00:00
c4cd41c03c
WIP: Rewrite progress 2025-01-01 22:03:59 +00:00
17b48c92a4
WIP: Rewrite Start 2025-01-01 02:18:50 +00:00
46 changed files with 1775 additions and 683 deletions

6
.gitignore vendored
View file

@ -1 +1,5 @@
testing/
node_modules/
build/
logs/
images/
config.json

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
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
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)

BIN
bun.lockb Executable file

Binary file not shown.

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"
}
}

View file

@ -0,0 +1,76 @@
import type LoginViewModel from "../models/account/LoginViewModel";
import type RegisterViewModel from "../models/account/RegisterViewModel";
import Config from "../objects/Config";
import Session from "../objects/Session";
import UserService from "../services/UserService";
import Controller from "./Controller";
export default class AccountController extends Controller {
public async Login_Get_AllowAnonymous() {
return this.view();
}
public async Login_Post_AllowAnonymous(loginViewModel: LoginViewModel) {
if (typeof(loginViewModel.username) !== "string" || typeof(loginViewModel.password) !== "string") {
return this.badRequest();
}
const user = await UserService.AuthenticateUser(loginViewModel.username, loginViewModel.password);
if (!user) {
loginViewModel.password = "";
loginViewModel.message = "Username or Password is incorrect";
return this.view(loginViewModel);
}
Session.AssignUserSession(this.res, user);
return this.redirectToAction("index", "home");
}
public async Register_Get_AllowAnonymous() {
return this.view();
}
public async Register_Post_AllowAnonymous(registerViewModel: RegisterViewModel) {
if (typeof(registerViewModel.username) !== "string" || typeof(registerViewModel.password) !== "string" || typeof(registerViewModel.registerKey) !== "string" || typeof(registerViewModel.password2) !== "string" || typeof(registerViewModel.email) !== "string") {
return this.badRequest();
}
if (registerViewModel.registerKey !== Config.accounts.signup.key) {
registerViewModel.password = "";
registerViewModel.password2 = "";
registerViewModel.message = "Incorrect Registration Key.";
return this.view(registerViewModel);
}
const username = registerViewModel.username.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (!await UserService.CreateUser(1, username, registerViewModel.email.trim(), registerViewModel.password)) {
registerViewModel.password = "";
registerViewModel.password2 = "";
registerViewModel.message = "Sorry! That username is already taken.";
return this.view(registerViewModel);
}
const user = await UserService.GetUserByUsername(username);
if (!user) {
registerViewModel.password = "";
registerViewModel.password2 = "";
registerViewModel.message = "Failed to create your account, please try again later.";
return this.view(registerViewModel);
}
Session.AssignUserSession(this.res, user);
return this.redirectToAction("index", "home");
}
public async Logout_Get_AllowAnonymous() {
Session.Clear(this.req.cookies, this.res);
return this.redirectToAction("index", "home");
}
}

View file

@ -0,0 +1,5 @@
import Controller from "./Controller";
export default class ApiController extends Controller {
}

150
controllers/Controller.ts Normal file
View file

@ -0,0 +1,150 @@
import { cyan } from "dyetty";
import { Console } from "hsconsole";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import RequestCtx from "../objects/RequestCtx";
import Session from "../objects/Session";
import SessionUser from "../objects/SessionUser";
import UserType from "../enums/UserType";
import HeaderUtility from "../utilities/HeaderUtility";
// 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 static RegisteredPaths:Array<string> = [];
private logInfo(logText: string) {
Console.printInfo(`[ ${cyan("CONTROLLER")} ] ${logText}`);
}
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 userType = prop.split("$")[1];
// @ts-ignore
controllerAuthLevels.push(UserType[userType]);
this.logInfo(`Set Auth level requirement for ${this.constructor.name} to ${userType}`);
}
}
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");
}
}
if (controllerName !== "api") {
HeaderUtility.AddBakedHeaders(res);
}
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);
this.logInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName === "index" ? "" : methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}/${methodName === "index" ? "" : methodName}`);
if (methodName === "index") {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName}`, requestHandler);
this.logInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}/${methodName}`);
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}`, requestHandler);
this.logInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}`);
} else if (controllerName === "home") {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${methodName}`, requestHandler);
this.logInfo(`Registered ${this.constructor.name}.${method} to "/${methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${methodName}`);
}
} else if (param.startsWith("Auth")) {
const nameWithMethod = `${controllerName}_${methodName}_${thisMethodHttpMethod}`;
const userType = param.split("$")[1];
if (!(nameWithMethod in actionAuthLevels)) {
actionAuthLevels[nameWithMethod] = [];
}
// @ts-ignore
actionAuthLevels[nameWithMethod].push(UserType[userType]);
this.logInfo(`Set Auth level requirement for ${this.constructor.name}.${method} to ${userType}`);
}
}
if (controllerName === "home" && methodName === "index") {
for (const httpMethod of funcMethods) {
// @ts-ignore
Controller.FastifyInstance[httpMethod.toLowerCase()](`/`, requestHandler);
this.logInfo(`Registered ${this.constructor.name}.${method} to "/" as ${httpMethod}`);
Controller.RegisteredPaths.push(`/`);
}
}
}
}
// not real, these should mirror 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) { view; model; }
redirectToAction(action:string, controller?:string) { action; controller; }
ok(message?:string) { message }
badRequest(message?:string) { message }
unauthorised(message?:string) { message }
forbidden(message?:string) { message }
}

View file

@ -0,0 +1,52 @@
import Controller from "./Controller";
import type DashboardViewModel from "../models/home/DashboardViewModel";
import UserService from "../services/UserService";
export default class HomeController extends Controller {
public async Index_Get_AllowAnonymous() {
if (this.session) {
const dashboardViewModel: DashboardViewModel = {
recentUploads: await UserService.GetRecentUploads(this.session.userId)
}
return this.view("dashboard",dashboardViewModel);
}
return this.view();
}
public async Upload_Post_AllowAnonymous() {
const data = await this.req.file();
if (data && data.type === "file") {
let uploadKey: string = "";
let host: string = "";
//console.log(this.req.headers);
if ("upload-key" in this.req.headers) {
// @ts-ignore
uploadKey = this.req.headers["upload-key"];
} else {
return this.unauthorised("Upload key invalid or missing.");
}
if ("host" in this.req.headers) {
// @ts-ignore
host = this.req.headers["host"];
} else {
return this.badRequest("Host header missing?!");
}
const user = await UserService.GetByUploadKey(uploadKey);
if (!user) {
return this.unauthorised("Upload key invalid or missing.");
}
const fileUrl = await UserService.UploadMedia(user.Id, host, data);
if (!fileUrl) {
return this.badRequest("This domain is not registered to your EUS account.");
}
return this.ok(fileUrl);
}
return this.badRequest();
}
}

14
entities/Domain.ts Normal file
View file

@ -0,0 +1,14 @@
export default class Domain {
public Id: number = Number.MIN_VALUE;
public UserId: number = Number.MIN_VALUE;
public HasHttps: boolean = false;
public Domain: string = "";
public Active: boolean = false;
public CreatedByUserId = Number.MIN_VALUE;
public CreatedDatetime = new Date(0);
public LastModifiedByUserId?: number;
public LastModifiedDatetime?: Date;
public DeletedByUserId?: number;
public DeletedDatetime?: Date;
public IsDeleted: boolean = false;
}

17
entities/Media.ts Normal file
View file

@ -0,0 +1,17 @@
export default class Media {
public Id: number = Number.MIN_VALUE;
public UserId: number = Number.MIN_VALUE;
public DomainId: number = Number.MIN_VALUE;
public FileName: string = "";
public MediaTag: string = "";
public MediaType: string = "";
public Hash: string = "";
public FileSize: number = Number.MIN_VALUE;
public CreatedByUserId: number = Number.MIN_VALUE;
public CreatedDatetime: Date = new Date();
public LastModifiedByUserId?: number;
public LastModifiedDatetime?: Date;
public DeletedByUserId?: number;
public DeletedDatetime?: Date;
public IsDeleted: boolean = false;
}

20
entities/User.ts Normal file
View file

@ -0,0 +1,20 @@
import UserType from "../enums/UserType";
export default class User {
public Id: number = Number.MIN_VALUE;
public UserType: UserType = UserType.Unknown;
public Username: string = "";
public EmailAddress: string = "";
public PasswordHash: string = "";
public PasswordSalt: string = "";
public ApiKey: string = "";
public UploadKey: string = "";
public Verified: boolean = false;
public CreatedByUserId: number = Number.MIN_VALUE;
public CreatedDatetime: Date = new Date();
public LastModifiedByUserId?: number;
public LastModifiedDatetime?: Date;
public DeletedByUserId?: number;
public DeletedDatetime?: Date;
public IsDeleted: boolean = false;
}

7
enums/UserType.ts Normal file
View file

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

140
index.ts Normal file
View file

@ -0,0 +1,140 @@
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";
import AccountController from "./controllers/AccountController";
import { magenta, blue, cyan, green, red } from "dyetty";
import ConsoleUtility from "./utilities/ConsoleUtility";
import HashFS from "./objects/HashFS";
import FunkyArray from "funky-array";
import MediaService from "./services/MediaService";
import Media from "./entities/Media";
import HeaderUtility from "./utilities/HeaderUtility";
import { createReadStream } from "fs";
Console.customHeader(`EUS 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"),
preCompressed: true,
decorateReply: false,
redirect: false
});
const hashLookupCache = new FunkyArray<string, Media>();
fastify.addHook("preHandler", (req, res, done) => {
(async () => {
// @ts-ignore
req.startTime = Date.now();
// * Take usual controller path if this path is registered.
if (Controller.RegisteredPaths.includes(req.url)) {
// @ts-ignore
req.logType = cyan("CONTROLLER");
HeaderUtility.AddBakedHeaders(res);
return done();
} else {
const urlParts = req.url.split("/");
if (urlParts.length === 2 && urlParts[1].length === 16) {
let media = hashLookupCache.get(urlParts[1]) ?? null;
if (!media) {
media = await MediaService.GetByTag(urlParts[1]);
if (media) {
hashLookupCache.set(urlParts[1], media);
}
}
if (media) {
// @ts-ignore
req.logType = cyan("IMAGE");
const fileStore = HashFS.GetHashFSInstance("images");
const readStream = createReadStream(join(fileStore.path, fileStore.GetRelativePath(media.Hash)));
res.raw.writeHead(200, HeaderUtility.CombineHeaders({
"content-type": media.MediaType,
"content-length": media.FileSize,
}));
readStream.pipe(res.raw);
return;
}
} else {
HeaderUtility.AddBakedHeaders(res);
}
// @ts-ignore
req.logType = magenta("STATIC");
}
return done();
})();
});
fastify.addHook("onSend", (req, res, _payload, done) => {
// @ts-ignore
Console.printInfo(`[ ${req.logType} ] [ ${req.method.toUpperCase()} ] [ ${ConsoleUtility.StatusColor(res.statusCode)} ] [ ${blue(`${Date.now() - req.startTime}ms`)} ] > ${req.url}`);
//console.log(res.getHeaders());
done();
});
fastify.setNotFoundHandler(async (_req, res) => {
return res.status(404).view("views/404.ejs");
});
HashFS.STARTUP_DIR = __dirname;
new HashFS("images");
if (Config.database.enabled) {
new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name);
} else {
Console.printInfo(`[ ${red("DATABASE")} ] Database is disabled.`);
}
if (Config.controllers.enabled && Config.database.enabled) {
Controller.FastifyInstance = fastify;
new AccountController();
new HomeController();
} else {
Console.printInfo(`[ ${red("CONTROLLER")} ] Controllers are disabled${Config.controllers.enabled && !Config.database.enabled ? " because the database is disabled but required by the controllers." : "."} Server will operate in static mode only.`);
}
fastify.listen({ port: Config.hosts.webPort, host: Config.hosts.webHost }, (err, address) => {
if (err) {
Console.printError(`Error occured while spinning up fastify:\n${err}`);
process.exit(1);
}
Console.printInfo(`[ ${green("MAIN")} ] Listening at ${address.replace("0.0.0.0", "localhost").replace("127.0.0.1", "localhost")}`);
});

View file

@ -0,0 +1,5 @@
export default interface LoginViewModel {
message?: string,
username: string,
password: string
}

View file

@ -0,0 +1,8 @@
export default interface RegisterViewModel {
message?: string,
registerKey: string
username: string,
email: string,
password: string,
password2: string
}

View file

@ -0,0 +1,5 @@
import Media from "../../entities/Media";
export default interface DashboardViewModel {
recentUploads: Array<Media>
}

50
objects/Config.ts Normal file
View file

@ -0,0 +1,50 @@
import { readFileSync } from "fs";
const config = JSON.parse(readFileSync("./config.json").toString());
export default abstract class Config {
public static instance: string = config.instance;
public static hosts: IHosts = config.hosts;
public static database: IDatabase = config.database;
public static session: ISession = config.session;
public static controllers: IControllers = config.controllers;
public static accounts: IAccounts = config.accounts;
}
interface IHosts {
webHost: string,
webPort: number
}
interface IDatabase {
enabled: boolean,
address: string,
port: number,
username: string,
password: string,
name: string
}
interface ISession {
secret: string,
validity: number,
length: number
}
interface IControllers {
enabled: boolean
}
interface ISignup {
enabled: boolean,
key: string | null
}
interface IPbkdf2 {
itterations: number,
keylength: number
}
interface IAccounts {
signup: ISignup,
pbkdf2: IPbkdf2
}

103
objects/Database.ts Normal file
View file

@ -0,0 +1,103 @@
import { blue } from "dyetty";
import { Console } from "hsconsole";
import { createPool, type Pool, type RowDataPacket } from "mysql2";
export type DBInDataType = string | number | Date | 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(`[ ${blue("DATABASE")} ] 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;
}
}

168
objects/HashFS.ts Normal file
View file

@ -0,0 +1,168 @@
// ! Hashed File Store (not file system!!)
import { join } from "path";
import { existsSync, mkdirSync, createWriteStream, rename, stat, writeFile, rm, rmSync, } from "fs";
import { Console } from "hsconsole";
import { yellow } from "dyetty";
import { createHash, randomBytes } from "crypto";
import FunkyArray from "funky-array";
import HashFSFileInformation from "./HashFSFileInformation";
import type { BusboyFileStream } from "@fastify/busboy";
export default class HashFS {
public static STARTUP_DIR: string;
private static HASHFS_INSTANCES: FunkyArray<string, HashFS> = new FunkyArray<string, HashFS>();
public static GetHashFSInstance(name: string) {
const instance = this.HASHFS_INSTANCES.get(name);
if (!instance) {
throw `Attempted to get nonexistent HashFS instance "${name}"`;
}
return instance;
}
public readonly path: string;
private readonly tempPath: string;
private readonly folder: string;
private logInfo(logText: string) {
Console.printInfo(`[ ${yellow(`HashFS: ${this.folder}`)} ] ${logText}`);
}
public constructor(folder: string) {
HashFS.HASHFS_INSTANCES.set(folder, this);
this.folder = folder;
this.path = join(HashFS.STARTUP_DIR, folder);
let firstCreation = false;
if (!existsSync(this.path)) {
this.logInfo(`Creating HashFS for "${folder}"...`);
mkdirSync(this.path);
firstCreation = true;
}
this.logInfo(`Validating file store...`);
let issuesRepaired = 0;
for (let i = 0; i < 16; i++) {
const hashRootFolderPath = join(this.path, i.toString(16));
if (!existsSync(hashRootFolderPath)) {
mkdirSync(hashRootFolderPath);
this.logInfo(`"${i.toString(16)}" does not exist, creating...`);
issuesRepaired++;
}
for (let i1 = 0; i1 < 16; i1++) {
const subFolderPath = join(hashRootFolderPath, (i * 16 + i1).toString(16).padStart(2, "0"));
if (!existsSync(subFolderPath)) {
this.logInfo(`"${i.toString(16)}/${(i * 16 + i1).toString(16).padStart(2, "0")}" does not exist, creating...`);
mkdirSync(subFolderPath);
issuesRepaired++;
}
}
}
// TODO: Validate the files in the file store
this.logInfo(`File Store Validated Successfully${!firstCreation && issuesRepaired > 0 ? `. Repaired ${issuesRepaired} issues.` : " with no issues."}`);
this.tempPath = join(this.path, "temp");
if (existsSync(this.tempPath)) {
rmSync(this.tempPath, { recursive: true });
}
mkdirSync(this.tempPath);
this.logInfo(`Created temp working folder at "${this.tempPath}"`);
this.logInfo(`Ready!`);
}
public GetFilePath(hash: string) {
return join(this.path, hash[0], `${hash[0]}${hash[1]}`, hash);
}
public GetRelativePath(hash: string) {
return join(hash[0], `${hash[0]}${hash[1]}`, hash);
}
public AddFile(contents: Buffer | string) {
return new Promise<HashFSFileInformation>(async (resolve, reject) => {
const hasher = createHash("sha1");
hasher.setEncoding("hex");
hasher.write(contents);
hasher.end();
const hash: string = hasher.read();
const fileInfo = new HashFSFileInformation();
fileInfo.fileHash = hash;
fileInfo.fileSize = contents.length;
if (await this.FileExists(hash)) {
fileInfo.fileExistsAlready = true;
this.logInfo(`File with hash "${hash}" already exists.`);
return resolve(fileInfo);
}
writeFile(this.GetFilePath(hash), contents, (err) => {
if (err) {
return reject(err);
}
resolve(fileInfo);
})
});
}
public AddFromStream(stream: BusboyFileStream) {
return new Promise<HashFSFileInformation>(async (resolve, reject) => {
const hasher = createHash("sha1");
hasher.setEncoding("hex");
const tempFilePath = join(this.tempPath, randomBytes(16).toString("base64url"));
const tempFile = createWriteStream(tempFilePath);
tempFile.on("close", async () => {
const hash: string = hasher.read();
const fileInfo = new HashFSFileInformation();
fileInfo.fileHash = hash;
fileInfo.fileSize = fileSize;
if (await this.FileExists(hash)) {
fileInfo.fileExistsAlready = true;
rm(tempFilePath, err => {
if (err) {
return reject(err);
}
this.logInfo(`File with hash "${hash}" already exists.`);
resolve(fileInfo);
});
} else {
rename(tempFilePath, this.GetFilePath(hash), err => {
if (err) {
return reject(err);
}
this.logInfo(`Stored file as ${hash}`);
resolve(fileInfo);
});
}
});
stream.pipe(tempFile);
stream.pipe(hasher);
let fileSize = 0;
stream.on("data", chunk => fileSize += chunk.length);
});
}
public FileExists(hash: string) {
return new Promise<boolean>((resolve, reject) => {
stat(this.GetFilePath(hash), (err, _stat) => {
if (err) {
if (err.code === "ENOENT") {
resolve(false);
} else {
reject(err);
}
} else {
resolve(true);
}
});
});
}
}

View file

@ -0,0 +1,5 @@
export default class HashFSFileInformation {
public fileHash: string = "";
public fileSize: number = Number.MIN_VALUE;
public fileExistsAlready: boolean = false;
}

67
objects/RequestCtx.ts Normal file
View file

@ -0,0 +1,67 @@
import { type FastifyReply, type 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["UserType"] = UserType;
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 ?? "");
}
}

55
objects/Session.ts Normal file
View file

@ -0,0 +1,55 @@
import Config from "./Config";
import FastifyCookie from "@fastify/cookie";
import FunkyArray from "funky-array";
import SessionUser from "./SessionUser";
import { type FastifyReply } from "fastify";
import User from "../entities/User";
import { randomBytes } from "crypto";
type Cookies = { [cookieName: string]: string | undefined }
export default abstract class Session {
public static Sessions = new FunkyArray<string, SessionUser>();
public static SessionExpiryInterval = setInterval(() => {
const currentTime = Date.now();
for (const key of Session.Sessions.keys) {
const session = Session.Sessions.get(key);
if (!session || (session && currentTime >= session.validityPeriod.getTime())) {
Session.Sessions.remove(key);
}
}
}, 3600000);
public static AssignUserSession(res:FastifyReply, user:User) {
const validPeriod = new Date();
validPeriod.setTime(validPeriod.getTime() + Config.session.validity);
const key = randomBytes(Config.session.length).toString("hex");
Session.Sessions.set(key, new SessionUser(user.Id, user.Username, user.UserType, validPeriod));
res.setCookie("EHP_SESSION", key, {
path: "/",
signed: true
});
}
public static Clear(cookies:Cookies, res:FastifyReply) {
if ("EHP_SESSION" in cookies && typeof(cookies["EHP_SESSION"]) === "string") {
const key:unknown = FastifyCookie.unsign(cookies["EHP_SESSION"], Config.session.secret);
Session.Sessions.remove(key as string);
res.clearCookie("EHP_SESSION");
}
}
public static CheckValiditiy(cookies:Cookies) {
if ("EHP_SESSION" in cookies && typeof(cookies["EHP_SESSION"]) === "string") {
const key = FastifyCookie.unsign(cookies["EHP_SESSION"], Config.session.secret);
if (key.valid && Session.Sessions.has(key.value ?? "badkey")) {
return Session.Sessions.get(key.value ?? "badkey");
}
}
return undefined;
}
}

15
objects/SessionUser.ts Normal file
View file

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

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"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": {
"updateCheck": "check-outdated",
"dev:legacy_node": "nodemon --watch './**/*.ts' ts-node index.ts",
"dev": "bun --watch index.ts",
"build": "tsc --build"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/ejs": "^3.1.5",
"@vercel/ncc": "^0.38.3",
"check-outdated": "^2.12.0",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/formbody": "^8.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.0.4",
"@fastify/view": "^10.0.2",
"dyetty": "^1.0.1",
"ejs": "^3.1.10",
"fastify": "^5.2.1",
"funky-array": "^1.0.0",
"hsconsole": "^1.1.0",
"mysql2": "^3.12.0"
}
}

68
repos/DomainRepo.ts Normal file
View file

@ -0,0 +1,68 @@
import Domain from "../entities/Domain";
import Database from "../objects/Database";
export default class DomainRepo {
public static async SelectAll() {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE IsDeleted = 0");
const mediaList = new Array<Domain>();
for (const row of dbMedia) {
const media = new Domain();
PopulateDomainFromDB(media, row);
mediaList.push(media);
}
return mediaList;
}
public static async SelectById(id: number) {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE Id = ? LIMIT 1", [id]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Domain();
PopulateDomainFromDB(media, dbMedia[0]);
return media;
}
}
public static async SelectByDomain(domain: string) {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE Domain = ? AND IsDeleted = 0 LIMIT 1", [domain]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Domain();
PopulateDomainFromDB(media, dbMedia[0]);
return media;
}
}
public static async InsertUpdate(domain: Domain) {
if (domain.Id === Number.MIN_VALUE) {
domain.Id = (await Database.Instance.query("INSERT Domain (UserId, DomainId, FileName, MediaTag, MediaType, Hash, FileSize, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [
domain.UserId, Number(domain.HasHttps), domain.Domain, Number(domain.Active), domain.CreatedByUserId, domain.CreatedDatetime, domain.LastModifiedByUserId ?? null, domain.LastModifiedDatetime ?? null, domain.DeletedByUserId ?? null, domain.DeletedDatetime ?? null, Number(domain.IsDeleted)
]))[0]["Id"];
} else {
await Database.Instance.query(`UPDATE Media SET UserId = ?, HasHttps = ?, Domain = ?, Active = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [
domain.UserId, Number(domain.HasHttps), domain.Domain, Number(domain.Active), domain.CreatedByUserId, domain.CreatedDatetime, domain.LastModifiedByUserId ?? null, domain.LastModifiedDatetime ?? null, domain.DeletedByUserId ?? null, domain.DeletedDatetime ?? null, Number(domain.IsDeleted), domain.Id
]);
}
return domain;
}
}
function PopulateDomainFromDB(domain: Domain, dbDomain: any) {
domain.Id = dbDomain.Id;
domain.UserId = dbDomain.UserId;
domain.HasHttps = dbDomain.HasHttps[0] === 1;
domain.Domain = dbDomain.Domain;
domain.Active = dbDomain.Active[0] === 1;
domain.CreatedByUserId = dbDomain.CreatedByUserId;
domain.CreatedDatetime = dbDomain.CreatedDatetime;
domain.LastModifiedByUserId = dbDomain.LastModifiedByUserId;
domain.LastModifiedDatetime = dbDomain.LastModifiedDatetime;
domain.DeletedByUserId = dbDomain.DeletedByUserId;
domain.DeletedDatetime = dbDomain.DeletedDatetime;
domain.IsDeleted = dbDomain.IsDeleted[0] === 1;
}

106
repos/MediaRepo.ts Normal file
View file

@ -0,0 +1,106 @@
import Database from "../objects/Database";
import Media from "../entities/Media";
export default abstract class MediaRepo {
public static async SelectAll() {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE IsDeleted = 0");
const mediaList = new Array<Media>();
for (const row of dbMedia) {
const media = new Media();
PopulateMediaFromDB(media, row);
mediaList.push(media);
}
return mediaList;
}
public static async SelectById(id: number) {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE Id = ? LIMIT 1", [id]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Media();
PopulateMediaFromDB(media, dbMedia[0]);
return media;
}
}
public static async SelectByMediaTag(mediaTag: string) {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE MediaTag = ? LIMIT 1", [mediaTag]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Media();
PopulateMediaFromDB(media, dbMedia[0]);
return media;
}
}
public static async SelectByUserHash(currentUserId: number, hash: string) {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE Hash = ? LIMIT 1", [hash]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Media();
PopulateMediaFromDB(media, dbMedia[0]);
return media;
}
}
public static async SelectByHash(hash: string) {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE Hash = ? LIMIT 1", [hash]);
if (dbMedia == null || dbMedia.length === 0) {
return null;
} else {
const media = new Media();
PopulateMediaFromDB(media, dbMedia[0]);
return media;
}
}
public static async SelectRecentMedia(userId: number, amount: number) {
const dbMedia = await Database.Instance.query("SELECT * FROM Media WHERE UserId = ? ORDER BY Id DESC LIMIT ?", [userId, amount]);
const mediaList = new Array<Media>();
for (const row of dbMedia) {
const media = new Media();
PopulateMediaFromDB(media, row);
mediaList.push(media);
}
return mediaList;
}
public static async InsertUpdate(media: Media) {
if (media.Id === Number.MIN_VALUE) {
media.Id = (await Database.Instance.query("INSERT Media (UserId, DomainId, FileName, MediaTag, MediaType, Hash, FileSize, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [
media.UserId, media.DomainId, media.FileName, media.MediaTag, media.MediaType, media.Hash, media.FileSize, media.CreatedByUserId, media.CreatedDatetime, media.LastModifiedByUserId ?? null, media.LastModifiedDatetime ?? null, media.DeletedByUserId ?? null, media.DeletedDatetime ?? null, Number(media.IsDeleted)
]))[0]["Id"];
} else {
await Database.Instance.query(`UPDATE Media SET UserId = ?, DomainId = ?, FileName = ?, MediaTag = ?, MediaType = ?, Hash = ?, FileSize = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [
media.UserId, media.DomainId, media.FileName, media.MediaTag, media.Hash, media.FileSize, media.CreatedByUserId, media.CreatedDatetime, media.LastModifiedByUserId ?? null, media.LastModifiedDatetime ?? null, media.DeletedByUserId ?? null, media.DeletedDatetime ?? null, Number(media.IsDeleted), media.Id
]);
}
return media;
}
}
function PopulateMediaFromDB(media: Media, dbMedia: any) {
media.Id = dbMedia.Id;
media.UserId = dbMedia.UserId;
media.DomainId = dbMedia.DomainId;
media.FileName = dbMedia.FileName;
media.MediaTag = dbMedia.MediaTag;
media.MediaType = dbMedia.MediaType;
media.Hash = dbMedia.Hash;
media.FileSize = dbMedia.FileSize;
media.CreatedByUserId = dbMedia.CreatedByUserId;
media.CreatedDatetime = dbMedia.CreatedDatetime;
media.LastModifiedByUserId = dbMedia.LastModifiedByUserId;
media.LastModifiedDatetime = dbMedia.LastModifiedDatetime;
media.DeletedByUserId = dbMedia.DeletedByUserId;
media.DeletedDatetime = dbMedia.DeletedDatetime;
media.IsDeleted = dbMedia.IsDeleted[0] === 1;
}

105
repos/UserRepo.ts Normal file
View file

@ -0,0 +1,105 @@
import Database from "../objects/Database";
import User from "../entities/User";
export default abstract class UserRepo {
public static async SelectAll() {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE IsDeleted = 0");
const users = new Array<User>();
for (const row of dbUser) {
const user = new User();
PopulateUserFromDB(user, row);
users.push(user);
}
return users;
}
public static async SelectById(id: number) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Id = ? LIMIT 1", [id]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByUsername(username: string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Username = ? LIMIT 1", [username]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByApiKey(apiKey: string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE ApiKey = ? LIMIT 1", [apiKey]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByUploadKey(uploadKey: string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE UploadKey = ? LIMIT 1", [uploadKey]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByEmailAddress(emailAddress: string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE EmailAddress = ? LIMIT 1", [emailAddress]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async InsertUpdate(user: User) {
if (user.Id === Number.MIN_VALUE) {
user.Id = (await Database.Instance.query("INSERT User (UserTypeId, Username, EmailAddress, PasswordHash, PasswordSalt, ApiKey, UploadKey, Verified, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [
user.UserType, user.Username, user.EmailAddress, user.PasswordHash, user.PasswordSalt, user.ApiKey, user.UploadKey, Number(user.Verified), user.CreatedByUserId, user.CreatedDatetime, user.LastModifiedByUserId ?? null, user.LastModifiedDatetime ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime ?? null, Number(user.IsDeleted)
]))[0]["Id"];
} else {
await Database.Instance.query(`UPDATE User SET UserTypeId = ?, Username = ?, EmailAddress = ?, PasswordHash = ?, PasswordSalt = ?, ApiKey = ?, UploadKey = ?, Verified = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [
user.UserType, user.Username, user.EmailAddress, user.PasswordHash, user.PasswordSalt, user.ApiKey, user.UploadKey, Number(user.Verified), user.CreatedByUserId, user.CreatedDatetime, user.LastModifiedByUserId ?? null, user.LastModifiedDatetime ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime ?? null, Number(user.IsDeleted), user.Id
]);
}
return user;
}
}
function PopulateUserFromDB(user: User, dbUser: any) {
user.Id = dbUser.Id;
user.UserType = dbUser.UserTypeId;
user.Username = dbUser.Username;
user.EmailAddress = dbUser.EmailAddress;
user.PasswordHash = dbUser.PasswordHash;
user.PasswordSalt = dbUser.PasswordSalt;
user.ApiKey = dbUser.ApiKey;
user.UploadKey = dbUser.UploadKey;
user.Verified = dbUser.Verified[0] === 1;
user.CreatedByUserId = dbUser.CreatedByUserId;
user.CreatedDatetime = dbUser.CreatedDatetime;
user.LastModifiedByUserId = dbUser.LastModifiedByUserId;
user.LastModifiedDatetime = dbUser.LastModifiedDatetime;
user.DeletedByUserId = dbUser.DeletedByUserId;
user.DeletedDatetime = dbUser.DeletedDatetime;
user.IsDeleted = dbUser.IsDeleted[0] === 1;
}

22
services/MediaService.ts Normal file
View file

@ -0,0 +1,22 @@
import { Console } from "hsconsole";
import MediaRepo from "../repos/MediaRepo";
export default abstract class MediaService {
public static async GetByHash(hash: string) {
try {
return await MediaRepo.SelectByHash(hash);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetByTag(tag: string) {
try {
return await MediaRepo.SelectByMediaTag(tag);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
}

137
services/UserService.ts Normal file
View file

@ -0,0 +1,137 @@
import { Console } from "hsconsole";
import UserRepo from "../repos/UserRepo";
import PasswordUtility from "../utilities/PasswordUtility";
import UserType from "../enums/UserType";
import User from "../entities/User";
import MediaRepo from "../repos/MediaRepo";
import { type MultipartFile } from "@fastify/multipart"
import HashFS from "../objects/HashFS";
import Media from "../entities/Media";
import { randomBytes } from "crypto";
import DomainRepo from "../repos/DomainRepo";
export default abstract class UserService {
public static async AuthenticateUser(username:string, password:string) {
try {
const user = await UserRepo.SelectByUsername(username);
if (!user) {
return null;
}
if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, password)) {
return user;
}
return null;
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetUser(id:number) {
try {
return await UserRepo.SelectById(id);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetAll() {
try {
return await UserRepo.SelectAll();
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetUserByUsername(username:string) {
try {
return await UserRepo.SelectByUsername(username);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async CreateUser(currentUserId: number, username: string, email: string, password: string) {
try {
const existingCheck = await UserRepo.SelectByUsername(username);
if (existingCheck) {
return null;
}
const user = new User();
user.UserType = UserType.User;
user.Username = username;
user.EmailAddress = email;
user.PasswordSalt = PasswordUtility.GenerateSalt();
user.PasswordHash = await PasswordUtility.HashPassword(user.PasswordSalt, password);
user.ApiKey = randomBytes(64).toString("base64url");
user.UploadKey = randomBytes(64).toString("base64url");
user.CreatedByUserId = currentUserId;
user.CreatedDatetime = new Date();
await UserRepo.InsertUpdate(user);
return user;
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetRecentUploads(currentUserId: number) {
try {
return await MediaRepo.SelectRecentMedia(currentUserId, 10);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetByUploadKey(uploadKey: string) {
try {
return await UserRepo.SelectByUploadKey(uploadKey);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async UploadMedia(currentUserId: number, host: string, data: MultipartFile) {
try {
const fileInfo = await HashFS.GetHashFSInstance("images").AddFromStream(data.file);
const domain = await DomainRepo.SelectByDomain(host);
if (!domain) {
return null;
}
let media = await MediaRepo.SelectByUserHash(currentUserId, fileInfo.fileHash);
if (!media) {
media = new Media();
media.CreatedByUserId = currentUserId;
media.CreatedDatetime = new Date();
media.UserId = currentUserId;
media.DomainId = domain.Id; // TODO: Make this come from the host. Only EUS's domain is supported for now.
media.FileName = data.filename;
media.MediaTag = randomBytes(12).toString("base64url");
media.MediaType = data.mimetype;
media.Hash = fileInfo.fileHash;
media.FileSize = fileInfo.fileSize;
await MediaRepo.InsertUpdate(media);
}
return `${domain.HasHttps ? "https" : "http"}://${domain.Domain}/${media.MediaTag}`;
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
}

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true
}
}

View file

@ -0,0 +1,15 @@
import { green, yellow, red, gray } from "dyetty";
export default abstract class ConsoleUtility {
public static StatusColor(statusCode: number) {
if (statusCode < 300) {
return `${green(statusCode.toString())}`;
} else if (statusCode >= 300 && statusCode < 400) {
return `${yellow(statusCode.toString())}`;
} else if (statusCode >= 400 && statusCode < 600) {
return `${red(statusCode.toString())}`;
} else {
return `${gray(statusCode.toString())}`;
}
}
}

View file

@ -0,0 +1,35 @@
import type { FastifyReply } from "fastify";
export default abstract class HeaderUtility {
public static BakedHeaders = {
"x-powered-by": "EUS",
"rel": "cute",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-XSS-Protection": "1; mode=block",
"Permissions-Policy": "microphone=(), geolocation=(), magnetometer=(), camera=(), payment=(), usb=(), accelerometer=(), gyroscope=()",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "block-all-mixed-content;frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff"
};
public static AddBakedHeaders(res: FastifyReply) {
/*res.header("x-powered-by", "EUS");
res.header("rel", "cute");
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");*/
res.headers(this.BakedHeaders);
}
public static CombineHeaders(headers: any) {
// for (const header of Object.keys(headers)) {
// res.header(header, headers[header]);
// }
return { ...this.BakedHeaders, ...headers };
}
}

View file

@ -0,0 +1,36 @@
import { pbkdf2, randomBytes } from "crypto";
import Config from "../objects/Config";
export default abstract class PasswordUtility {
public static ValidatePassword(hash:string, salt:string, password:string) {
return new Promise<boolean>((resolve, reject) => {
pbkdf2(password, salt, Config.accounts.pbkdf2.itterations, Config.accounts.pbkdf2.keylength, "sha512", (err, derivedKey) => {
if (err) {
return reject(err);
} else {
if (derivedKey.toString("hex") !== hash) {
return resolve(false);
}
return resolve(true);
}
});
});
}
public static HashPassword(salt:string, password:string) {
return new Promise<string>((resolve, reject) => {
pbkdf2(password, salt, Config.accounts.pbkdf2.itterations, Config.accounts.pbkdf2.keylength, "sha512", (err, derivedKey) => {
if (err) {
return reject(err);
} else {
return resolve(derivedKey.toString("hex"));
}
});
});
}
public static GenerateSalt() {
return randomBytes(Config.accounts.pbkdf2.keylength).toString("hex");
}
}

13
views/404.ejs Normal file
View file

@ -0,0 +1,13 @@
<%- include("./base/header", { title: "404" }) %>
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1>404</h1>
</div>
</div>
</div>
</div>
<%- include("./base/footer") %>

27
views/account/login.ejs Normal file
View file

@ -0,0 +1,27 @@
<%- include("../base/header", { title: "Login", session }) %>
<div class="d-flex justify-content-center">
<div class="card my-auto" style="width: 25rem;">
<div class="card-body">
<h4 class="card-title text-center">EUS Login</h5>
<% if (typeof(message) === "string") { %>
<div class="alert alert-danger text-center" role="alert"><%= message %></div>
<% } %>
<form action="/account/login" method="POST">
<input type="hidden" name="returnTo" value="<%= typeof(returnTo) === "undefined" ? "" : returnTo %>" >
<input class="form-control mt-3 mb-2" name="username" placeholder="Username" value="<%= typeof(username) === "undefined" ? "" : username %>" required />
<input class="form-control mb-3" name="password" type="password" placeholder="Password" required />
<div class="row">
<div class="col d-flex justify-content-center">
<a class="align-self-center" href="/account/register">I don't have an account.</a>
</div>
<div class="col-auto me-3">
<input class="btn btn-primary mx-auto d-block" type="submit" value="Login" />
</div>
</div>
</form>
</div>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -0,0 +1,35 @@
<%- include("../base/header", { title: "Register", session }) %>
<div class="d-flex justify-content-center">
<div class="card my-auto" style="width: 25rem;">
<div class="card-body">
<h4 class="card-title text-center">EUS Registration</h5>
<% if (typeof(message) === "string") { %>
<div class="alert alert-danger text-center" role="alert"><%= message %></div>
<% } %>
<form action="/account/register" method="POST">
<input type="hidden" name="returnTo" value="<%= typeof(returnTo) === "undefined" ? "" : returnTo %>" >
<div class="input-group mt-3 mb-2">
<span class="input-group-text"><i class="bi bi-key-fill"></i></span>
<input class="form-control" name="registerKey" placeholder="Registration Key" value="<%= typeof(registerKey) === "undefined" ? "" : registerKey %>" required autocomplete="new-password" />
</div>
<hr>
<input class="form-control mt-3 mb-2" name="username" placeholder="Username" value="<%= typeof(username) === "undefined" ? "" : username %>" required />
<input class="form-control mt-3 mb-2" name="email" type="email" placeholder="Email Address" value="<%= typeof(email) === "undefined" ? "" : email %>" required autocomplete="new-password" />
<hr>
<input class="form-control mb-3" name="password" type="password" placeholder="Password" required autocomplete="new-password" />
<input class="form-control mb-3" name="password2" type="password" placeholder="Confirm Password" required autocomplete="new-password" />
<div class="row">
<div class="col d-flex justify-content-center">
<a class="align-self-center" href="/account/login">I have an account!</a>
</div>
<div class="col-auto me-3">
<input class="btn btn-primary mx-auto d-block" type="submit" value="Register" />
</div>
</div>
</form>
</div>
</div>
</div>
<%- include("../base/footer") %>

36
views/base/footer.ejs Normal file
View file

@ -0,0 +1,36 @@
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.js" integrity="sha512-yXXqOFjdjHNH1GND+1EO0jbvvebABpzGKD66djnUfiKlYME5HGMUJHoCaeE4D5PTG2YsSJf6dwqyUUvQvS0vaA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
(() => {
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
})();
window.cookieconsent.initialise({
"palette": {
"popup": {
"background": "#0b5ed7",
"text": "#fff"
},
"button": {
"background": "#198754",
"text": "#fff"
}
},
"content": {
"message": "This site uses cookies to retain your login, no more, no less.<br>If you do not agree with this use of cookies, please do not use this site."
}
});
</script>
</body>
</html>

64
views/base/header.ejs Normal file
View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> - EUS</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root {
font-family: Inter, sans-serif;
--bs-font-sans-serif: "Inter, sans-serif";
font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */
}
@supports (font-variation-settings: normal) {
:root {
font-family: InterVariable, sans-serif;
--bs-font-sans-serif: "InterVariable, sans-serif";
}
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" integrity="sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css" integrity="sha512-dPXYcDub/aeb08c63jRq/k6GaKccl256JQy/AnOq7CAnEZ9FzSL9wSbcZkMp4R26vBsMLFYH4kQ67/bbV8XaCQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.css" integrity="sha512-LQ97camar/lOliT/MqjcQs5kWgy6Qz/cCRzzRzUCfv0fotsCTC9ZHXaPQmJV8Xu/PVALfJZ7BDezl5lW3/qBxg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js" integrity="sha512-7Pi/otdlbbCR+LnW+F7PwFcSDJOuUJB3OxtEHbg4vSMvzvJjde4Po1v4BR9Gdc9aXNUNFVUY+SK51wWT8WF0Gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<nav class="navbar navbar-expand">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img src="/img/EUSIcon32xSlim.webp" alt="EUS"></a>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<!-- <div class="nav-item">
<a class="nav-link" href="/">Home</a>
</div> -->
</ul>
<ul class="navbar-nav">
<% if (typeof(session) !== "undefined") { %>
<div class="nav-item float-end">
<!-- <a class="nav-link" href="/account/logout">Logout</a> -->
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Logged in as <%= session.username %></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/account/pwchange">Change Password</a></li>
<li><a class="dropdown-item" href="/account/2fa">Enable 2FA</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/account/logout">Logout</a></li>
</ul>
</div>
</div>
<% } else { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/login">Sign In</a>
</div>
<% } %>
</ul>
</div>
</div>
</nav>
<div class="container pt-5">

35
views/home/dashboard.ejs Normal file
View file

@ -0,0 +1,35 @@
<%- include("../base/header", { title: "Home", session }) %>
<div class="row">
<!-- Recent Uploads -->
<div class="col">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col text-start">Recent Uploads</div>
<div class="col text-end"><a aria-label="View All Uploads" href="/imagelist">View All >></a></div>
</div>
</div>
<div class="card-body">
<table>
<tbody>
<% for (const upload of recentUploads) { %>
<tr>
<td><%= upload.FileName %></td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
<!-- Stats -->
<div class="col">
<div class="card">
<div class="card-header">Stats</div>
<div class="card-body"></div>
</div>
</div>
</div>
<%- include("../base/footer") %>

5
views/home/index.ejs Normal file
View file

@ -0,0 +1,5 @@
<%- include("../base/header", { title: "Home", session }) %>
<%- include("../base/footer") %>

BIN
wwwroot/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
wwwroot/img/EUSIcon32x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB