652 lines
20 KiB
JavaScript
Executable File
652 lines
20 KiB
JavaScript
Executable File
// Import all the things
|
|
const axios = require('axios');
|
|
const express = require('express');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const readline = require('node:readline/promises');
|
|
const http = require('node:http');
|
|
|
|
// yes, it is `new new require('ytmusic-api')`, i didnt fuck up
|
|
// this is literally black magic i have no idea how or why it works
|
|
const ytmusic = new new require('ytmusic-api'); // i love me a new new
|
|
|
|
// Create interface for console
|
|
const rl = readline.createInterface({input:process.stdin, output:process.stdout});
|
|
|
|
// Global constants
|
|
|
|
/**
|
|
* Should always be `/prod/portfolio`, dynamically caching it in case i decide to move it to a different path sometime in the future
|
|
*/
|
|
const baseDir = process.cwd();
|
|
|
|
/**
|
|
* The ID for my favorite moosic playlist :D
|
|
*/
|
|
const playlistId = 'PLnlTMS4cxfx3-et_L8APzpEgy_eCf18-U';
|
|
|
|
/**
|
|
* Port that nginx proxies to public
|
|
*/
|
|
const port = 48915;
|
|
|
|
// Helper functions I've made to do things and stuff :P
|
|
|
|
/**
|
|
* Loops a thing every hour
|
|
* @param {Function} something
|
|
* @param {Boolean} skipFirst
|
|
* @returns jack shit
|
|
*/
|
|
// I know, I know, I am just the most bestest and awesomest coder everrrrr
|
|
const loopHourly = (something, skipFirst = false) => {
|
|
// If skipFirst is false, we do the thing immediately
|
|
if (!skipFirst) setTimeout(() => {
|
|
let cancel = false;
|
|
something();
|
|
if (!cancel) loopHourly(something);
|
|
return;
|
|
}, 3600000 - new Date().getTime() % 3600000); // 3600000 ms = 1000 ms (one second) * 60 seconds (one minute) * 60 minutes (one hour)
|
|
|
|
// If skipFirst is true, wait a hour then execute
|
|
else setTimeout(() => {
|
|
loopHourly(something)
|
|
}, 3600000 - new Date().getTime() % 3600000);
|
|
}
|
|
|
|
function sendSource(req, res, filePath) {
|
|
try {
|
|
// This will throw an error, which I catch, if the file isnt found
|
|
// Use lstatSync to have a sync read while also not going through symlinks (to prevent arbritrary reads via symlinks)
|
|
// Further restricted by my use of the --permission commandline flag
|
|
// I also added a manual check to block any file reads outside the base dir (/prod/portfolio as of the time of writing this)
|
|
const stat = fs.lstatSync(filePath);
|
|
|
|
if (!stat.isFile()) {
|
|
// If it's not a file, error
|
|
res.status(403).send('<pre>Not a file</pre>');
|
|
} else {
|
|
// If it's a file, send the content
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
res.render('sourceviewer', { faviconb64, filePath, fileContent });
|
|
}
|
|
} catch (err) {
|
|
// Legit why are you doing this
|
|
// If it's whitelisted, it's listed under the index page
|
|
// If it's not whitelisted, you cant access it anyway :P
|
|
res.status(404).send('File not found');
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------------------
|
|
// - Below is black magic. I have no idea how it works. -
|
|
// - I have either stolen it from somewhere or completely forgotten how it works. -
|
|
// - If you know how it works please explain it to me PLEASE I BEG OF YOU HALP :AAAAAA: -
|
|
// --------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Pulls all the pictures for the songs and stores them in a base64 key:value array where the key is the song id and the value is the thumbnail
|
|
* @param {*} song
|
|
*/
|
|
const populateThumbnails = (async song => {
|
|
let id = song.videoId;
|
|
let thumbnail;
|
|
|
|
const response = await axios.get(song.thumbnails[song.thumbnails.length - 1].url, { responseType: 'arraybuffer' });
|
|
|
|
// We now check if Youtube moosic has yelled back a thing we want to hear
|
|
if (response.status == 200) {
|
|
thumbnail = Buffer.from(response.data, "utf-8").toString('base64');
|
|
thumbnails.push({ id, thumbnail });
|
|
} else {
|
|
console.error("Failed to pull thumbnail: " + response);
|
|
}
|
|
});
|
|
|
|
// Updates the list of songs by yelling at youtube moosic to gimme the playlist
|
|
async function updateSongs() {
|
|
|
|
// Yell at youtube moosic to get a list of songs
|
|
playlistSongs = await ytmusic.getPlaylistVideos(playlistId);
|
|
|
|
if (typeof playlistSongs == 'object') {
|
|
// For each song that youtube moosic has yelled back at us, give them individually to populateThumbnails()
|
|
await Promise.all(playlistSongs.map(async song => await populateThumbnails(song)));
|
|
}
|
|
else {
|
|
// like legit this shouldnt even be possible
|
|
console.log("somehow i have managed to get this code into an area i thought impossible");
|
|
console.log("*various anxiousness noises*");
|
|
console.log("help");
|
|
throw TypeError('playlistSongs is not a object!'); // this should never ever happen, if it does that is very very bad
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Array} pathPattern This thingy is the indexPaths below this function
|
|
* @returns {Array} an array called `results`
|
|
*/
|
|
function getDirectoryContents(pathPattern) {
|
|
const results = [];
|
|
|
|
// Handle wildcards (including multiple wildcards like blog/*/*)
|
|
if (pathPattern.includes('*')) {
|
|
const segments = pathPattern.split('/').filter(s => s);
|
|
|
|
/**
|
|
* Recursively resolves the pattern segments against the file system.
|
|
* @param {Array} remainingSegments - The parts of the path left to match (e.g., ['*']).
|
|
* @param {String} currentAbsPath - The absolute path on disk we are currently checking.
|
|
* @returns {Array} - Array of matched file/directory objects.
|
|
*/
|
|
function traverse(remainingSegments, currentAbsPath) {
|
|
// Base case: We have matched all segments. Return the node at this location.
|
|
if (remainingSegments.length === 0) {
|
|
return [createNode(currentAbsPath)];
|
|
}
|
|
|
|
const segment = remainingSegments[0];
|
|
const rest = remainingSegments.slice(1);
|
|
|
|
if (segment === '*') {
|
|
// Wildcard: Read current directory and recurse for all children
|
|
try {
|
|
const files = fs.readdirSync(currentAbsPath, { withFileTypes: true });
|
|
const matches = [];
|
|
|
|
files.forEach(file => {
|
|
const fullPath = path.join(currentAbsPath, file.name);
|
|
// Recurse with the remaining segments
|
|
const childMatches = traverse(rest, fullPath);
|
|
matches.push(...childMatches);
|
|
});
|
|
|
|
return matches;
|
|
} catch (err) {
|
|
console.error(`Error reading directory ${currentAbsPath}:`, err);
|
|
return [];
|
|
}
|
|
} else {
|
|
// Exact segment: Join path and recurse
|
|
const fullPath = path.join(currentAbsPath, segment);
|
|
return traverse(rest, fullPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the result object for a specific path.
|
|
* If it's a directory, it recursively fetches children.
|
|
*/
|
|
function createNode(fullPath) {
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
const relativePath = path.relative(baseDir, fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
// Recursively get children using the main function
|
|
// We append '/*' to get the immediate contents of this directory
|
|
const children = getDirectoryContents(`${relativePath}/*`);
|
|
return {
|
|
name: path.basename(fullPath),
|
|
path: relativePath,
|
|
type: 'directory',
|
|
children: children
|
|
};
|
|
} else {
|
|
return {
|
|
name: path.basename(fullPath),
|
|
path: relativePath,
|
|
type: 'file'
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error reading path ${fullPath}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Start traversal from the base directory
|
|
const nodes = traverse(segments, baseDir);
|
|
// Filter out any null results from errors
|
|
nodes.forEach(node => {
|
|
if (node) results.push(node);
|
|
});
|
|
|
|
} else {
|
|
// Handle exact paths
|
|
|
|
// Thanks past me, for the extremely helpful comment above
|
|
// I now understand exactly what this does /s
|
|
|
|
const fullPath = path.join(baseDir, pathPattern);
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
const relativePath = path.relative(baseDir, fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
const files = fs.readdirSync(fullPath, { withFileTypes: true });
|
|
files.forEach(file => {
|
|
const childPath = path.join(fullPath, file.name);
|
|
const childRelativePath = path.relative(baseDir, childPath);
|
|
|
|
if (file.isDirectory()) {
|
|
const children = getDirectoryContents(`${childRelativePath}/*`);
|
|
results.push({
|
|
name: file.name,
|
|
path: childRelativePath,
|
|
type: 'directory',
|
|
children: children
|
|
});
|
|
} else {
|
|
results.push({
|
|
name: file.name,
|
|
path: childRelativePath,
|
|
type: 'file'
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
results.push({
|
|
name: path.basename(fullPath),
|
|
path: relativePath,
|
|
type: 'file'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error reading path ${fullPath}:`, err);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* An array of the whitelisted directories and their contents
|
|
*/
|
|
let whitelistedFiles = [];
|
|
|
|
/**
|
|
* Whitelisted paths for the source code (to prevent arbritrary reads and stuffs),
|
|
* Also referred to as `pathPattern` in whatever the hell `getDirectoryContents()` is
|
|
*/
|
|
const indexPaths = [
|
|
'blog/*',
|
|
'blog/*/*',
|
|
'public/*',
|
|
'public/piskelfile/*',
|
|
'views/*',
|
|
'views/partials/*',
|
|
'index.js'
|
|
];
|
|
|
|
/**
|
|
* directory tree object (built in the func buildDirectoryTree below)
|
|
*/
|
|
let directoryTree;
|
|
|
|
/**
|
|
* sorted result
|
|
*/
|
|
let sortedResult;
|
|
|
|
// Build directory tree
|
|
const buildDirectoryTree = (items) => {
|
|
const tree = {};
|
|
items.forEach(item => {
|
|
const parts = item.path.split(path.sep).filter(part => part !== '');
|
|
let currentLevel = tree;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (!currentLevel[part]) {
|
|
currentLevel[part] = {
|
|
name: part,
|
|
path: parts.slice(0, i + 1).join(path.sep),
|
|
type: i === parts.length - 1 ? item.type : 'directory',
|
|
children: {}
|
|
};
|
|
}
|
|
currentLevel = currentLevel[part].children;
|
|
}
|
|
if (item.type === 'file') {
|
|
currentLevel[parts[parts.length - 1]] = {
|
|
...currentLevel[parts[parts.length - 1]],
|
|
type: 'file'
|
|
};
|
|
}
|
|
});
|
|
return tree;
|
|
};
|
|
|
|
// Convert the tree to an array for easier rendering
|
|
const treeToArray = (node) => {
|
|
const result = [];
|
|
Object.values(node).forEach(item => {
|
|
if (item.type === 'directory') {
|
|
const directoryItem = {
|
|
name: item.name,
|
|
path: item.path,
|
|
type: 'directory',
|
|
children: treeToArray(item.children)
|
|
};
|
|
result.push(directoryItem);
|
|
} else {
|
|
result.push({
|
|
name: item.name,
|
|
path: item.path,
|
|
type: 'file'
|
|
});
|
|
}
|
|
});
|
|
return result.sort((a, b) => {
|
|
if (a.type === 'directory' && b.type === 'file') return -1;
|
|
if (a.type === 'file' && b.type === 'directory') return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
};
|
|
|
|
async function updateFileIndex() {
|
|
// Clear whitelisted files to prevent memory leak
|
|
whitelistedFiles = [];
|
|
|
|
indexPaths.forEach(path => {
|
|
let contents = getDirectoryContents(path);
|
|
whitelistedFiles.push(...contents);
|
|
});
|
|
|
|
directoryTree = buildDirectoryTree(whitelistedFiles);
|
|
sortedResult = treeToArray(directoryTree);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// - End black magic -
|
|
// - I am pretty sure I know what is going on below here :p -
|
|
// ----------------------------------------------------------
|
|
|
|
var blog; // Setup the blog variable
|
|
|
|
// Blog stuffs
|
|
|
|
async function generateBlogIndex() {
|
|
var blogfolder = fs.readdirSync(path.join(baseDir, 'blog/'));
|
|
var tmpobj = {};
|
|
blogfolder.forEach(thing => {
|
|
if (fs.statSync(path.join(baseDir, `blog/${thing}`)).isDirectory()) {
|
|
var files = fs.readdirSync(path.join(baseDir, `blog/${thing}`));
|
|
tmpobj[thing] = {};
|
|
files.forEach(file => {
|
|
tmpobj[thing][file] = true;
|
|
});
|
|
}
|
|
});
|
|
blog = tmpobj;
|
|
}
|
|
|
|
async function send404(req, res) {
|
|
// TODO: actual 404 page
|
|
res.status(404).send("<h1><pre>404 Not found</pre></h1>");
|
|
}
|
|
|
|
// Begin the server-ing things
|
|
const app = express();
|
|
var server; // Set at app.listen (bottom), used to kill server on ctrl-c
|
|
|
|
// YT Music stuff
|
|
/**
|
|
* An object that stores a Map of songs (also objects)
|
|
*/
|
|
let playlistSongs = new Map;
|
|
/**
|
|
* Key:Value array of songs. The song ID is the key, and a base64 encoded image is the value
|
|
*/
|
|
let thumbnails = [];
|
|
|
|
// Other middlewares
|
|
app.set('view engine', 'ejs');
|
|
app.use(express.static('public'));
|
|
app.use(express.static('private'));
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use((req, res, next) => {
|
|
res.setHeader("Content-Security-Policy","default-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self';");
|
|
next();
|
|
});
|
|
|
|
// Cache the favicon cause I'm probably not gonna change that for a while
|
|
const favicon = fs.readFileSync('public/favicon.ico');
|
|
// Also cache a base64 version of it to pass to my pages
|
|
const faviconb64 = favicon.toString('base64');
|
|
|
|
// ---------------------
|
|
// - Routes begin here -
|
|
// ---------------------
|
|
|
|
// Sends the cached favicon.ico to prevent an unneeded read on a static file :P
|
|
app.get('/favicon.ico', (req, res) => {
|
|
res.status(200).send(favicon);
|
|
});
|
|
|
|
// Home page
|
|
app.get('/', (req, res) => {
|
|
res.render('home', { faviconb64 });
|
|
});
|
|
|
|
// Fun Fact page
|
|
app.get('/funfact', (req, res) => {
|
|
res.render('funfact', { });
|
|
});
|
|
|
|
// The page with all my art :>
|
|
app.get('/art', (req, res) => {
|
|
res.render('art', { faviconb64 });
|
|
});
|
|
|
|
// Source code page
|
|
app.get('/source', (req, res) => {
|
|
res.render('source', { faviconb64, paths: sortedResult });
|
|
});
|
|
|
|
// Route to serve indexed files
|
|
app.get(/^\/source\/.*/, (req, res) => {
|
|
const requestedPath = req.path.split('/source/')[1];
|
|
|
|
// Check if the requested file is whitelisted
|
|
const isIndexed = indexPaths.some(pattern => {
|
|
if (pattern.toString().includes("*")) {
|
|
return whitelistedFiles.some(pattern => { return requestedPath.startsWith(pattern.path) && !(requestedPath.split(pattern.path).some(pattern => {return pattern.includes("/");}));});
|
|
}
|
|
return requestedPath === pattern;
|
|
});
|
|
|
|
if (!isIndexed) {
|
|
return res.status(403).send('<pre>File not whitelisted</pre>');
|
|
}
|
|
|
|
const filePath = path.join(baseDir, requestedPath);
|
|
|
|
// Completely prevent ../ attacks (hopefully)
|
|
// Well I mean it wont prevent bind mounts but if you can bind mount I'm fucked anyways cause you're root
|
|
// Symlinks might (?) bypass this??
|
|
// Hardlinks defo bypass this afaik.
|
|
// because i pass the --permission flag and only pass --allow-fs-read=/prod/portfolio/*
|
|
// you can't write via NJS at all
|
|
// nor read outside /prod/portfolio
|
|
// you'd need to break outta nodejs and start a native process
|
|
// and my filesystem permissions wont letchu write stuff anyway
|
|
if (!filePath.startsWith(baseDir)) {
|
|
return res.status(403).send('<pre>File not whitelisted</pre>');
|
|
}
|
|
|
|
sendSource(req, res, filePath);
|
|
});
|
|
|
|
// Moosic page
|
|
app.get('/music', (req, res) => {
|
|
res.render('moosic', { faviconb64, playlistId, playlistSongs, thumbnails });
|
|
});
|
|
|
|
// Blog
|
|
app.get('/blog', (req, res) => {
|
|
if (req.query && req.query.sample == "1") {
|
|
res.render('blog', { faviconb64, type: "sample", page: null, blog });
|
|
}
|
|
else res.render('blog', { faviconb64, type: "main", page: null, blog });
|
|
});
|
|
|
|
// Individual blog pages / topic pages
|
|
app.get(/^\/blog\/.*/, (req, res) => {
|
|
let requestedTopic = req.path.split('/blog/')[1].split('/')[0];
|
|
let requestedPage = req.path.split('/blog/')[1].split('/')[1];
|
|
let topic = blog[requestedTopic];
|
|
let page;
|
|
if (typeof topic != "object") {
|
|
send404(req, res);
|
|
return;
|
|
}
|
|
if (topic[requestedPage] == true) {
|
|
page = fs.readFileSync(path.join(baseDir, 'blog', requestedTopic, requestedPage));
|
|
res.render('blog', { faviconb64, type: "individual", page, blog })
|
|
} else send404(req, res);
|
|
});
|
|
|
|
app.get('/editor', (req, res) => {
|
|
res.render('editor', { faviconb64 })
|
|
});
|
|
|
|
// Error handling (must be 2nd to last last app.use to make sure it catches all the errorings)
|
|
app.use(function(err, req, res, next) {
|
|
if(!err) {
|
|
next();
|
|
return;
|
|
}
|
|
console.error('-------------------------------ERROR---------------------------------------\n');
|
|
console.error(err); // Log da errors
|
|
console.error('-----------------------------USER-INFO-------------------------------------\n');
|
|
console.error("Body:\n");
|
|
console.error(req.body);
|
|
console.error("\n");
|
|
console.error("Cookies:\n");
|
|
console.error(req.cookies);
|
|
console.error("\n");
|
|
console.error("Host:\n");
|
|
console.error(req.host);
|
|
console.error("\n");
|
|
console.error("Path:\n");
|
|
console.error(req.path);
|
|
console.error("\n");
|
|
console.error('----------------------------------------------------------------------------\n');
|
|
res.status(400).send("<pre>An internal server error has occurred. Please bother me if you see this.<br />Contact me at @oddbyte.11 on Signal, or <a href='mailto:contact@oddbyte.dev'>contact@oddbyte.dev</a> if you are a caveman and are stuck in the last decade.</pre>"); // Give the user something that they can read
|
|
});
|
|
|
|
// 404
|
|
app.use(function(req, res) {
|
|
send404(req, res);
|
|
});
|
|
|
|
function printHelp() {
|
|
rl.write("\n\n-------------------------------HELP-----------------------------------------\n");
|
|
rl.write("> help [?] - prints this message\n");
|
|
rl.write("> updateMusic [um] - runs updateSongs()\n");
|
|
rl.write("> updateFileIndex [ufi] - runs updateFileIndex()\n");
|
|
rl.write("> eval [exec] - runs raw JS code, returns output (if any)\n");
|
|
rl.write("> quit [exit] - stops the server, and quits\n");
|
|
rl.write("-------------------------------HELP-----------------------------------------\n\n")
|
|
}
|
|
|
|
function quit() {
|
|
server.close();
|
|
rl.write('\n\nI do declare - end broadcast\n\n'); // Yoinked Lunduke Journal's signoff :>
|
|
rl.close();
|
|
process.exit(0);
|
|
}
|
|
|
|
async function customConsole() {
|
|
while (1) {
|
|
// Pull commands
|
|
const cmd_raw = await rl.question("> ");
|
|
const cmd_raw_args = cmd_raw.split(" ");
|
|
const cmd_args = cmd_raw_args.slice(1);
|
|
const cmd = cmd_raw_args[0];
|
|
|
|
switch(cmd) {
|
|
case "?":
|
|
case "help":
|
|
printHelp();
|
|
break;
|
|
case "um":
|
|
case "updateMusic":
|
|
updateSongs();
|
|
break;
|
|
case "ufi":
|
|
case "updateFileIndex":
|
|
updateFileIndex();
|
|
break;
|
|
case "exec":
|
|
case "eval":
|
|
// TODO: Fix this not actually using the correct context for whatever reason it just evals in an empty context
|
|
// ...... i dont know what or how or why but magically it is working now so i am just not gonna touch it and hope
|
|
// whatever black magic made it work keeps working .-.
|
|
try {
|
|
rl.write(eval(cmd_args.toString()) + "\n");
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
break;
|
|
case "quit":
|
|
case "exit":
|
|
quit();
|
|
return;
|
|
case "":
|
|
break;
|
|
default:
|
|
rl.write(`Command not found: ${cmd}`)
|
|
printHelp();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wrap in a async function to wait for youtube music response before starting http server
|
|
// (to prevent a race condition where people can view the moosic page be4 it is ready)
|
|
async function main() {
|
|
|
|
// Init the moosics stuff (black magic)
|
|
await ytmusic.initialize();
|
|
|
|
// Populate playlistSongs and thumbnails
|
|
await updateSongs();
|
|
|
|
// Populate the blog index
|
|
await generateBlogIndex();
|
|
|
|
// Populate file index
|
|
await updateFileIndex();
|
|
|
|
server = http.createServer(app);
|
|
|
|
await (async () => {
|
|
server.listen(port, () => {
|
|
console.log(`Listening to ${port}`);
|
|
|
|
// Start hourly loop to update playlist
|
|
loopHourly(async () => await updateSongs());
|
|
|
|
// Start hourly loop to update file index
|
|
loopHourly(async () => await updateFileIndex());
|
|
});
|
|
})();
|
|
|
|
// Start console
|
|
customConsole();
|
|
}
|
|
|
|
// Handle a few signals
|
|
|
|
rl.on('SIGINT', () => {
|
|
quit();
|
|
});
|
|
|
|
rl.on('SIGTERM', () => {
|
|
quit();
|
|
});
|
|
|
|
main();
|