Classes/ExpressApp.js

// Basics
const crypto = require("node:crypto");
const path = require("node:path");
const { exec } = require('node:child_process');
const fs = require("node:fs");

// Web
const http = require('http');
const Express = require('express'); // also kinda important
const multer = require('multer');

// Ours
const Notification = require('./Notification.js');
const Client = require('./Client.js');
const IPAddress = require("./IPAddress.js");
const { Logger } = require("./LogMan.js");
const NeptuneCrypto = require('../Support/NeptuneCrypto.js');


const isWin = process.platform === "win32"; // Can change notification handling behavior

/**
 * Time to wait before ending HTTP requests (if the server never did it itself)
 * @type {number}
 */
const autoKillRequestTimeout = (global.__TESTING !== undefined && global.__TESTING === true)? 5000 : 30000;

/**
 * Web log
 * @type {Logger}
 */
global.Neptune.webLog = global.Neptune.logMan.getLogger("Web");



/**
 * @typedef {object} conInitObject
 * @property {Logger} log - Logging object
 * @property {crypto.DiffieHellmanGroup} aliceDHObject - This is our keys
 * @property {crypto.DiffieHellmanGroup} dynamicSaltDHObject - The dynamic salt DH keys
 * @property {boolean} enabled - Whether we're allowing step 2 or 3 to go through
 * @property {boolean} socketCreated - Accepting connections via the socket
 * @property {string} socketUUID - Socket id
 * @property {string} secret - Shared secret key
 * @property {string} createdTime - ISO timestamp when this initiation attempt started. 5 minutes after this time we invalidate this attempt
 * @property {string[]} supportedCiphers - Array of NeptuneCrypto ciphers the client wishes to use
 * @property {string} selectedCipher - Cipher algorithm we've accepted
 * @property {string[]} supportedHashAlgorithms - Array of NeptuneCrypto HKDF hash algorithms the client wishes to use
 * @property {string} selectedHashAlgorithm - Hash algorithm we've accepted
 * @property {string[]} supportedKeyGroups - Array of DH key groups the client supported
 * @property {string} selectedKeyGroup - Key group we've agreed to use
 * @property {string} clientId - Client UUID
 * @property {Client} client - Client object
 */

/**
 * A collection of connection initiation ids. Key is the conInitUUID, 
 * @type {Map<String,conInitObject>}
 */
var conInitUUIDs = {};

/**
 * A collection of socket UUIDs mapped to conInitUUIDs.
 * @type {Map<String, String>}
 */
var socketUUIDs = {};




/**
 * Express app
 * @type {Express}
 */
const app = Express();
// Create HTTP server
const httpServer = http.createServer(app);
const WebSocketServer = require('ws').Server;
app.set('trust proxy', true);
// app.use(Express.urlencoded());
app.use(Express.json());
// app.use(session({
// 	secret: "fThx4TVHS7XvW84274W0uoY4GvhmsDN7nN0W3mhRGH2fgFFEZUEZYIeCGoDNoGojW4YfCUlfNZupiekNiOXI1wuOeS2HICpRsrQdndecLCFKtYXr26jLTEtekpPJpFJ7gt8DSmtOYx8WRVz0Jbb211Vqiwnnc8ENl7Z8iDldh01cICNHBrG4F5E6Uz6IRBJonHOPbi3TiNjnW4nxCywjuhpOkzpDGKhox1A3EythsBLNEJp4Br6X3Uef8muOxKzN",
// 	saveUninitialized: true,
// 	resave: true
// }));

var upload = multer({
	dest: './data/uploads/',
	limits: {
		fileSize: 1000000000, // 1,000MB
	},
}); // For uploads

// Reset uploads folder!
try {
	if (fs.existsSync('./data/uploads')) {
		fs.rmSync('./data/uploads', { recursive: true, force: true });
	}

	fs.mkdirSync('./data/uploads');
} catch (e) {}
// Heartbeat
var sounds = ["Thoomp-thoomp", "Bump-bump", "Thump-bump"];
app.get("/heartbeat", (req, res) => {
	let sound = sounds[Math.floor(Math.random()*sounds.length)];
	global.Neptune.webLog.verbose(sound + " (heartbeat)");
	res.status(200).end('ok');
});

// Web page
app.get("/", (req, res) => {
	res.status(200).end(`<html>
	<head>
		<title>Neptune</title>
	</head>
	<body>
		<h1>Oh my Neptune.</h1>
	</body>
</html>`);

	mainWindow.show();
});


