From 04eb44d685ba677a444b73c7cfc2427d2740497f Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Thu, 2 Jul 2026 22:17:17 -0700 Subject: implement better leader, queue, and upload management - Majority vote skip management (Admins and the original video queuer overrides this behavior) - Leader request now requires the current leader to give up the role (via `/giveup `) - Overhauled default CSS and improved mobile view - Caching and uploading files is limited to Admin users only Signed-off-by: Pinapelz --- README.md | 16 ++-- build/server.js | 191 ++++++++++++++++++++++++++++++++++++++------- res/client.js | 35 +++++++-- src/client/Buttons.hx | 6 +- src/client/FileUploader.hx | 6 ++ src/client/Main.hx | 20 ++++- src/client/Player.hx | 4 +- src/server/HttpServer.hx | 21 +++++ src/server/Main.hx | 113 +++++++++++++++++++++++++-- 9 files changed, 359 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 49f9b6c..33fe47c 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,18 @@ Lightweight modern implementation and a very easy way to run locally. - External `vtt`/`srt`/`ass` subtitles support - External audiotrack / voiceover support - `/30`, `/-21`, etc chat commands to rewind video playback by seconds -- Hotkeys (`Alt-P` for global play/pause, [etc](https://github.com/RblSb/SyncTube/blob/382f9b2ebedca905028341825350a0fa69d88673/src/client/Buttons.hx#L416-L427)) +- Hotkeys (`Alt-P` for global play/pause, [etc](https://github.com/pinapelz/SyncTube-plus-plus/blob/382f9b2ebedca905028341825350a0fa69d88673/src/client/Buttons.hx#L416-L427)) - Playback rate synchronization (with leader) - Links mask: `foo.com/bar${1-4}.mp4` to add multiple items - Override every front-end file you want (`user/res` folder) -## PlusPlus Features +## PlusPlus Features + Changes - Admin registration via configuring the `adminToken` config key and then registering on the `/admin-register` page - `gatePassword` config option to prevent unwanted users from joining -- Overhauled CSS and mobile view - - -### Easier playback controls for smaller groups -- Enable `requestLeaderOnPause` to allow global pause by any user, without `Leader` button -- Enable `unpauseWithoutLeader` to allow global unpause for non-leaders +- Majority vote skip management (Admins and the original video queuer overrides this behavior) +- Leader request now requires the current leader to give up the role (via `/giveup `) +- Overhauled default CSS and improved mobile view +- Caching and uploading files is limited to Admin users only ## Supported players - Youtube (videos, shorts, streams and playlists) @@ -54,7 +52,7 @@ or ## "Cache On Server" -"Cache on Server" is a feature that enables a user to upload videos to the server and serve it to all other users temporarily. The video is deleted after it has been removed from the queue. +"Cache on Server" is a feature that enables a user to upload videos to the server and serve it to all other users temporarily. The video is deleted after it has been removed from the queue. This feature is limited to admin users only. This feature requires `ffmpeg` on your server system, it's only used to build single mp4 from downloaded audio/video tracks. Default cache size is 3.0 GiB. diff --git a/build/server.js b/build/server.js index c2b42ef..5f68d3c 100644 --- a/build/server.js +++ b/build/server.js @@ -2854,6 +2854,14 @@ haxe_crypto_Sha256.prototype = { } ,__class__: haxe_crypto_Sha256 }; +var haxe_ds_IntMap = function() { + this.h = { }; +}; +haxe_ds_IntMap.__name__ = true; +haxe_ds_IntMap.__interfaces__ = [haxe_IMap]; +haxe_ds_IntMap.prototype = { + __class__: haxe_ds_IntMap +}; var haxe_ds_StringMap = function() { this.h = Object.create(null); }; @@ -4085,8 +4093,26 @@ server_HttpServer.prototype = { res.end(data); }); } + ,hasUploadAccess: function(req) { + var uuid = req.headers["x-client-uuid"]; + if(uuid == null || uuid.length == 0) { + return false; + } + var client = Lambda.find(this.main.clients,function(client) { + return client.uuid == uuid; + }); + if(client != null) { + return (client.group & 8) != 0; + } else { + return false; + } + } ,uploadFileLastChunk: function(req,res) { var _gthis = this; + if(!this.hasUploadAccess(req)) { + tools_HttpServerTools.json(tools_HttpServerTools.status(res,403),{ info : "Only admins may upload files.", errorId : "accessError"}); + return; + } var fileName; try { fileName = decodeURIComponent(req.headers["content-name"]); @@ -4111,6 +4137,10 @@ server_HttpServer.prototype = { } ,uploadFile: function(req,res) { var _gthis = this; + if(!this.hasUploadAccess(req)) { + tools_HttpServerTools.json(tools_HttpServerTools.status(res,403),{ info : "Only admins may upload files.", errorId : "accessError"}); + return; + } var fileName; try { fileName = decodeURIComponent(req.headers["content-name"]); @@ -4165,7 +4195,7 @@ server_HttpServer.prototype = { } }); stream.on("error",function(err) { - haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 256, className : "server.HttpServer", methodName : "uploadFile"}); + haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 277, className : "server.HttpServer", methodName : "uploadFile"}); tools_HttpServerTools.json(tools_HttpServerTools.status(res,500),{ info : "File write stream error."}); var _this = _gthis.uploadingFilesSizes; if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { @@ -4178,7 +4208,7 @@ server_HttpServer.prototype = { _gthis.cache.remove(name); }); req.on("error",function(err) { - haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 263, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); + haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 284, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); stream.destroy(); tools_HttpServerTools.json(tools_HttpServerTools.status(res,500),{ info : "File request error."}); var _this = _gthis.uploadingFilesSizes; @@ -4584,6 +4614,7 @@ var server_Main = function(opts) { this.loadedClientsCount = 0; this.matchGuestName = new EReg("guest [0-9]+",""); this.matchHtmlChars = new EReg("[&^<>'\"]",""); + this.skipVotes = new haxe_ds_IntMap(); this.isServerPause = false; this.flashbacks = []; this.messages = []; @@ -4656,7 +4687,7 @@ var server_Main = function(opts) { preparePort = function() { server_Utils.isPortFree(_gthis.port,function(isFree) { if(!isFree && attempts > 0) { - haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 157, className : "server.Main", methodName : "new"}); + haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 159, className : "server.Main", methodName : "new"}); attempts -= 1; _gthis.port++; preparePort(); @@ -4707,16 +4738,16 @@ server_Main.prototype = { var protocol = ssl == null ? "http" : "https"; var port = this.getPort(protocol); var colonPort = this.isDefaultPort() ? "" : ":" + port; - haxe_Log.trace("Local: " + protocol + "://" + this.localIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 187, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Local: " + protocol + "://" + this.localIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 189, className : "server.Main", methodName : "runServer"}); if(this.config.localNetworkOnly) { - haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 189, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 191, className : "server.Main", methodName : "runServer"}); } else if(!this.isNoState) { server_Utils.getGlobalIp(function(ip) { if(ip.indexOf(":") != -1) { ip = "[" + ip + "]"; } _gthis.globalIp = ip; - haxe_Log.trace("Global: " + protocol + "://" + _gthis.globalIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 195, className : "server.Main", methodName : "runServer"}); + haxe_Log.trace("Global: " + protocol + "://" + _gthis.globalIp + colonPort,{ fileName : "src/server/Main.hx", lineNumber : 197, className : "server.Main", methodName : "runServer"}); }); } var dir = "" + this.rootDir + "/res"; @@ -4737,7 +4768,7 @@ server_Main.prototype = { redirectHttpServer.listen(80); } catch( _g ) { var _g1 = haxe_Exception.caught(_g); - haxe_Log.trace("Cannot run http server on port 80 for https redirects:",{ fileName : "src/server/Main.hx", lineNumber : 221, className : "server.Main", methodName : "runServer", customParams : [_g1]}); + haxe_Log.trace("Cannot run http server on port 80 for https redirects:",{ fileName : "src/server/Main.hx", lineNumber : 223, className : "server.Main", methodName : "runServer", customParams : [_g1]}); } } server = js_node_Https.createServer({ key : ssl.key, cert : ssl.cert},$bind(httpServer,httpServer.serveFiles)); @@ -4774,10 +4805,10 @@ server_Main.prototype = { return { key : key, cert : cert}; } if(!sys_FileSystem.exists(config.sslKeyPemPath)) { - haxe_Log.trace("sslKeyPemPath: absolute file path not found: " + config.sslKeyPemPath,{ fileName : "src/server/Main.hx", lineNumber : 259, className : "server.Main", methodName : "getSslConfig"}); + haxe_Log.trace("sslKeyPemPath: absolute file path not found: " + config.sslKeyPemPath,{ fileName : "src/server/Main.hx", lineNumber : 261, className : "server.Main", methodName : "getSslConfig"}); } if(!sys_FileSystem.exists(config.sslCertPemPath)) { - haxe_Log.trace("sslCertPemPath: absolute file path not found: " + config.sslCertPemPath,{ fileName : "src/server/Main.hx", lineNumber : 261, className : "server.Main", methodName : "getSslConfig"}); + haxe_Log.trace("sslCertPemPath: absolute file path not found: " + config.sslCertPemPath,{ fileName : "src/server/Main.hx", lineNumber : 263, className : "server.Main", methodName : "getSslConfig"}); } return null; } @@ -4834,7 +4865,7 @@ server_Main.prototype = { var field = _g1[_g]; ++_g; if(Reflect.field(config,field) == null) { - haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 306, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 308, className : "server.Main", methodName : "getUserConfig"}); } config[field] = Reflect.field(customConfig,field); } @@ -4845,14 +4876,14 @@ server_Main.prototype = { var emote = _g1[_g]; ++_g; if(emoteCopies_h[emote.name]) { - haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 312, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 314, className : "server.Main", methodName : "getUserConfig"}); } emoteCopies_h[emote.name] = true; if(!this.verbose) { continue; } if(emoteCopies_h[emote.image]) { - haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 316, className : "server.Main", methodName : "getUserConfig"}); + haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 318, className : "server.Main", methodName : "getUserConfig"}); } emoteCopies_h[emote.image] = true; } @@ -4889,7 +4920,7 @@ server_Main.prototype = { js_node_Fs.writeFileSync("" + this.userDir + "/users.json",JSON.stringify({ admins : users1, bans : _g, salt : users.salt},null,"\t")); } ,saveState: function() { - haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 354, className : "server.Main", methodName : "saveState"}); + haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 356, className : "server.Main", methodName : "saveState"}); var json = JSON.stringify(this.getCurrentState(),null,"\t"); js_node_Fs.writeFileSync(this.statePath,json); this.writeUsers(this.userList); @@ -4904,7 +4935,7 @@ server_Main.prototype = { if(!sys_FileSystem.exists(this.statePath)) { return; } - haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 378, className : "server.Main", methodName : "loadState"}); + haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 380, className : "server.Main", methodName : "loadState"}); var state = JSON.parse(js_node_Fs.readFileSync(this.statePath,{ encoding : "utf8"})); state.flashbacks = state.flashbacks != null ? state.flashbacks : []; state.cachedFiles = state.cachedFiles != null ? state.cachedFiles : []; @@ -4926,7 +4957,7 @@ server_Main.prototype = { } ,logError: function(type,data) { this.cache.removeOlderCache(1048576); - haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 402, className : "server.Main", methodName : "logError", customParams : [data]}); + haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 404, className : "server.Main", methodName : "logError", customParams : [data]}); var crashesFolder = "" + this.userDir + "/crashes"; server_Utils.ensureDir(crashesFolder); var name = DateTools.format(new Date(),"%Y-%m-%d_%H_%M_%S") + "-" + type; @@ -4948,7 +4979,7 @@ server_Main.prototype = { if(_gthis.clients.length == 0) { return; } - haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 415, className : "server.Main", methodName : "initIntergationHandlers"}); + haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 417, className : "server.Main", methodName : "initIntergationHandlers"}); js_node_Http.get(url,null,function(r) { }); }; @@ -4963,17 +4994,27 @@ server_Main.prototype = { } return req.socket.remoteAddress; } + ,isLoopbackIp: function(ip) { + if(ip == null) { + return false; + } + if(!(ip == "127.0.0.1" || ip == "::1")) { + return ip == "::ffff:127.0.0.1"; + } else { + return true; + } + } ,addAdmin: function(name,password) { password += this.config.salt; var hash = haxe_crypto_Sha256.encode(password); this.userList.admins.push({ name : name, hash : hash}); - haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 436, className : "server.Main", methodName : "addAdmin"}); + haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 443, className : "server.Main", methodName : "addAdmin"}); } ,removeAdmin: function(name) { HxOverrides.remove(this.userList.admins,Lambda.find(this.userList.admins,function(item) { return item.name == name; })); - haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 443, className : "server.Main", methodName : "removeAdmin"}); + haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 450, className : "server.Main", methodName : "removeAdmin"}); } ,replayLog: function(events) { var _gthis = this; @@ -5040,8 +5081,9 @@ server_Main.prototype = { var ip = this.clientIp(req); var id = this.freeIds.length > 0 ? this.freeIds.shift() : this.clients.length; var name = "Guest " + (id + 1); - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 506, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]}); - var isAdmin = this.config.localAdmins && req.socket.localAddress == ip; + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 513, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]}); + var remoteIp = req.socket.remoteAddress; + var isAdmin = this.config.localAdmins && this.isLoopbackIp(remoteIp); var client = new Client(ws,req,id,name,0); client.uuid = uuid; client.setGroupFlag(ClientGroup.Admin,isAdmin); @@ -5054,7 +5096,7 @@ server_Main.prototype = { var obj = _gthis.wsEventParser.fromJson(data.toString()); if(_gthis.wsEventParser.errors.length > 0 || _gthis.noTypeObj(obj)) { var errors = "" + ("Wrong request for type \"" + obj.type + "\":") + "\n" + json2object_ErrorUtils.convertErrorArray(_gthis.wsEventParser.errors); - haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 523, className : "server.Main", methodName : "onConnect"}); + haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 531, className : "server.Main", methodName : "onConnect"}); _gthis.serverMessage(client,errors); return; } @@ -5121,6 +5163,10 @@ server_Main.prototype = { this.serverMessage(client,"videoAlreadyExistsError"); return; } + if(item.doCache && (client.group & 8) == 0) { + item.doCache = false; + this.serverMessage(client,"Only admins may cache videos."); + } if(!item.doCache) { data.addVideo.item = item; _gthis.videoList.addItem(item,data.addVideo.atEnd); @@ -5211,6 +5257,7 @@ server_Main.prototype = { if(this.isPlaylistLockedFor(client)) { return; } + this.resetSkipVote(); if(!this.checkPermission(client,"removeVideo")) { return; } @@ -5249,14 +5296,14 @@ server_Main.prototype = { if((client.group & 8) == 0) { return; } - haxe_Log.trace("Crashing...",{ fileName : "src/server/Main.hx", lineNumber : 1043, className : "server.Main", methodName : "onMessage"}); + haxe_Log.trace("Crashing...",{ fileName : "src/server/Main.hx", lineNumber : 1073, className : "server.Main", methodName : "onMessage"}); null[1]++; break; case "Disconnected": if(!internal) { return; } - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 588, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 596, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]}); server_Utils.sortedPush(this.freeIds,client.id); HxOverrides.remove(this.clients,client); this.sendClientList(); @@ -5397,7 +5444,7 @@ server_Main.prototype = { this.send(client,{ type : "LoginError"}); return; } - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 679, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 687, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]}); client.name = name; client.setGroupFlag(ClientGroup.User,true); this.checkBan(client); @@ -5410,7 +5457,7 @@ server_Main.prototype = { var oldName = client.name; client.name = "Guest " + (this.clients.indexOf(client) + 1); client.setGroupFlag(ClientGroup.User,false); - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 700, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]}); + haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 708, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]}); this.send(client,{ type : data.type, logout : { oldClientName : oldName, clientName : client.name, clients : this.clientList()}}); this.sendClientListExcept(client); break; @@ -5472,6 +5519,7 @@ server_Main.prototype = { if(!this.checkPermission(client,"changeOrder")) { return; } + this.resetSkipVote(); var pos = data.playItem.pos; if(!this.videoList.hasItem(pos)) { return; @@ -5491,6 +5539,9 @@ server_Main.prototype = { if(this.videoList.items.length == 0) { return; } + if(this.activeSkipVoteUrl == data.removeVideo.url) { + this.resetSkipVote(); + } if(this.isPlaylistLockedFor(client)) { return; } @@ -5540,10 +5591,24 @@ server_Main.prototype = { if(!this.checkPermission(client,"requestLeader")) { return; } - } else if((client.group & 4) == 0 && clientName != "") { - if(!this.checkPermission(client,"setLeader")) { + var currentLeader = Lambda.find(this.clients,function(c) { + return (c.group & 4) != 0; + }); + if((client.group & 8) == 0 && (currentLeader != null && currentLeader != client)) { + this.serverMessage(client,"Leader request sent to " + currentLeader.name + ". Waiting for /giveup " + client.name + "."); + this.serverMessage(currentLeader,"" + client.name + " requested leader. Use /giveup " + client.name + " to transfer leadership."); return; } + } else if(clientName == "") { + if((client.group & 4) == 0 && !this.checkPermission(client,"setLeader")) { + return; + } + } else if((client.group & 4) == 0 && !this.checkPermission(client,"setLeader")) { + return; + } + if(clientName != "" && ClientTools.getByName(this.clients,clientName) == null) { + this.serverMessage(client,"Client \"" + clientName + "\" not found."); + return; } this.isServerPause = false; ClientTools.setLeader(this.clients,clientName); @@ -5617,7 +5682,7 @@ server_Main.prototype = { if(!this.checkPermission(client,"removeVideo")) { return; } - this.skipVideo(data); + this.handleSkipVote(client,data); break; case "ToggleItemType": if(this.isPlaylistLockedFor(client)) { @@ -5699,10 +5764,78 @@ server_Main.prototype = { client.ws.send(json,null); } } + ,resetSkipVote: function() { + this.activeSkipVoteUrl = null; + this.skipVotes.h = { }; + } + ,getSkipVoteEligibleClients: function() { + var _g = []; + var _g1 = 0; + var _g2 = this.clients; + while(_g1 < _g2.length) { + var c = _g2[_g1]; + ++_g1; + if((c.group & 1) == 0 && c.hasPermission("removeVideo",this.config.permissions)) { + _g.push(c); + } + } + return _g; + } + ,getSkipVoteCount: function() { + var count = 0; + var _g = 0; + var _g1 = this.getSkipVoteEligibleClients(); + while(_g < _g1.length) if(this.skipVotes.h[_g1[_g++].id]) { + ++count; + } + return count; + } + ,getSkipVoteRequiredCount: function() { + var total = this.getSkipVoteEligibleClients().length; + if(total <= 1) { + return 1; + } + return (Math.floor(total / 2) | 0) + 1; + } + ,handleSkipVote: function(client,data) { + if(this.videoList.items.length == 0) { + return; + } + var _this = this.videoList; + var item = _this.items[_this.pos]; + if(item.url != data.skipVideo.url) { + return; + } + if((client.group & 8) != 0 || item.author == client.name) { + if(this.activeSkipVoteUrl != null) { + this.broadcast({ type : "ServerMessage", serverMessage : { textId : "" + client.name + " " + ((client.group & 8) != 0 ? "forced skip" : "skipped own queued video") + "."}}); + } + this.resetSkipVote(); + this.skipVideo(data); + return; + } + if(this.activeSkipVoteUrl != item.url) { + this.activeSkipVoteUrl = item.url; + this.skipVotes.h = { }; + } + if(this.skipVotes.h[client.id]) { + this.serverMessage(client,"You already voted to skip this video."); + return; + } + this.skipVotes.h[client.id] = true; + var votes = this.getSkipVoteCount(); + var required = this.getSkipVoteRequiredCount(); + this.broadcast({ type : "ServerMessage", serverMessage : { textId : "" + client.name + " voted to skip (" + votes + "/" + required + ")."}}); + if(votes >= required) { + this.resetSkipVote(); + this.skipVideo(data); + } + } ,skipVideo: function(data) { if(this.videoList.items.length == 0) { return; } + this.resetSkipVote(); var _this = this.videoList; if(_this.items[_this.pos].url != data.skipVideo.url) { return; @@ -5747,7 +5880,7 @@ server_Main.prototype = { client.setGroupFlag(ClientGroup.Banned,!isOutdated); if(isOutdated) { HxOverrides.remove(this.userList.bans,ban); - haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1152, className : "server.Main", methodName : "checkBan"}); + haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1255, className : "server.Main", methodName : "checkBan"}); this.sendClientList(); } break; diff --git a/res/client.js b/res/client.js index 4d19790..c0571cb 100644 --- a/res/client.js +++ b/res/client.js @@ -788,7 +788,7 @@ client_Buttons.init = function(main) { var tmp = url.length > 0 && isSingle; window.document.querySelector("#voiceoverblock").style.display = tmp ? "" : "none"; var isExternal = main.isExternalVideoUrl(url); - checkboxCache.parentElement.style.display = isSingle && isExternal && main.playersCacheSupport.indexOf(playerType) != -1 ? "" : "none"; + checkboxCache.parentElement.style.display = (main.personal.group & 8) != 0 && isSingle && isExternal && main.playersCacheSupport.indexOf(playerType) != -1 ? "" : "none"; checkboxCache.checked = client_Buttons.settings.checkedCache.indexOf(playerType) != -1; var panel = window.document.querySelector("#addfromurl"); var oldH = panel.style.height; @@ -815,6 +815,10 @@ client_Buttons.init = function(main) { mediaUrl.focus(); }; window.document.querySelector("#mediaurl-upload").onclick = function(e) { + if((main.personal.group & 8) == 0) { + main.serverMessage("Only admins may upload files.",true,false); + return; + } client_Utils.browseJsFile(function(file) { new client_FileUploader(main).uploadFile(file); }); @@ -1093,6 +1097,10 @@ client_FileUploader.__name__ = true; client_FileUploader.prototype = { uploadFile: function(file) { var _gthis = this; + if((this.main.personal.group & 8) == 0) { + this.main.serverMessage("Only admins may upload files.",true,false); + return; + } var _this_r = new RegExp("[?#%/\\\\]","g".split("u").join("")); var name = StringTools.trim(file.name.replace(_this_r,"")); if(name.length == 0) { @@ -1113,6 +1121,8 @@ client_FileUploader.prototype = { var request = new XMLHttpRequest(); request.open("POST","/upload",true); request.setRequestHeader("content-name",name); + var tmp = this.main.settings.uuid; + request.setRequestHeader("x-client-uuid",tmp != null ? tmp : ""); request.upload.onprogress = function(event) { var ratio = 0.0; if(event.lengthComputable) { @@ -1126,7 +1136,7 @@ client_FileUploader.prototype = { try { data = JSON.parse(request.responseText); } catch( _g ) { - haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/FileUploader.hx", lineNumber : 61, className : "client.FileUploader", methodName : "uploadFullFile"}); + haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/FileUploader.hx", lineNumber : 66, className : "client.FileUploader", methodName : "uploadFullFile"}); return; } if(data.errorId == null) { @@ -1144,7 +1154,8 @@ client_FileUploader.prototype = { ,uploadLastChunk: function(file,name,callback) { var a = file.size - 5242880; var lastChunk = file.slice(a < 0 ? 0 : a); - var chunkReq = window.fetch("/upload-last-chunk",{ method : "POST", headers : { "content-name" : name}, body : lastChunk}); + var tmp = this.main.settings.uuid; + var chunkReq = window.fetch("/upload-last-chunk",{ method : "POST", headers : { "content-name" : name, "x-client-uuid" : tmp != null ? tmp : ""}, body : lastChunk}); chunkReq.then(function(e) { return e.json().then(function(data) { callback(data); @@ -1688,7 +1699,7 @@ client_Main.prototype = { var subsUrl = window.document.querySelector("#subsurl"); var isTemp = window.document.querySelector("#addfromurl .add-temp").checked; var checkboxCache = window.document.querySelector("#cache-on-server"); - var doCache = checkboxCache.checked && checkboxCache.parentElement.style.display != "none"; + var doCache = (this.personal.group & 8) != 0 && checkboxCache.checked && checkboxCache.parentElement.style.display != "none"; var url = mediaUrl.value; var subs = subsUrl.value; if(url.length == 0) { @@ -1836,7 +1847,7 @@ client_Main.prototype = { var data = JSON.parse(e.data); if(this.config != null && this.config.isVerbose) { var t = data.type; - haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 544, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]}); + haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 545, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]}); } client_JsApi.fireEvents(data); switch(data.type) { @@ -2244,6 +2255,10 @@ client_Main.prototype = { } else { adminMenu.style.display = "none"; } + window.document.querySelector("#mediaurl-upload").style.display = (this.personal.group & 8) != 0 ? "" : "none"; + var checkboxCache = window.document.querySelector("#cache-on-server"); + checkboxCache.checked = (this.personal.group & 8) != 0 && checkboxCache.checked; + checkboxCache.parentElement.style.display = (this.personal.group & 8) != 0 ? "" : "none"; } ,guestLogin: function(name) { if(name.length == 0) { @@ -2694,6 +2709,11 @@ client_Main.prototype = { case "fb":case "flashback": this.send({ type : "Flashback"}); return false; + case "giveup": + this.mergeRedundantArgs(args,0,1); + var tmp = args[0]; + this.send({ type : "SetLeader", setLeader : { clientName : tmp != null ? tmp : ""}}); + return true; case "help": this.showChatHintList(); return true; @@ -3258,7 +3278,8 @@ client_Player.prototype = { ,addVideoItem: function(item,atEnd) { var url = StringTools.htmlEscape(item.url,true); var duration = item.playerType == "IframeType" ? "" : this.duration(item.duration); - var itemEl = client_Utils.nodeFromString("
  • \n\t\t\t\t
    \n\t\t\t\t\t" + duration + "\n\t\t\t\t\t

    " + StringTools.htmlEscape(item.title) + "

    \n\t\t\t\t
    \n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
  • "); + var author = StringTools.htmlEscape(item.author); + var itemEl = client_Utils.nodeFromString("
  • \n\t\t\t\t
    \n\t\t\t\t\t" + duration + "\n\t\t\t\t\t

    " + StringTools.htmlEscape(item.title) + "

    \n\t\t\t\t\t" + Lang.get("addedBy") + ": " + author + "\n\t\t\t\t
    \n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
  • "); this.videoList.addItem(item,atEnd); this.setItemElementType(itemEl,item.isTemp); if(atEnd) { @@ -3584,7 +3605,7 @@ client_Player.prototype = { } }; http.onError = function(msg) { - haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 675, className : "client.Player", methodName : "skipAd"}); + haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 677, className : "client.Player", methodName : "skipAd"}); }; http.request(); } diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index 56f28d5..f3a2474 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -228,7 +228,7 @@ class Buttons { getEl("#voiceoverblock").style.display = (url.length > 0 && isSingle) ? "" : "none"; final isExternal = main.isExternalVideoUrl(url); - final showCache = isSingle && isExternal + final showCache = main.isAdmin() && isSingle && isExternal && main.playersCacheSupport.contains(playerType); checkboxCache.parentElement.style.display = showCache ? "" : "none"; checkboxCache.checked = settings.checkedCache.contains(playerType); @@ -258,6 +258,10 @@ class Buttons { } getEl("#mediaurl-upload").onclick = e -> { + if (!main.isAdmin()) { + main.serverMessage("Only admins may upload files.", true, false); + return; + } Utils.browseJsFile(file -> { final uploader = new FileUploader(main); uploader.uploadFile(file); diff --git a/src/client/FileUploader.hx b/src/client/FileUploader.hx index 60cc1d2..888dda6 100644 --- a/src/client/FileUploader.hx +++ b/src/client/FileUploader.hx @@ -18,6 +18,10 @@ class FileUploader { } public function uploadFile(file:File):Void { + if (!main.isAdmin()) { + main.serverMessage("Only admins may upload files.", true, false); + return; + } var name = ~/[?#%\/\\]/g.replace(file.name, "").trim(); if (name.length == 0) name = "video"; name = (window : Dynamic).encodeURIComponent(name); @@ -39,6 +43,7 @@ class FileUploader { final request = new XMLHttpRequest(); request.open("POST", "/upload", true); request.setRequestHeader("content-name", name); + request.setRequestHeader("x-client-uuid", main.settings.uuid ?? ""); request.upload.onprogress = (event:ProgressEvent) -> { var ratio = 0.0; @@ -81,6 +86,7 @@ class FileUploader { method: "POST", headers: { "content-name": name, + "x-client-uuid": main.settings.uuid ?? "", }, body: lastChunk, }); diff --git a/src/client/Main.hx b/src/client/Main.hx index 42aec3e..bea6b6b 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -356,7 +356,8 @@ class Main { final checkboxTemp:InputElement = getEl("#addfromurl .add-temp"); final isTemp = checkboxTemp.checked; final checkboxCache:InputElement = getEl("#cache-on-server"); - final doCache = checkboxCache.checked + final doCache = isAdmin() + && checkboxCache.checked && checkboxCache.parentElement.style.display != "none"; final url = mediaUrl.value; final subs = subsUrl.value; @@ -905,6 +906,13 @@ class Main { final adminMenu = getEl("#adminMenu"); if (isAdmin()) adminMenu.style.display = ""; else adminMenu.style.display = "none"; + + final uploadBtn = getEl("#mediaurl-upload"); + uploadBtn.style.display = isAdmin() ? "" : "none"; + + final checkboxCache:InputElement = getEl("#cache-on-server"); + checkboxCache.checked = isAdmin() && checkboxCache.checked; + checkboxCache.parentElement.style.display = isAdmin() ? "" : "none"; } public function guestLogin(name:String):Void { @@ -1377,6 +1385,16 @@ class Main { case "clear": send({type: ClearChat}); return true; + case "giveup": + mergeRedundantArgs(args, 0, 1); + final name = args[0] ?? ""; + send({ + type: SetLeader, + setLeader: { + clientName: name + } + }); + return true; case "flashback", "fb": send({type: Flashback}); return false; diff --git a/src/client/Player.hx b/src/client/Player.hx index 10c4351..68b1a4a 100644 --- a/src/client/Player.hx +++ b/src/client/Player.hx @@ -415,11 +415,13 @@ class Player { public function addVideoItem(item:VideoItem, atEnd:Bool):Void { final url = item.url.htmlEscape(true); final duration = item.playerType == IframeType ? "" : duration(item.duration); + final author = item.author.htmlEscape(); final itemEl = Utils.nodeFromString( - '
  • + '
  • $duration

    ${item.title.htmlEscape()}

    + ${Lang.get("addedBy")}: $author
    diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index f0f565b..bcbf9aa 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -194,7 +194,21 @@ class HttpServer { }); } + function hasUploadAccess(req:IncomingMessage):Bool { + final uuid:Null = cast req.headers["x-client-uuid"]; + if (uuid == null || uuid.length == 0) return false; + final client = main.clients.find(client -> client.uuid == uuid); + return client != null && client.isAdmin; + } + function uploadFileLastChunk(req:IncomingMessage, res:ServerResponse) { + if (!hasUploadAccess(req)) { + res.status(403).json({ + info: "Only admins may upload files.", + errorId: "accessError" + }); + return; + } var fileName = try decodeURIComponent(req.headers["content-name"]) catch (e) ""; if (fileName.trim().length == 0) fileName = null; final name = cache.getFreeFileName(fileName); @@ -213,6 +227,13 @@ class HttpServer { } function uploadFile(req:IncomingMessage, res:ServerResponse) { + if (!hasUploadAccess(req)) { + res.status(403).json({ + info: "Only admins may upload files.", + errorId: "accessError" + }); + return; + } var fileName = try decodeURIComponent(req.headers["content-name"]) catch (e) ""; if (fileName.trim().length == 0) fileName = null; final name = cache.getFreeFileName(fileName); diff --git a/src/server/Main.hx b/src/server/Main.hx index 8b9ade1..12a3bfa 100644 --- a/src/server/Main.hx +++ b/src/server/Main.hx @@ -82,6 +82,8 @@ class Main { **/ var emptyRoomCallbackTimer:Null; var isServerPause = false; + var activeSkipVoteUrl:Null; + final skipVotes:Map = []; static function main():Void { new Main({ @@ -426,6 +428,11 @@ class Main { return req.socket.remoteAddress; } + function isLoopbackIp(ip:Null):Bool { + if (ip == null) return false; + return ip == "127.0.0.1" || ip == "::1" || ip == "::ffff:127.0.0.1"; + } + public function addAdmin(name:String, password:String):Void { password += config.salt; final hash = Sha256.encode(password); @@ -504,7 +511,8 @@ class Main { final id = freeIds.length > 0 ? freeIds.shift() : clients.length; final name = 'Guest ${id + 1}'; trace(Date.now().toString(), '$name connected ($ip)'); - final isAdmin = config.localAdmins && req.socket.localAddress == ip; + final remoteIp = req.socket.remoteAddress; + final isAdmin = config.localAdmins && isLoopbackIp(remoteIp); final client = new Client(ws, req, id, name, 0); client.uuid = uuid; client.isAdmin = isAdmin; @@ -760,6 +768,10 @@ class Main { // Initial timer start if VideoLoaded is not happen if (videoList.length == 1) restartWaitTimer(); } + if (item.doCache && !client.isAdmin) { + item.doCache = false; + serverMessage(client, "Only admins may cache videos."); + } if (!item.doCache) { addVideo(); } else { @@ -789,6 +801,7 @@ class Main { case RemoveVideo: if (videoList.length == 0) return; + if (activeSkipVoteUrl == data.removeVideo.url) resetSkipVote(); if (isPlaylistLockedFor(client)) return; if (!checkPermission(client, RemoveVideoPerm)) return; final url = data.removeVideo.url; @@ -805,7 +818,7 @@ class Main { case SkipVideo: if (!checkPermission(client, RemoveVideoPerm)) return; - skipVideo(data); + handleSkipVote(client, data); case Pause: if (videoList.length == 0) return; @@ -910,11 +923,26 @@ class Main { }); case SetLeader: - final clientName = data.setLeader.clientName; + var clientName = data.setLeader.clientName; if (client.name == clientName) { if (!checkPermission(client, RequestLeaderPerm)) return; - } else if (!client.isLeader && clientName != "") { - if (!checkPermission(client, SetLeaderPerm)) return; + final currentLeader = clients.find(c -> c.isLeader); + final hasOtherLeader = currentLeader != null && currentLeader != client; + if (!client.isAdmin && hasOtherLeader) { + serverMessage(client, + 'Leader request sent to ${currentLeader.name}. Waiting for /giveup ${client.name}.'); + serverMessage(currentLeader, + '${client.name} requested leader. Use /giveup ${client.name} to transfer leadership.'); + return; + } + } else if (clientName == "") { + if (!client.isLeader && !checkPermission(client, SetLeaderPerm)) return; + } else if (!client.isLeader && !checkPermission(client, SetLeaderPerm)) { + return; + } + if (clientName != "" && clients.getByName(clientName) == null) { + serverMessage(client, 'Client "$clientName" not found.'); + return; } isServerPause = false; clients.setLeader(clientName); @@ -938,6 +966,7 @@ class Main { case PlayItem: if (!checkPermission(client, ChangeOrderPerm)) return; + resetSkipVote(); final pos = data.playItem.pos; if (!videoList.hasItem(pos)) return; if (videoTimer.getTime() > FLASHBACK_DIST) { @@ -972,6 +1001,7 @@ class Main { case ClearPlaylist: if (isPlaylistLockedFor(client)) return; + resetSkipVote(); if (!checkPermission(client, RemoveVideoPerm)) return; if (videoList.length != 0) { if (videoTimer.getTime() > FLASHBACK_DIST) { @@ -1113,8 +1143,81 @@ class Main { return value; } + function resetSkipVote():Void { + activeSkipVoteUrl = null; + skipVotes.clear(); + } + + function getSkipVoteEligibleClients():Array { + return [ + for (c in clients) + if (!c.isBanned && c.hasPermission(RemoveVideoPerm, config.permissions)) c + ]; + } + + function getSkipVoteCount():Int { + var count = 0; + for (c in getSkipVoteEligibleClients()) { + if (skipVotes[c.id]) count++; + } + return count; + } + + function getSkipVoteRequiredCount():Int { + final total = getSkipVoteEligibleClients().length; + if (total <= 1) return 1; + return Std.int(Math.floor(total / 2)) + 1; + } + + function handleSkipVote(client:Client, data:WsEvent):Void { + if (videoList.length == 0) return; + final item = videoList.currentItem; + if (item.url != data.skipVideo.url) return; + + final isOwnItemAuthor = item.author == client.name; + if (client.isAdmin || isOwnItemAuthor) { + if (activeSkipVoteUrl != null) { + final reason = client.isAdmin ? "forced skip" : "skipped own queued video"; + broadcast({ + type: ServerMessage, + serverMessage: { + textId: '${client.name} ${reason}.' + } + }); + } + resetSkipVote(); + skipVideo(data); + return; + } + + if (activeSkipVoteUrl != item.url) { + activeSkipVoteUrl = item.url; + skipVotes.clear(); + } + if (skipVotes[client.id]) { + serverMessage(client, "You already voted to skip this video."); + return; + } + skipVotes[client.id] = true; + + final votes = getSkipVoteCount(); + final required = getSkipVoteRequiredCount(); + broadcast({ + type: ServerMessage, + serverMessage: { + textId: '${client.name} voted to skip (${votes}/${required}).' + } + }); + + if (votes >= required) { + resetSkipVote(); + skipVideo(data); + } + } + function skipVideo(data:WsEvent):Void { if (videoList.length == 0) return; + resetSkipVote(); final item = videoList.currentItem; if (item.url != data.skipVideo.url) return; final dur = videoList.currentItem.duration; -- cgit v1.2.3