From 0592564264fff57ccfd9677957196951f9f1c6cf Mon Sep 17 00:00:00 2001 From: RblSb Date: Sun, 26 Jan 2025 23:22:33 +0300 Subject: Video upload feature And you can play video as soon as upload starts! This is pretty useful for thicc ones. Video will be keeped in cache and will comply cache size limit. I'm also implemented system storage check to change cache limit if it's lower than config value, so upload will be blocked if there is lower than 10MiB available on disk. --- src/server/HttpServer.hx | 216 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 175 insertions(+), 41 deletions(-) (limited to 'src/server/HttpServer.hx') diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index 7622708..6602e86 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -1,8 +1,10 @@ package server; +import Types.UploadResponse; +import haxe.Json; import haxe.io.Path; import js.node.Buffer; -import js.node.Fs; +import js.node.Fs.Fs; import js.node.Http; import js.node.Https; import js.node.Path as JsPath; @@ -12,6 +14,14 @@ import js.node.http.ServerResponse; import js.node.url.URL; import sys.FileSystem; +@:structInit +private class HttpServerConfig { + public final dir:String; + public final customDir:String = null; + public final allowLocalRequests = false; + public final cache:Cache = null; +} + class HttpServer { static final mimeTypes = [ "html" => "text/html", @@ -36,31 +46,49 @@ class HttpServer { "wasm" => "application/wasm" ]; - static var dir:String; - static var customDir:String; - static var hasCustomRes = false; - static var allowedLocalFiles:Map = []; - static var allowLocalRequests = false; - static final CHUNK_SIZE = 1024 * 1024 * 5; // 5 MB - - public static function init(dir:String, ?customDir:String, allowLocalRequests:Bool):Void { - HttpServer.dir = dir; - if (customDir == null) return; - HttpServer.customDir = customDir; - hasCustomRes = FileSystem.exists(customDir); - HttpServer.allowLocalRequests = allowLocalRequests; + final main:Main; + final dir:String; + final customDir:String; + final hasCustomRes = false; + final allowedLocalFiles:Map = []; + final allowLocalRequests = false; + final cache:Cache = null; + final CHUNK_SIZE = 1024 * 1024 * 5; // 5 MB + final uploadingFiles:Map = []; + final uploadingFilesLastChunks:Map = []; + + public function new(main:Main, config:HttpServerConfig):Void { + this.main = main; + dir = config.dir; + customDir = config.customDir; + allowLocalRequests = config.allowLocalRequests; + cache = config.cache; + + if (customDir != null) hasCustomRes = FileSystem.exists(customDir); } - public static function serveFiles(req:IncomingMessage, res:ServerResponse):Void { + public function serveFiles(req:IncomingMessage, res:ServerResponse):Void { final url = try { new URL(safeDecodeURI(req.url), "http://localhost"); - } catch (e) new URL("/", "http://localhost"); + } catch (e) { + new URL("/", "http://localhost"); + } var filePath = getPath(dir, url); final ext = Path.extension(filePath).toLowerCase(); res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Content-Type", getMimeType(ext)); + if (cache != null && req.method == "POST") { + switch url.pathname { + case "/upload-last-chunk": + uploadFileLastChunk(req, res); + case "/upload": + uploadFile(req, res); + } + return; + } + if (allowLocalRequests && req.socket.remoteAddress == req.socket.localAddress || allowedLocalFiles[url.pathname]) { if (isMediaExtension(ext)) { @@ -103,14 +131,104 @@ class HttpServer { }); } - static function getPath(dir:String, url:URL):String { + function uploadFileLastChunk(req:IncomingMessage, res:ServerResponse) { + final name = cache.getFreeFileName(req.headers["content-name"]); + final filePath = cache.getFilePath(name); + final body:Array = []; + req.on("data", chunk -> body.push(chunk)); + req.on("end", () -> { + final buffer = Buffer.concat(body); + uploadingFilesLastChunks[filePath] = buffer; + res.writeHead(200, { + 'Content-Type': 'application/json', + }); + final json:UploadResponse = { + info: "File last chunk uploaded" + } + res.end(Json.stringify(json)); + }); + } + + function uploadFile(req:IncomingMessage, res:ServerResponse) { + final name = cache.getFreeFileName(req.headers["content-name"]); + final clientName = req.headers["client-name"]; + final filePath = cache.getFilePath(name); + final size = Std.parseInt(req.headers["content-length"]) ?? return; + var written = 0; + + inline function end(json:UploadResponse):Void { + uploadingFiles.remove(name); + uploadingFilesLastChunks.remove(name); + + res.statusCode = 200; + res.end(Json.stringify(json)); + } + + if (cache.getFreeSpace() < size) { + end({ + info: "Error: Not enough free space on server or file size is out of cache storage limit.", + errorId: "freeSpace" + }); + return; + } + + final stream = Fs.createWriteStream(filePath); + req.pipe(stream); + + inline function onStart() { + cache.removeOlderCache(size); + cache.add(name); + uploadingFiles[filePath] = size; + } + var isStart = true; + req.on("data", chunk -> { + var url:String = null; + if (isStart) { + isStart = false; + onStart(); + url = cache.getFileUrl(name); + } + written += chunk.length; + final ratio = (written / size).clamp(0, 1); + final percent = (ratio * 100).toFixed(2); + final client = main.clients.getByName(clientName) ?? return; + main.send(client, { + type: Progress, + progress: { + type: Uploading, + ratio: ratio, + data: url + } + }); + }); + stream.on("close", () -> { + end({ + info: "File write stream closed.", + }); + }); + stream.on("error", err -> { + trace(err); + end({ + info: "File write stream error.", + }); + }); + req.on("error", err -> { + trace("Request Error:", err); + stream.destroy(); + end({ + info: "File request error.", + }); + }); + } + + function getPath(dir:String, url:URL):String { var filePath = dir + url.pathname; filePath = filePath.urlDecode(); if (!FileSystem.isDirectory(filePath)) return filePath; return Path.addTrailingSlash(filePath) + "index.html"; } - static function readFileError(err:Dynamic, res:ServerResponse, filePath:String):Void { + function readFileError(err:Dynamic, res:ServerResponse, filePath:String):Void { res.setHeader("Content-Type", getMimeType("html")); if (err.code == "ENOENT") { res.statusCode = 404; @@ -122,9 +240,13 @@ class HttpServer { } } - static function serveMedia(req:IncomingMessage, res:ServerResponse, filePath:String):Bool { + function serveMedia(req:IncomingMessage, res:ServerResponse, filePath:String):Bool { if (!Fs.existsSync(filePath)) return false; - final videoSize = Fs.statSync(filePath).size; + var videoSize:Int = cast Fs.statSync(filePath).size; + // use future content length to start playing it before uploaded + if (uploadingFiles.exists(filePath)) { + videoSize = uploadingFiles[filePath]; + } final rangeHeader:String = req.headers["range"]; if (rangeHeader == null) { res.statusCode = 200; @@ -142,23 +264,33 @@ class HttpServer { res.setHeader("Content-Range", 'bytes $start-$end/$videoSize'); res.setHeader("Content-Length", '$contentLength'); - // HTTP Status 206 for Partial Content - res.statusCode = 206; - // create video read stream for this particular chunk - final videoStream = Fs.createReadStream(filePath, {start: cast start, end: cast end}); + res.statusCode = 206; // partial content + + // check for last chunk cache for instant play while uploading + final buffer = uploadingFilesLastChunks[filePath]; + if (buffer != null && end == videoSize - 1 && contentLength < buffer.byteLength) { + final bufferStart = (buffer.byteLength - contentLength).limitMin(0); + res.end(buffer.slice(bufferStart)); + return true; + } + // stream the video chunk to the client + final videoStream = Fs.createReadStream( + filePath, + {start: start, end: end} + ); videoStream.pipe(res); res.on("error", () -> videoStream.destroy()); res.on("close", () -> videoStream.destroy()); return true; } - static function parseRangeHeader(rangeHeader:String, videoSize:Float):{start:Float, end:Float} { + function parseRangeHeader(rangeHeader:String, videoSize:Int):{start:Int, end:Int} { final ranges = ~/[-=]/g.split(rangeHeader); - var start = Std.parseFloat(ranges[1]); + var start = Std.parseInt(ranges[1]); if (Utils.isOutOfRange(start, 0, videoSize - 1)) start = 0; - var end = Std.parseFloat(ranges[2]); - if (Math.isNaN(end)) end = start + CHUNK_SIZE; + var end = Std.parseInt(ranges[2]); + if (end == null) end = start + CHUNK_SIZE; if (Utils.isOutOfRange(end, start, videoSize - 1)) end = videoSize - 1; return { start: start, @@ -166,14 +298,14 @@ class HttpServer { }; } - static function isMediaExtension(ext:String):Bool { + function isMediaExtension(ext:String):Bool { return ext == "mp4" || ext == "webm" || ext == "mp3" || ext == "wav"; } - static final matchLang = ~/^[A-z]+/; - static final matchVarString = ~/\${([A-z_]+)}/g; + final matchLang = ~/^[A-z]+/; + final matchVarString = ~/\${([A-z_]+)}/g; - static function localizeHtml(data:String, lang:String):String { + function localizeHtml(data:String, lang:String):String { if (lang != null && matchLang.match(lang)) { lang = matchLang.matched(0); } else lang = "en"; @@ -184,7 +316,7 @@ class HttpServer { return data; } - static function proxyUrl(req:IncomingMessage, res:ServerResponse):Bool { + function proxyUrl(req:IncomingMessage, res:ServerResponse):Bool { final url = req.url.replace("/proxy?url=", ""); final proxy = proxyRequest(url, req, res, proxyRes -> { final url = proxyRes.headers["location"] ?? return false; @@ -201,7 +333,7 @@ class HttpServer { return true; } - static function proxyRequest( + function proxyRequest( url:String, req:IncomingMessage, res:ServerResponse, @@ -209,7 +341,9 @@ class HttpServer { ):Null { final url = try { new URL(safeDecodeURI(url)); - } catch (e) return null; + } catch (e) { + return null; + } if (url.host == req.headers["host"]) return null; final options = { host: url.hostname, @@ -222,7 +356,7 @@ class HttpServer { final request = url.protocol == "https:" ? Https.request : Http.request; final proxy = request(options, proxyRes -> { if (cancelProxyRequest(proxyRes)) return; - proxyRes.headers["Content-Type"] = "application/octet-stream"; + proxyRes.headers["content-type"] = "application/octet-stream"; res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); @@ -232,18 +366,18 @@ class HttpServer { return proxy; } - static function isChildOf(parent:String, child:String):Bool { + function isChildOf(parent:String, child:String):Bool { final rel = JsPath.relative(parent, child); return rel.length > 0 && !rel.startsWith('..') && !JsPath.isAbsolute(rel); } - static function getMimeType(ext:String):String { + function getMimeType(ext:String):String { return mimeTypes[ext] ?? return "application/octet-stream"; } - static final ctrlCharacters = ~/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/g; + final ctrlCharacters = ~/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/g; - static function safeDecodeURI(data:String):String { + function safeDecodeURI(data:String):String { try { data = decodeURI(data); } catch (err) { @@ -253,7 +387,7 @@ class HttpServer { return data; } - static inline function decodeURI(data:String):String { + inline function decodeURI(data:String):String { return js.Syntax.code("decodeURI({0})", data); } } -- cgit v1.2.3