// https://nodejs.org/api/crypto.html#class-diffiehellman
// This is the initial endpoint for the client
app.post('/api/v1/server/initiateConnection', (req, res) => {
	try {
		let conInitUUID = crypto.randomUUID(); // NeptuneCrypto.convert(NeptuneCrypto.randomString(16), "utf8", "hex"); // string (len 16) -> HEX
		let conInitLog = global.Neptune.logMan.getLogger("ConInit-" + conInitUUID);
		conInitLog.info("Initiating new client connection, uuid: " + conInitUUID);

		// https://nodejs.org/api/crypto.html#class-diffiehellmangroup
		let supportedKeyGroups = ['modp14', 'modp15', 'modp16'];
		let keyGroup = 'modp16';
		if (req.body.supportedKeyGroups !== undefined) {
			for (var i = 0; i<req.body.supportedKeyGroups.length; i++) {
				if (supportedKeyGroups.indexOf(req.body.supportedKeyGroups[i]) != -1) {
					keyGroup = req.body.supportedKeyGroups[i];
				}
			}
		}
		conInitLog.debug("Selected DH key group: " + keyGroup);

		// Select the cipher and hash
		let supportedCiphers = ["chacha20-poly1305", "chacha20", "aes-256-gcm", "aes-128-gcm"];
		let supportedHashAlgorithms = ["sha256", "sha512"];
		let cipher = "aes-128-gcm";
		let hashAlgorithm = "sha256";

		if (req.body.supportedCiphers !== undefined) {
			for (var i = 0; i<req.body.supportedCiphers.length; i++) {
				if (supportedCiphers.indexOf(req.body.supportedCiphers[i]) != -1) {
					cipher = req.body.supportedCiphers[i];
				}
			}
		}
		conInitLog.silly("Selected cipher: " + cipher);

		if (req.body.supportedHashAlgorithms !== undefined) {
			for (var i = 0; i<req.body.supportedHashAlgorithms.length; i++) {
				if (supportedHashAlgorithms.indexOf(req.body.supportedHashAlgorithms[i]) != -1) {
					hashAlgorithm = req.body.supportedHashAlgorithms[0];
				}
			}
		}
		conInitLog.silly("Selected hash algorithm: " + hashAlgorithm);
		


		let alice = crypto.getDiffieHellman(keyGroup);
		alice.generateKeys();

		let useDynamicSalt = false;
		let dynamicSalt;
		if (req.body.useDynamicSalt == true) {
			conInitLog.silly("Using dynamicSalt");
			useDynamicSalt = true;
			dynamicSalt = crypto.getDiffieHellman(keyGroup);
			dynamicSalt.generateKeys();
		}


		// Store this data. Create our response
		conInitUUIDs[conInitUUID] = {
			log: conInitLog,
			enabled: true,
			socketCreated: false,
			aliceDHObject: alice,
			createdTime: new Date().toISOString(),
			supportedCiphers: req.body.acceptedCrypto,
			selectedCipher: cipher,
			supportedHashAlgorithms: req.body.acceptedHashTypes,
			selectedHashAlgorithm: hashAlgorithm,
			supportedKeyGroups: req.body.acceptedKeyGroups,
			selectedKeyGroup: keyGroup,
		}

		let myResponsePacket = {
			"g1": alice.getGenerator('base64'),
			"p1": alice.getPrime('base64'),
			"a1": alice.getPublicKey('base64'),
			"conInitUUID": conInitUUID,
			selectedKeyGroup: keyGroup,
			selectedCipher: cipher,
			selectedHashAlgorithm: hashAlgorithm
		};

		if (useDynamicSalt && dynamicSalt !== undefined) {
			myResponsePacket.g2 = dynamicSalt.getGenerator('base64');
			myResponsePacket.p2 = dynamicSalt.getPrime('base64');
			myResponsePacket.a2 = dynamicSalt.getPublicKey('base64');
			conInitUUIDs[conInitUUID].dynamicSaltDHObject = dynamicSalt;
		}

		let responseString = JSON.stringify(myResponsePacket);
		conInitLog.silly("Sending: " + responseString);
		res.status(200).send(responseString);
	} catch (e) {
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).send("{}");
	}
});



