Compare commits
9 commits
Author | SHA1 | Date | |
---|---|---|---|
a0fcc376b7 | |||
fd606996fd | |||
d612ea9668 | |||
301a3ac595 | |||
24247d938f | |||
2c5e40b36f | |||
d343acc9e5 | |||
c4cd41c03c | |||
17b48c92a4 |
46 changed files with 1775 additions and 683 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1 +1,5 @@
|
|||
testing/
|
||||
node_modules/
|
||||
build/
|
||||
logs/
|
||||
images/
|
||||
config.json
|
|
@ -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
550
EUS.js
|
@ -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"
|
||||
];
|
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
36
README.md
36
README.md
|
@ -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
BIN
bun.lockb
Executable file
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
76
controllers/AccountController.ts
Normal file
76
controllers/AccountController.ts
Normal 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("<", "<").replaceAll(">", ">");
|
||||
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");
|
||||
}
|
||||
}
|
5
controllers/ApiController.ts
Normal file
5
controllers/ApiController.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Controller from "./Controller";
|
||||
|
||||
export default class ApiController extends Controller {
|
||||
|
||||
}
|
150
controllers/Controller.ts
Normal file
150
controllers/Controller.ts
Normal 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 }
|
||||
}
|
52
controllers/HomeController.ts
Normal file
52
controllers/HomeController.ts
Normal 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
14
entities/Domain.ts
Normal 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
17
entities/Media.ts
Normal 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
20
entities/User.ts
Normal 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
7
enums/UserType.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
enum UserType {
|
||||
Unknown = 0,
|
||||
User = 10,
|
||||
Admin = 999
|
||||
}
|
||||
|
||||
export default UserType;
|
140
index.ts
Normal file
140
index.ts
Normal 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")}`);
|
||||
});
|
5
models/account/LoginViewModel.ts
Normal file
5
models/account/LoginViewModel.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default interface LoginViewModel {
|
||||
message?: string,
|
||||
username: string,
|
||||
password: string
|
||||
}
|
8
models/account/RegisterViewModel.ts
Normal file
8
models/account/RegisterViewModel.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default interface RegisterViewModel {
|
||||
message?: string,
|
||||
registerKey: string
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
password2: string
|
||||
}
|
5
models/home/DashboardViewModel.ts
Normal file
5
models/home/DashboardViewModel.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Media from "../../entities/Media";
|
||||
|
||||
export default interface DashboardViewModel {
|
||||
recentUploads: Array<Media>
|
||||
}
|
50
objects/Config.ts
Normal file
50
objects/Config.ts
Normal 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
103
objects/Database.ts
Normal 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
168
objects/HashFS.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
5
objects/HashFSFileInformation.ts
Normal file
5
objects/HashFSFileInformation.ts
Normal 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
67
objects/RequestCtx.ts
Normal 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
55
objects/Session.ts
Normal 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
15
objects/SessionUser.ts
Normal 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
41
package.json
Normal 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
68
repos/DomainRepo.ts
Normal 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
106
repos/MediaRepo.ts
Normal 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
105
repos/UserRepo.ts
Normal 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
22
services/MediaService.ts
Normal 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
137
services/UserService.ts
Normal 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
27
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
15
utilities/ConsoleUtility.ts
Normal file
15
utilities/ConsoleUtility.ts
Normal 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())}`;
|
||||
}
|
||||
}
|
||||
}
|
35
utilities/HeaderUtility.ts
Normal file
35
utilities/HeaderUtility.ts
Normal 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 };
|
||||
}
|
||||
}
|
36
utilities/PasswordUtility.ts
Normal file
36
utilities/PasswordUtility.ts
Normal 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
13
views/404.ejs
Normal 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
27
views/account/login.ejs
Normal 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") %>
|
35
views/account/register.ejs
Normal file
35
views/account/register.ejs
Normal 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
36
views/base/footer.ejs
Normal 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
64
views/base/header.ejs
Normal 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
35
views/home/dashboard.ejs
Normal 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
5
views/home/index.ejs
Normal file
|
@ -0,0 +1,5 @@
|
|||
<%- include("../base/header", { title: "Home", session }) %>
|
||||
|
||||
|
||||
|
||||
<%- include("../base/footer") %>
|
BIN
wwwroot/favicon.ico
Normal file
BIN
wwwroot/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
wwwroot/img/EUSIcon32x.webp
Normal file
BIN
wwwroot/img/EUSIcon32x.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
wwwroot/img/EUSIcon32xSlim.webp
Normal file
BIN
wwwroot/img/EUSIcon32xSlim.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
Loading…
Reference in a new issue