// This is the final part of negotiation, creates the socket and opens up command inputting
app.post('/api/v1/server/initiateConnection/:conInitUUID', (req, res) => {
	try {
		let conInitUUID = req.params.conInitUUID;
		global.Neptune.webLog.silly("POST: /api/v1/server/initiateConnection/" + conInitUUID + " .. body: " + JSON.stringify(req.body));
		if (conInitUUIDs[conInitUUID] !== undefined) {
			if (conInitUUIDs[conInitUUID].socketCreated !== false) {
				global.Neptune.webLog.warn("Attempt to use disabled conInitUUID! UUID: " + conInitUUID);
				res.status(403).send('{ "error": "Invalid conInitUUID" }');
				return;
			}
		} else {
			global.Neptune.webLog.silly("Attempt to use invalid conInitUUID: " + conInitUUID);
			res.status(401).send('{ "error": "Invalid conInitUUID" }');
			return;
		}

		/** @type {conInitObject} */
		let conInitObject = conInitUUIDs[conInitUUID];

		// Validate timestamp
		let timeNow = new Date();
		if (((timeNow - conInitObject.createdTime)/(60000)) >= 5) { // Older than 5 minutes
			conInitObject.log.warn("Attempt to use old conInitUUID! UUID: " + conInitUUID + " . createdAt: " + conInitObject.createdTime.toISOString());
			delete conInitUUIDs[conInitUUID];
			res.status(408).send('{ "error": "Request timeout for conInitUUID" }');
			return;
		}

		// Validate no other requests
		for (const [initUuid, initValue] of Object.entries(conInitUUIDs)) {
			if (initValue.clientId !== undefined) {
				if (initValue == req.body.clientId) {
					res.status(409).end(`{ "end": "Initiation request already in progress" }`);
					return;
				}
			}
		}


		// Generate shared secret
		let aliceSecret = conInitObject.aliceDHObject.computeSecret(Buffer.from(req.body.b1,'base64'));
		conInitObject.secret = NeptuneCrypto.HKDF(aliceSecret, "mySalt1234", {hashAlgorithm: conInitObject.selectedHashAlgorithm, keyLength: 32}).key;

		
		// Validate chkMsg
		var chkMsg = req.body.chkMsg;
		conInitObject.clientId = req.body.clientId;
		var client;
		try {
			if (NeptuneCrypto.isEncrypted(conInitObject.clientId))
				conInitObject.clientId = NeptuneCrypto.decrypt(conInitObject.clientId, conInitObject.secret);

			// Get/create client object
			client = global.Neptune.clientManager.getClient(conInitObject.clientId)
			client.clientId = conInitObject.clientId;
			conInitObject.client = client;

			if (client.pairKey !== undefined) {
				// Regenerate key using shared pair key
				conInitObject.log.debug("Using pairKey!");
				conInitObject.secret = NeptuneCrypto.HKDF(aliceSecret, client.pairKey, {hashAlgorithm: conInitObject.selectedHashAlgorithm, keyLength: 32}).key;
			}

			if (NeptuneCrypto.isEncrypted(chkMsg))
				chkMsg = NeptuneCrypto.decrypt(chkMsg, conInitObject.secret);
		} catch (err) {
			conInitObject.log.silly(err);
			if (err instanceof NeptuneCrypto.Errors.InvalidDecryptionKey || err instanceof NeptuneCrypto.Errors.MissingDecryptionKey) {
				// bad key.
				conInitObject.log.warn("Socket setup, bad key! UUID: " + conInitUUID);
				res.status(400).send(`{ "error": "Invalid encryption key" }`);
			} else {
				// Another error
				conInitObject.log.warn("Decryption of chkMsg failed, error: " + err.message);
				res.status(400).send(`{ "error": "Decryption of chkMsg failed" }`);
			}
			delete conInitUUIDs[conInitUUID];
			return;
		}
		
		let chkMsgHash = crypto.createHash(req.body.chkMsgHashFunction).update(chkMsg).digest('hex');
		chkMsgHash = req.body.chkMsgHash; // remove this later

		if (chkMsgHash !== req.body.chkMsgHash) {
			conInitObject.log.warn(`Invalid chkMsg hash! chkMsg: ${chkMsg} ... ourHash: ${chkMsgHash} ... clientHash: ${req.body.chkMsgHash}`);
			res.status(400).send(`{ "error": "Invalid chkMsgHash" }`);
			return;
		}


		try {
			if (typeof req.ip === "string" && req.ip !== "::1") {
				let ip = req.ip;
				if (ip.includes(":")) {
					ipArray = ip.split(":");
					ip = ipArray[ipArray.length-1];
				}
				client.IPAddress = new IPAddress(ip, "25560");
			}
			client.saveSync();
		} catch (e) {}



		// Create socket
		let socketUUID = crypto.randomUUID();
		socketUUIDs[socketUUID] = conInitUUID;
		conInitObject.socketCreated = true; // Done		

		// Setup connection manager (enables HTTP listener)
		client.setupConnectionManager(conInitObject.secret, {
			conInitUUID: conInitUUID,
			socketUUID: socketUUID,
			encryptionParameters: {
				cipherAlgorithm: conInitObject.selectedCipher,
				hashAlgorithm: conInitObject.selectedHashAlgorithm,
			}
		});
		

		// Create response
		let response = JSON.stringify({
			confMsg: crypto.createHash(req.body.chkMsgHashFunction).update(chkMsg + req.body.chkMsgHash).digest('hex'),
			socketUUID: socketUUID,
		});
		

		let encryptedResponse = NeptuneCrypto.encrypt(response, conInitObject.secret, undefined, {
			hashAlgorithm: conInitObject.selectedHashAlgorithm,
			cipherAlgorithm: conInitObject.selectedCipher
		});

		conInitObject.log.info(conInitUUID + " setup completed.");

		res.status(200).send(encryptedResponse);
	} catch (e) {
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).send("{}");
	}
});
app.post('/api/v1/server/initiateConnection/:conInitUUID/scrap', (req, res) => {
	try {
		let conInitUUID = req.params.conInitUUID;
		if (conInitUUIDs[conInitUUID] !== undefined) {
			global.Neptune.webLog.info("Scrapping initiation request for conInitUUID: " + conInitUUID.substr(0,48));
			if (conInitUUIDs[conInitUUID].client !== undefined)
				conInitUUIDs[conInitUUID].client.destroyConnectionManager();

			conInitUUIDs[conInitUUID].enabled = false;
			delete conInitUUIDs[conInitUUID];
			res.status(200).end("{}");
		} else
			res.status(404).end("{}");
	} catch (_) {}
});

app.post('/api/v1/server/socket/:socketUUID/http', (req, res) => {
	try {
		var sentResponse = false;
		let conInitUUID = req.body.conInitUUID;

		if (conInitUUIDs[conInitUUID] !== undefined) {
			if (conInitUUIDs[conInitUUID].enabled !== true) {
				global.Neptune.webLog.warn("Attempt to use disabled conInitUUID! UUID: " + conInitUUID);
				res.status(403).send('{ "error": "Invalid conInitUUID" }');
				return;
			}
		} else {
			res.status(401).send('{ "error": "Invalid conInitUUID" }');
			return;
		}

		conInitUUIDs[conInitUUID].client.processHTTPRequest(JSON.stringify(req.body), (data) => {
			conInitUUIDs[conInitUUID].log.silly(data);
			if (!sentResponse) {
				sentResponse = true;
				res.status(200).end(data);
			}
		});

		setTimeout(()=>{ // sends OK after 30 seconds (likely no response from server?)
			if (!sentResponse) {
				sentResponse = true;
				res.status(200).send("{}");
			}
		}, autoKillRequestTimeout);
	} catch (e) {
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).send("{}");
	}
});

SocketServer = new WebSocketServer({server: httpServer});
// Listen for socket connections
global.SocketServer.on('connection', (ws, req) => {
	try {
		if (req.url === undefined) {
			global.Neptune.webLog.error("New connection via WebSocket, no URL specified. Terminating.");
			ws.send("{ \"command\": \"/api/v1/client/disconnect\" }");
			ws.close();
			return;
		}
		let urlParts = req.url.split("/");
		if (urlParts.length != 6) {
			global.Neptune.webLog.error("New connection via WebSocket, URL (" + req.url + ") invalid. Terminating.");
			ws.send("{ \"command\": \"/api/v1/client/disconnect\" }");
			ws.close();
			return;
		}

		let socketUUID = urlParts[5].toLowerCase();
		let conInitUUID = socketUUIDs[socketUUID];
		if (conInitUUID === undefined || conInitUUIDs[conInitUUID] === undefined) {
			global.Neptune.webLog.error("New connection via WebSocket, invalid socket UUID (" + socketUUID +"). Terminating.");
			ws.send("{ \"command\": \"/api/v1/client/disconnect\" }");
			ws.close(1002, "InvalidSocketUUID"); // Tells client to reinitialize the connection
			return;
		}

		let conInitObject = conInitUUIDs[conInitUUID];
		let client = conInitObject.client;
		if (client === undefined) {
			global.Neptune.webLog.error("New connection via WebSocket, socket UUID (" + socketUUID +") valid, but unable to find the client to setup the socket with. Terminating.");
			ws.send("{ \"command\": \"/api/v1/client/disconnect\" }");
			ws.close(1002, "InvalidClient"); // Tells client to reinitialize the connection
			return;
		}

		global.Neptune.webLog.info("Client " + client.clientId + " connected to socket, socketUUID: " + socketUUID);
		client.setupConnectionManagerWebsocket(ws);
	} catch (e) {
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).send("{}");
	}
});



/**
 * 
 * Downloading / uploading 
 * 
 */
/**
 * @typedef {object} fileSharingObject
 * @property {string} fileUUID - File UUID
 * @property {string} fileName - File name for when the file is copied to the received folder
 * @property {boolean} enabled - Whether the file is able to be downloaded/uploaded. Once the fileUUID is used, this is flipped off and the object is deleted.
 * @property {string} createdTime - Time object was created (ISO) (file have a life time of 2 minutes)
 * @property {boolean} isUpload - File is being uploaded
 * @property {string} filePath - If upload, this is the directory the file is being saved to. If not an uploaded, this is the file we're serving.
 * @property {string} clientUUID - UUID of the client.
 * @property {string} clientName - Friendly name of the client.
 * @property {string} socketUUID - UUID of the socket client uses.
 * @property {string} authenticationCode - Random string of length 64 to represent a unique key only the client will know. Only used for download
 */


/**
 * Holds file sharing setup functions
 * @namespace
 */
global.Neptune.filesharing = {};

/**
 * A collection of file sharing ids. Key is the fileUUID
 * @type {Map<String, fileSharingObject>}
 */
let fileUUIDs = {};

/**
* Sanitizes a filename for Windows and Linux devices by removing illegal characters and reserved file names on Windows.
* If the filename is empty (excluding the file extension), sets it to "file".
*
* @param {string} filename - The filename to sanitize.
* @returns {string} The sanitized filename.
*/
function sanitizeFilename(filename) {
	if (filename === undefined || typeof filename !== "string") {
		return "new_file";
	}
	
	// Remove illegal characters
	const sanitizedFilename = filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, '');

	// Remove reserved file names for Windows
	const filenameWithoutExtension = path.parse(sanitizedFilename).name;
	const extension = path.parse(sanitizedFilename).ext;

	if (isWin) {
		const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
		if (filenameWithoutExtension.length > 0 && reservedNames.includes(filenameWithoutExtension.toUpperCase())) {
			return "file_" + sanitizedFilename;
		}
	}

	return filenameWithoutExtension.length > 0 ? sanitizedFilename : 'file' + extension;
}


/**
 * Used by the Client class to setup a file download
 * @function global.Neptune.filesharing.newClientDownload
 * @param {Client} client - Client that will be downloading this file.
 * @param {string} filepath - Path to the file being downloaded by the client.
 * @return {fileSharingObject} fileSharingObject
 */
global.Neptune.filesharing.newClientDownload = function(client, filepath) {
	if (client === undefined) {
		global.Neptune.log.error("newClientDownload: client is undefined!");
		throw new Error("client is undefined.");
	}
		

	if (!client.fileSharingSettings.enabled) { // don't check this, client does that: || !client.fileSharingSettings.allowClientToDownload)
		global.Neptune.log.error("newClientDownload: client has file sharing disabled (likely forgot to save the settings!)");
		throw new Error("file sharing disabled for " + client.friendlyName + ".\nBe sure to check \"Enable file sharing\" and save the settings.");
	}

	if (fs.existsSync(filepath) && fs.lstatSync(filepath).isFile()) {
		let fileUUID = crypto.randomUUID();

		/** @type {fileSharingObject} */
		let fileSharingObject = {
			fileUUID: fileUUID,
			enabled: true,
			createdTime: new Date().toISOString(),
			isUpload: false,
			filePath: filepath,
			clientUUID: client.clientId,
			clientName: client.friendlyName,
			authenticationCode: Buffer.from(NeptuneCrypto.randomString(64), "utf8").toString("base64"),
			socketUUID: client.getSocketUUID(),
		}

		fileUUIDs[fileUUID] = fileSharingObject;

		return fileSharingObject;
	} else {
		throw new Error("File does not exist.");
	}
}

/**
 * Used by the Client class to setup a file upload
 * @alias global.Neptune.filesharing.newClientUpload
 * @param {Client} client - Client that will be uploading this file.
 * @param {string} saveToDirectory - Path to save the uploaded file to.
 * @return {fileSharingObject} fileSharingObject
 */
global.Neptune.filesharing.newClientUpload = function(client, fileName, saveToDirectory) {
	if (client === undefined) {
		global.Neptune.log.error("newClientDownload: client is undefined!");
		throw new Error("client is undefined.");
	}
		

	if (!client.fileSharingSettings.enabled) {
		global.Neptune.log.error("newClientDownload: client has file sharing disabled (likely forgot to save the settings!)");
		throw new Error("file sharing disabled for " + client.friendlyName + ".\nBe sure to check \"Enable file sharing\" and save the settings.");
	}

	if (!client.fileSharingSettings.allowClientToUpload) {
		global.Neptune.log.error("newClientDownload: client is not allowed to upload files");
		throw new Error("receiving files for " + client.friendlyName + " is disabled.\nBe sure to check \"Enable file sharing\" and save the settings.");
	}

	try {
		if (!fs.existsSync(saveToDirectory))
			fs.mkdirSync(saveToDirectory)

		if (fs.existsSync(saveToDirectory) && fs.lstatSync(saveToDirectory).isDirectory()) {
			let fileUUID = crypto.randomUUID();

			// Sanitize file name (remove illegal characters + reserved file names on windows)
			fileName = sanitizeFilename(fileName);

			/** @type {fileSharingObject} */
			let fileSharingObject = {
				fileUUID: fileUUID,
				fileName: fileName,
				enabled: true,
				createdTime: new Date().toISOString(),
				isUpload: true,
				filePath: saveToDirectory,
				clientUUID: client.clientId,
				clientName: client.friendlyName,
				authenticationCode: Buffer.from(NeptuneCrypto.randomString(64), "utf8").toString("base64"),
				socketUUID: client.getSocketUUID(),
			}

			fileUUIDs[fileUUID] = fileSharingObject;
			return fileSharingObject;
		} else {
			throw new Error("Directory does not exist: " + saveToDirectory);
		}
	} catch (e) {
		global.Neptune.webLog.error(e, false);
	}
}

// Download/upload endpoint
app.post('/api/v1/server/socket/:socketUUID/filesharing/:fileUUID/download', (req, res) => {
	try {
		/** @type {string} */
		let fileUUID = req.params.fileUUID;
		let socketUUID = req.params.socketUUID;



		global.Neptune.webLog.silly("Download requested: /api/v1/server/socket/" + socketUUID + "/" + fileUUID);
		if (fileUUIDs[fileUUID] !== undefined) {
			if (fileUUIDs[fileUUID].enabled !== true) {
				global.Neptune.webLog.warn("Attempt to use disabled fileUUID! UUID: " + fileUUID);
				delete fileUUIDs[fileUUID];
				res.status(403).end('{ "error": "Invalid fileUUID" }');
				return;
			}
		} else {
			global.Neptune.webLog.silly("Attempt to use invalid fileUUID: " + fileUUID);
			res.status(401).end('{ "error": "Invalid fileUUID" }');
			return;
		}

		/** @type {fileSharingObject} */
		let fileSharingObject = fileUUIDs[fileUUID];

		// Validate timestamp
		let timeNow = new Date();
		if (((timeNow - fileSharingObject.createdTime)/(60000)) >= 5) { // Older than 5 minutes
			global.Neptune.webLog.warn("Attempt to use expired fileUUID! UUID: " + fileUUID + " . createdAt: " + fileSharingObject.createdTime.toISOString());
			delete fileUUID[fileUUID];
			res.status(408).end('{ "error": "Request timeout for fileUUID" }');
			return;
		}

		// Validate socketUUID
		if (fileSharingObject.socketUUID !== socketUUID) {
			global.Neptune.webLog.warn("File upload socketUUID mismatch! FileUUID: " + fileUUID + " .");
			delete fileUUID[fileUUID];
			res.status(408).send('{ "error": "Invalid socketUUID" }');
			deleteFiles();
			return;
		}


		// For download?
		if (fileSharingObject.isUpload) {
			global.Neptune.webLog.warn("Attempt to download using a upload fileUUID.");
			delete fileUUIDs[fileUUID];
			res.status(405).end('{ "error": "Attempt to upload using a download fileUUID." }');
			return;
		}


		// Check validation code
		if (req.body.authenticationCode !== fileSharingObject.authenticationCode) {
			global.Neptune.webLog.warn("Invalid authenticationCode used on fileUUID: " + fileUUID);
			res.status(401).end('{ "error": "Invalid authenticationCode" }');
			return;
		}




		let filePath = fileSharingObject.filePath;
		global.Neptune.webLog.info("Client " + fileSharingObject.clientUUID + " has downloaded " + filePath);

		fileUUIDs[fileUUID].enabled = false;
		delete fileUUIDs[fileUUID];

		// Serve the file
		let filename = filePath.replace(/^.*[\\\/]/, '');
		res.setHeader('Content-disposition', 'attachment; filename=' + filename);
		res.download(filePath);

		return;
	} catch (e) {
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).send("{}");
	}
});


// Upload
app.post('/api/v1/server/socket/:socketUUID/filesharing/:fileUUID/upload', upload.single('file'), (req, res) => {
	try {
		let fileUUID = req.params.fileUUID;
		let socketUUID = req.params.socketUUID;

		function deleteFiles() {
			if (fs.existsSync('./' + req.file.path)) {
				fs.unlink('./' + req.file.path, err => {
					global.Neptune.webLog.warn("Failed to delete blocked upload: " + req.file.filename)
				});
			}
		}


		global.Neptune.webLog.silly("Upload requested: /api/v1/server/socket/" + socketUUID + "/filesharing/" + fileUUID + "/upload");
		if (fileUUIDs[fileUUID] !== undefined) {
			if (fileUUIDs[fileUUID].enabled !== true) {
				global.Neptune.webLog.warn("Attempt to use disabled fileUUID! UUID: " + fileUUID);
				delete fileUUIDs[fileUUID];
				res.status(403).send('{ "error": "Invalid fileUUID" }');
				deleteFiles();
				return;
			}
		} else {
			global.Neptune.webLog.silly("Attempt to use invalid fileUUID: " + fileUUID);
			res.status(401).send('{ "error": "Invalid fileUUID" }');
			deleteFiles();
			return;
		}

		if (req.file == undefined) {
			// No file????
			global.Neptune.webLog.warn("No file included in upload! UUID: " + fileUUID);
			delete fileUUIDs[fileUUID];
			res.status(403).send('{ "error": "Missing file" }');
			deleteFiles();
			return;
		}

		/** @type {fileSharingObject} */
		let fileSharingObject = fileUUIDs[fileUUID];

		// Validate timestamp
		let timeNow = new Date();
		if (((timeNow - fileSharingObject.createdTime)/(60000)) >= 5) { // Older than 5 minutes
			global.Neptune.webLog.warn("Attempt to use expired fileUUID! UUID: " + fileUUID + " . createdAt: " + fileSharingObject.createdTime.toISOString());
			delete fileUUID[fileUUID];
			res.status(408).send('{ "error": "Request timeout for fileUUID" }');
			deleteFiles();
			return;
		}

		// Validate socketUUID
		if (fileSharingObject.socketUUID !== socketUUID) {
			global.Neptune.webLog.warn("File upload socketUUID mismatch! FileUUID: " + fileUUID + " .");
			delete fileUUID[fileUUID];
			res.status(408).send('{ "error": "Invalid socketUUID" }');
			deleteFiles();
			return;
		}

		// For upload?
		if (!fileSharingObject.isUpload) {
			global.Neptune.webLog.warn("Attempt to upload using a download fileUUID.");
			delete fileUUIDs[fileUUID];
			res.status(405).send('{ "error": "Attempt to upload using a download fileUUID." }');
			deleteFiles();
			return;
		}

		let client = global.Neptune.clientManager.getClient(fileSharingObject.clientUUID);
		let fileName = (fileSharingObject.fileName !== undefined? fileSharingObject.fileName : fileUUID);
		let acceptedFunction = function() {

			if (client.fileSharingSettings.notifyOnClientUpload) {
				let actionButtonContents = "Show me in folder";
				let notification = new Notification({
					clientId: "Neptune",
					friendlyName: "MainWindow",
				}, {
					action: 'create',
					applicationPackage: 'com.global.Neptune.server',
					applicationName: 'Neptune Server',
					notificationId: 'fileReceivedNotification-' + fileUUID,
					title: 'Received file from ' + (fileSharingObject.clientName != undefined? fileSharingObject.clientName : 'a client') + '.',
					type: 'standard',

					contents: {
						text: 'Received a file: ' + fileName,
						subtext: "File received",
						// actions: [
						// 	{
						// 		"id": "showme",
						// 		"type": "button",
						// 		"contents": actionButtonContents
						// 	},
						// ]
					},

					onlyAlertOnce: true,
					priority: "default",
					isSilent: false,
				});
				notification.push();
				notification.on('activate', (data) => {
					try {
						let button = "";
						if (data.actionParameters !== undefined) {
							if (data.actionParameters.id !== undefined)
								button = Buffer.from(data.actionParameters.id, "base64").toString("utf8");
						}

						if (button != undefined)
							button = button.toLowerCase();

						console.log(button);

						if (button == actionButtonContents.toLowerCase()) {
							// Opens the file browser and selects the received file
							let absolutePath = path.resolve(__dirname, "..", fileSharingObject.filePath, fileName);
							if (process.platform === 'win32') {
								const explorerPath = path.join(process.env.SystemRoot, 'explorer.exe');
								exec(`"${explorerPath}" /select, "${absolutePath}"`, (error, stdout, stderr) => {});
							} else if (process.platform === 'darwin') {
								exec(`open -R "${absolutePath}"`, (error, stdout, stderr) => {});
							} else if (process.platform === 'linux' && process.env.DESKTOP_SESSION === 'gnome') {
								exec(`gnome-open "${absolutePath}"`, (error, stdout, stderr) => {});
							}
						}
					} catch (e) {
						global.Neptune.webLog.error("Failed to open file browser to select received file. See log for details.");
						global.Neptune.webLog.error(e, false);
					}
				});
			}

			// Process
			let targetPath = path.resolve(fileSharingObject.filePath + "/" + fileName);
			global.Neptune.webLog.info("Received file \"" + fileName + "\" from client " + fileSharingObject.clientUUID);
			global.Neptune.webLog.info("Received files directory: " + fileSharingObject.filePath, false);
			if (fs.existsSync(targetPath)) {
				// Target file already exists, generate a unique file name
				let counter = 1;
				let newTargetPath = fileName.replace(/\.[^.]+$/, '') + ' (' + counter + ')' + path.extname(fileName);

				while (fs.existsSync(newTargetPath)) {
					// Increment the counter until a unique file name is found
					counter++;
					newTargetPath = fileName.replace(/\.[^.]+$/, '') + ' (' + counter + ')' + path.extname(fileName);
				}

				global.Neptune.webLog.debug("Saving received file (conflict) to: " + path.resolve(fileSharingObject.filePath + "/" + newTargetPath))

				// Rename the file to the new unique name
				fs.renameSync(req.file.path, path.resolve(fileSharingObject.filePath + "/" + newTargetPath));

			} else {
				global.Neptune.webLog.debug("Saving received file to: " + targetPath)
				fs.renameSync(req.file.path, targetPath);
			}

			res.status(200).end("{ \"status\": \"success\", \"approved\": true }");
		}


		if (client.fileSharingSettings.requireConfirmationOnClinetUploads) {
			let requestPermissionNotification = new Notification({
				clientId: "Neptune",
				friendlyName: "MainWindow",
			}, {
				action: 'create',
				applicationPackage: 'com.global.Neptune.server',
				applicationName: 'Neptune Server',
				notificationId: 'fileRequestNotification-' + fileUUID,
				title: 'Accept incoming file?',
				type: 'standard',

				contents: {
					text: 'New file request from: ' + fileSharingObject.clientName + '\r\n' + 'Accept file? Name: ' + fileName,
					subtext: "File received",
					actions: [
						{
							"id": "deny",
							"type": "button",
							"contents": "Deny"
						},
						{
							"id": "accept",
							"type": "button",
							"contents": "Accept"
						}
					]
				},

				onlyAlertOnce: true,
				priority: "default",
				isSilent: false,
			});
			requestPermissionNotification.push();

			let alreadyProcessedPleaseDoNotRaceMe = false; // we love race conditions
			requestPermissionNotification.on('activate', (data) => {
				try {
					let button = "";
					if (data.actionParameters !== undefined) {
						if (data.actionParameters.id !== undefined)
							button = Buffer.from(data.actionParameters.id, "base64").toString("utf8");
					}

					if (button != undefined)
						button = button.toLowerCase();

					console.log(button);

					if (button === "accept") {
						alreadyProcessedPleaseDoNotRaceMe = true;
						acceptedFunction();
					} else {
						if (fs.existsSync(req.file.path))
							fs.unlinkSync(req.file.path)

						alreadyProcessedPleaseDoNotRaceMe = true;
						res.status(418).end("{ \"status\": \"rejected by user\", \"approved\": false }");
					}
				} catch (e) {
					global.Neptune.webLog.error("Failed to process accept/deny notification for received file. See log for details.");
					global.Neptune.webLog.error(e, false);
					try {
						if (req.file.path !== undefined) {
							if (fs.existsSync(req.file.path))
								fs.unlinkSync(req.file.path)
		
							res.status(418).end("{ \"status\": \"unable to request approval\", \"approved\": false }");
						}
					} catch (_) {}
				}
			});

			setTimeout(() => {
				if (requestPermissionNotification != undefined) {
					requestPermissionNotification.delete();
				}

				if (alreadyProcessedPleaseDoNotRaceMe)
					return;

				if (req.file.path !== undefined) {
					if (fs.existsSync(req.file.path))
						fs.unlinkSync(req.file.path)

					res.status(418).end("{ \"status\": \"timed out\", \"approved\": false }");
				}
			}, 30000); // 30 second timeout
		} else {
			acceptedFunction();
		}
	} catch (e) {
		global.Neptune.webLog.error("Error receiving file from client. See log for details.");
		global.Neptune.webLog.error(e, false);

		if (res != undefined)
			res.status(500).end("{ \"status\": \"error receiving file\", \"approved\": false }");
	}
});



module.exports = { 
	app: app,
	httpServer: httpServer,
};