diff options
| -rw-r--r-- | .github/workflows/main.yml | 26 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | default-config.json | 136 | ||||
| -rw-r--r-- | docker-compose.yml | 14 | ||||
| -rw-r--r-- | res/admin-register.html | 92 | ||||
| -rw-r--r-- | res/client.js | 2 | ||||
| -rw-r--r-- | res/css/des.css | 97 | ||||
| -rw-r--r-- | res/css/setup.css | 56 | ||||
| -rw-r--r-- | res/img/welcome.webp | bin | 0 -> 16356 bytes | |||
| -rw-r--r-- | res/index.html | 3 | ||||
| -rw-r--r-- | res/langs/en.json | 228 | ||||
| -rw-r--r-- | res/setup.html | 55 | ||||
| -rw-r--r-- | src/Types.hx | 2 | ||||
| -rw-r--r-- | src/client/Main.hx | 2 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 138 |
15 files changed, 524 insertions, 334 deletions
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 35c0c18..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CI -on: [push, pull_request] -jobs: - test: - runs-on: ${{matrix.os}} - - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - fail-fast: false - - steps: - - uses: actions/checkout@v2 - - - name: Setup - run: | - node -v - npm install --global lix - lix install haxe 4.3.6 --global - lix download - npm ci - haxelib install tests.hxml --always - - - name: Test - run: | - haxe tests.hxml @@ -1,3 +1,10 @@ +Fork Key Differences
+- `gatePassword` config option, allowing only people who know the password to enter
+- `adminToken` allows for registering admin on the frontend using a known token
+- `/admin-register` page that is visible only if `adminToken` is set
+
+This fork adds basic password based authentication to prevent unwanted users from joining and strips most existing emotes.
+
# <img src="./res/img/favicon.svg" width="40" height="40" align="top"> SyncTube
Synchronized video viewing with chat and other features.
Lightweight modern implementation and a very easy way to run locally.
diff --git a/default-config.json b/default-config.json index bdfdb4e..e4bb98d 100644 --- a/default-config.json +++ b/default-config.json @@ -1,84 +1,56 @@ { - "port": 4200, - "channelName": "SyncTube", - "maxLoginLength": 20, - "maxMessageLength": 500, - "serverChatHistory": 50, - "totalVideoLimit": 0, - "userVideoLimit": 0, - "requestLeaderOnPause": false, - "unpauseWithoutLeader": false, - "localAdmins": true, - "allowProxyIps": true, - "localNetworkOnly": false, - "sslKeyPemPath": "", - "sslCertPemPath": "", - "templateUrl": "https://youtube.com/playlist?list=PL9FiZUDVMu9tc_85frYognMOVFC_-VkSX", - "youtubeApiKey": "AIzaSyDTk1OPRI9cDkAK_BKsBcv10DQCHse-QaA", - "youtubePlaylistLimit": 50, - "cacheStorageLimitGiB": 3.0, - "permissions": { - "banned": [], - "guest": ["writeChat", "addVideo", "removeVideo", "changeOrder", "toggleItemType", "requestLeader", "rewind"], - "user": ["guest"], - "leader": ["user"], - "admin": ["user", "clearChat", "setLeader", "lockPlaylist", "banClient"] - }, - "ytDlp": { - "channel": "stable", - "jsRuntime": "node" - }, - "emotes": [ - {"name": ":adorable:", "image": "https://i.imgur.com/5GxNwDY.png"}, - {"name": ":angry:", "image": "https://i.imgur.com/Mx9lhMZ.png"}, - {"name": ":artist:", "image": "https://i.imgur.com/8wQxnse.png"}, - {"name": ":back:", "image": "https://i.imgur.com/hafcTP1.png"}, - {"name": ":beaten:", "image": "https://i.imgur.com/QIvPD83.png"}, - {"name": ":cold:", "image": "https://i.imgur.com/fwgJPJ2.png"}, - {"name": ":confusion:", "image": "https://i.imgur.com/rQHCTPI.png"}, - {"name": ":doh:", "image": "https://i.imgur.com/EaFIosT.png"}, - {"name": ":drink:", "image": "https://i.imgur.com/mSeXSIk.png"}, - {"name": ":eh:", "image": "https://i.imgur.com/lrpyuCX.png"}, - {"name": ":ehehe:", "image": "https://i.imgur.com/UrxypoB.png"}, - {"name": ":fallen:", "image": "https://i.imgur.com/ihYXAlM.png"}, - {"name": ":frustrated:", "image": "https://i.imgur.com/pSY67Ja.gif"}, - {"name": ":goodmood:", "image": "https://i.imgur.com/t5WgyL6.gif"}, - {"name": ":happy:", "image": "https://i.imgur.com/2JNBCk7.png"}, - {"name": ":hmm:", "image": "https://i.imgur.com/u0O7xrc.png"}, - {"name": ":honor:", "image": "https://i.imgur.com/Dkn7JfX.png"}, - {"name": ":hot:", "image": "https://i.imgur.com/9IHCj1a.png"}, - {"name": ":hungry:", "image": "https://i.imgur.com/QoVVKYK.png"}, - {"name": ":idea:", "image": "https://i.imgur.com/l3HoqtG.png"}, - {"name": ":ill:", "image": "https://i.imgur.com/EgY26B0.png"}, - {"name": ":awesome:", "image": "https://i.imgur.com/3COHK8w.png"}, - {"name": ":lovebreak:", "image": "https://i.imgur.com/FcTCnCx.png"}, - {"name": ":loveformoney:", "image": "https://i.imgur.com/Z18e5NZ.png"}, - {"name": ":money:", "image": "https://i.imgur.com/1uM0wky.png"}, - {"name": ":nani:", "image": "https://i.imgur.com/bePPNni.png"}, - {"name": ":ok:", "image": "https://i.imgur.com/gub2TDR.png"}, - {"name": ":ontheway:", "image": "https://i.imgur.com/fWWBeBz.png"}, - {"name": ":romantic:", "image": "https://i.imgur.com/sze1iiu.png"}, - {"name": ":sad:", "image": "https://i.imgur.com/yAjo1kC.png"}, - {"name": ":scared:", "image": "https://i.imgur.com/rsqs0fw.png"}, - {"name": ":sleep:", "image": "https://i.imgur.com/UbR9xv3.png"}, - {"name": ":think:", "image": "https://i.imgur.com/nAoKYYi.png"}, - {"name": ":tired:", "image": "https://i.imgur.com/mKCmaIQ.png"}, - {"name": ":tricky:", "image": "https://i.imgur.com/gC7NlqY.png"}, - {"name": ":writer:", "image": "https://i.imgur.com/rJLs4Ig.png"}, - {"name": ":haxe:", "image": "https://i.imgur.com/F9Tllyv.png"} - ], - "filters": [ - { - "name": "image", - "regex": "(https?:\\/\\/[^']*\\.)(png|jpg|gif|jpeg|webp)([^' ,]*)", - "flags": "g", - "replace": "<a href='$1$2$3' target='_blank' rel='noopener noreferrer'><img src='$1$2$3' class='chat-img'/></a>" - }, - { - "name": "url", - "regex": "(^|[^'])(https?:\\/\\/[^' \t]*)", - "flags": "g", - "replace": "$1<a href='$2' target='_blank' rel='noopener noreferrer'>$2</a>" - } - ] + "port": 4200, + "channelName": "Dohee Cinema", + "gatePassword": "changeme", + "adminToken": "admin", + "maxLoginLength": 20, + "maxMessageLength": 500, + "serverChatHistory": 50, + "totalVideoLimit": 0, + "userVideoLimit": 0, + "requestLeaderOnPause": false, + "unpauseWithoutLeader": false, + "localAdmins": true, + "allowProxyIps": true, + "localNetworkOnly": false, + "sslKeyPemPath": "", + "sslCertPemPath": "", + "templateUrl": "YouTube Playlist URL here", + "youtubeApiKey": "", + "youtubePlaylistLimit": 50, + "cacheStorageLimitGiB": 3.0, + "permissions": { + "banned": [], + "guest": [ + "writeChat", + "addVideo", + "removeVideo", + "changeOrder", + "toggleItemType", + "requestLeader", + "rewind" + ], + "user": ["guest"], + "leader": ["user"], + "admin": ["user", "clearChat", "setLeader", "lockPlaylist", "banClient"] + }, + "ytDlp": { + "channel": "stable", + "jsRuntime": "node" + }, + "emotes": [{ "name": ":haxe:", "image": "https://i.imgur.com/F9Tllyv.png" }], + "filters": [ + { + "name": "image", + "regex": "(https?:\\/\\/[^']*\\.)(png|jpg|gif|jpeg|webp)([^' ,]*)", + "flags": "g", + "replace": "<a href='$1$2$3' target='_blank' rel='noopener noreferrer'><img src='$1$2$3' class='chat-img'/></a>" + }, + { + "name": "url", + "regex": "(^|[^'])(https?:\\/\\/[^' \t]*)", + "flags": "g", + "replace": "$1<a href='$2' target='_blank' rel='noopener noreferrer'>$2</a>" + } + ] } diff --git a/docker-compose.yml b/docker-compose.yml index 47945f4..aa5ba19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ -# Run this as follows: 'docker compose up' (keep running in terminal session, exist via [STRG] + [C]) +# Run this as follows: 'docker compose up' (keep running in terminal session, exist via [STRG] + [C]) # or 'docker compose up -d' (keep running in background) version: "3" services: synctube: - build: . - #image: synctube - ports: - - "4200:4200" - volumes: - - "${PWD}/user:/usr/src/app/user" + build: . + restart: unless-stopped + ports: + - "4200:4200" + volumes: + - "${PWD}/user:/usr/src/app/user" diff --git a/res/admin-register.html b/res/admin-register.html new file mode 100644 index 0000000..1a75c48 --- /dev/null +++ b/res/admin-register.html @@ -0,0 +1,92 @@ +<!-- localization-template --> +<!DOCTYPE html> +<html> + +<head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> + <meta name="mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <link rel="manifest" href="manifest.json"> + <title>SyncTube</title> + <link rel="icon" href="img/favicon.svg" type="image/svg+xml"> + <link id="usertheme" href="css/des.css" rel="stylesheet"> + <link id="usertheme" href="css/setup.css" rel="stylesheet"> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap" rel="stylesheet"> + <link id="customcss" href="css/custom.css" rel="stylesheet"> +</head> + +<body> + <main class="setup"> + <h1 class="setup-title">Admin Registration</h1> + <p>Enter the admin token to register your account</p> + + <form id="admin-form" class="setup-form"> + <input type="text" name="name" placeholder="Username"> + <input type="password" name="password" placeholder="Password"> + <input type="password" name="confirmation" placeholder="Repeat password"> + <input type="password" name="token" placeholder="Admin Token"> + + <div id="form-errors" class="form-errors"></div> + + <button type="submit">Register</button> + </form> + </main> + + <script> + const formElement = document.getElementById("admin-form"); + const errorsElement = document.getElementById("form-errors"); + + formElement.addEventListener("submit", function (e) { + e.preventDefault(); + const { name, password, confirmation, token } = formElement.elements; + + const payload = { + name: name.value, + password: password.value, + passwordConfirmation: confirmation.value, + token: token.value + }; + + fetch("/admin-register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }) + .then(res => res.json()) + .then(response => handleResponse(response)) + .catch(() => handleResponse(null)); + }, true); + + function handleResponse(response) { + if (response && response.success === true) { + errorsElement.innerHTML = ""; + const successEl = document.createElement("div"); + successEl.innerText = "Admin account created! You can now log in."; + successEl.style.color = "green"; + errorsElement.appendChild(successEl); + + Array.from(formElement.elements).forEach(el => { el.disabled = true; }); + return; + } + + const message = (response && response.error) ? response.error : "Registration failed"; + showErrors(errorsElement, [message]); + } + + function showErrors(container, errors) { + container.innerHTML = ""; + + errors.forEach(function (message) { + const errorEl = document.createElement("div"); + errorEl.innerText = message; + container.appendChild(errorEl); + }); + } + </script> +</body> + +</html> diff --git a/res/client.js b/res/client.js index eeea7b3..faf13b4 100644 --- a/res/client.js +++ b/res/client.js @@ -1392,7 +1392,7 @@ var client_Main = function() { if(this.host == "") { this.host = "localhost"; } - client_Settings.init({ version : 6, uuid : null, name : "", hash : "", chatSize : 300, synchThreshold : 2, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, showHintList : true, checkboxes : [], checkedCache : []},$bind(this,this.settingsPatcher)); + client_Settings.init({ version : 6, uuid : null, name : "", hash : "", chatSize : 300, synchThreshold : 1, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, showHintList : true, checkboxes : [], checkedCache : []},$bind(this,this.settingsPatcher)); this.settings = client_Settings.read(); this.initListeners(); this.onTimeGet = new haxe_Timer(this.settings.synchThreshold * 1000); diff --git a/res/css/des.css b/res/css/des.css index 746fb7b..4da2005 100644 --- a/res/css/des.css +++ b/res/css/des.css @@ -3,17 +3,44 @@ /* CSS Document */ :root { - --background-video: #000; - --background-chat: #111; - --scroll-to-end-bg: #fff; - --midground: #888; - --foreground: #bbb; - --accent: #0055ff; - --success: #009632; - --leader-hint: #00963288; - --warning: #ffb800; - --error: #ff0800; - --border: #333; + --cl-black: hsl(140, 1%, 6%); + --cl-gray-0: hsl(140, 2%, 8%); + --cl-gray-1: hsl(140, 2%, 12%); + --cl-gray-2: hsl(140, 4%, 16%); + --cl-gray-3: hsl(140, 4%, 24%); + --cl-gray-4: hsl(140, 4%, 36%); + --cl-gray-5: hsl(140, 4%, 44%); + --cl-gray-6: hsl(80, 8%, 52%); + --cl-gray-7: hsl(70, 8%, 58%); + --cl-gray-8: hsl(60, 16%, 66%); + --cl-gray-9: hsl(40, 32%, 78%); + --cl-gray-10: hsl(30, 32%, 84%); + --cl-white: hsl(26, 64%, 88%); + --cl-red-6: hsl(4, 83%, 67%); + --cl-red-7: hsl(4, 75%, 75%); + --cl-orange-6: hsl(26, 84%, 62%); + --cl-orange-7: hsl(26, 84%, 74%); + --cl-yellow: hsl(37, 80%, 69%); + --cl-green-6: hsl(120, 41%, 64%); + --cl-green-7: hsl(120, 42%, 75%); + --cl-cyan-6: hsl(160, 41%, 64%); + --cl-cyan-7: hsl(160, 32%, 75%); + --cl-blue-6: hsl(200, 55%, 64%); + --cl-blue-8: hsl(201, 55%, 80%); + --cl-magenta-7: hsl(320, 59%, 72%); + --cl-magenta-8: hsl(320, 61%, 80%); + + --background-video: var(--cl-gray-0); + --background-chat: var(--cl-gray-1); + --scroll-to-end-bg: var(--cl-gray-8); + --midground: var(--cl-gray-5); + --foreground: var(--cl-white); + --accent: var(--cl-orange-6); + --success: var(--cl-green-6); + --leader-hint: hsl(120, 41%, 64%, 0.53); + --warning: var(--cl-yellow); + --error: var(--cl-red-6); + --border: var(--cl-gray-3); --box-shadow: 0rem .25rem 1rem rgba(0, 0, 0, .25); } @@ -30,7 +57,7 @@ html { } body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-family: "Roboto Mono", Menlo, Monaco, Consolas, "Courier New", monospace, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1rem; line-height: 1.4; color: var(--foreground); @@ -98,7 +125,24 @@ h6 { } a { - color: var(--accent); + text-decoration: none; + transition: color ease-in-out .15s; +} + +a:link { + color: var(--cl-orange-6); +} + +a:visited { + color: var(--cl-green-6); +} + +a:hover { + color: var(--cl-magenta-7); +} + +a:active { + color: var(--cl-green-6); } ul { @@ -121,9 +165,9 @@ textarea { } code { - background: var(--background-chat); + background: var(--cl-gray-2); border: solid var(--border) 1px; - border-radius: .5rem; + border-radius: 0; padding: .25rem; word-break: break-all; display: inline-flex; @@ -142,12 +186,12 @@ button { display: inline-flex; align-items: center; text-align: center; - background-color: var(--background-chat); + background-color: var(--cl-gray-2); color: var(--midground); line-height: 1; cursor: pointer; padding: .5rem; - border-radius: .5rem; + border-radius: 0; border: 0; transition: background-color ease-in-out .15s, color ease-in-out .15s; } @@ -163,7 +207,7 @@ button:not(:first-child) { button:hover, button.active { - background-color: rgba(0, 0, 0, .5); + background-color: var(--cl-gray-3); color: var(--foreground); } @@ -252,9 +296,9 @@ input[type=text], input[type="password"], textarea { padding: .5rem; - border-radius: .5rem; + border-radius: 0; color: var(--foreground); - background-color: var(--background-video); + background-color: var(--cl-gray-2); border: .125rem solid; border-color: var(--border); transition: border-color ease-in-out .15s; @@ -577,7 +621,7 @@ iframe#videoplayer { } .queue_temp { - background-color: var(--background-chat); + background-color: var(--cl-gray-2); } .queue_entry { @@ -672,9 +716,9 @@ footer#footer { #userlist-wrap { overflow-y: auto; - background-color: var(--background-video); + background-color: var(--cl-gray-2); border-right: 0; - border-radius: 1rem; + border-radius: 0; flex-shrink: 0; transition: 0ms; @@ -700,7 +744,7 @@ footer#footer { } .userlist_owner { - color: var(--accent); + color: var(--cl-blue-6); font-weight: bold; } @@ -733,6 +777,7 @@ footer#footer { display: block; text-overflow: ellipsis; overflow: hidden; + padding-right: 0.4rem; } /* Options */ @@ -894,8 +939,8 @@ footer#footer { display: flex; height: 0; width: 100%; - background: rgba(0, 0, 0, 0.7); - border-radius: 1rem; + background: var(--cl-gray-2); + border-radius: 0; overflow-y: scroll; text-align: center; } diff --git a/res/css/setup.css b/res/css/setup.css index 9755ad3..f0a63e5 100644 --- a/res/css/setup.css +++ b/res/css/setup.css @@ -3,23 +3,42 @@ body { height: 100vh; align-items: center; justify-content: center; + background-color: var(--background-video); + margin: 0; } .setup { margin: auto; - padding: 2rem; - width: 320px; + padding: 2.5rem 2rem; + width: 360px; display: flex; flex-direction: column; align-items: center; + background-color: var(--cl-gray-1); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: 0; + gap: 1rem; +} - /* debug */ - background-color: #1a1a1f; - border-radius: 0.375rem; +.welcome-image { + max-width: 200px; + width: 100%; + border-radius: 0; + margin-bottom: 0.5rem; +} - & h1 { - font-size: 1.75rem; - } +.setup-title { + font-size: 1.75rem; + margin: 0; + text-align: center; +} + +.setup p { + margin: 0; + text-align: center; + opacity: 0.7; + font-size: 0.95rem; } .setup-form { @@ -27,16 +46,28 @@ body { flex-direction: column; gap: 1rem; width: 100%; + margin-top: 0.5rem; + + & input { + width: 100%; + box-sizing: border-box; + } & button { margin: 0; - padding: .75rem .5rem; + padding: 0.75rem 0.5rem; justify-content: center; background-color: var(--accent); - color: #fff; + color: var(--cl-gray-0); + border: none; + border-radius: 0; + cursor: pointer; + font-size: 0.95rem; + font-weight: bold; &:hover { - filter: brightness(1.15); + background-color: var(--cl-orange-7); + color: var(--cl-gray-0); } } } @@ -46,4 +77,5 @@ body { flex-direction: column; gap: 0.5rem; color: var(--error); -} + font-size: 0.9rem; +}
\ No newline at end of file diff --git a/res/img/welcome.webp b/res/img/welcome.webp Binary files differnew file mode 100644 index 0000000..ed1e8d4 --- /dev/null +++ b/res/img/welcome.webp diff --git a/res/index.html b/res/index.html index 2f620c6..29f0b1b 100644 --- a/res/index.html +++ b/res/index.html @@ -11,6 +11,9 @@ <link rel="manifest" href="manifest.json"> <title>SyncTube</title> <link rel="icon" href="img/favicon.svg" type="image/svg+xml"> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap" rel="stylesheet"> <link id="usertheme" href="css/des.css" rel="stylesheet"> <link id="customcss" href="css/custom.css" rel="stylesheet"> <script type="module" src="https://cdn.jsdelivr.net/npm/ionicons@7.4.0/dist/ionicons/ionicons.esm.js"></script> diff --git a/res/langs/en.json b/res/langs/en.json index a7ce267..02f91ea 100644 --- a/res/langs/en.json +++ b/res/langs/en.json @@ -1,119 +1,119 @@ { - "connection": "Connection", - "msgConnected": "Connected", - "msgDisconnected": "Disconnected", - "joined": "joined", - "online": "online", - "nothingPlaying": "Nothing Playing", - "hintListStart": "Welcome to SyncTube! Here you can:", - "hintListAddVideo": "$addVideos to watch together", - "hintListRequestLeader": "$requestLeader to pause and rewind videos for everyone", - "hintListRequestLeaderMouse": "(also use right mouse button for quick pause)", - "hintListRequestLeaderTouch": "(also use long tap for quick pause)", - "hintListOpenInApp": "$openInApp this server for better Android experience", - "hintListHide": "$hideThisMessage and send <code>/help</code> in chat to see it again", - "addVideos": "Add Videos", - "requestLeader": "Request Leader", - "openInApp": "Open in App", - "hideThisMessage": "Hide this message", - "usernameError": "Username length must be from 1 to $MAX characters and don't repeat another's. Characters &^<>'\" are not allowed.", - "passwordError": "Password length must be from $MIN to $MAX characters.", - "passwordsMismatchError": "Passwords do not match.", - "passwordMatchError": "Wrong password.", - "accessError": "Access error", - "noPermission": "No '$PERMISSION' permission.", - "totalVideoLimitError": "Playlist video limit has been reached.", - "userVideoLimitError": "Playlist video limit per user has been reached.", - "videoAlreadyExistsError": "The video already exists in playlist.", - "addVideoError": "Failed to add video.", - "adminsCannotBeBannedError": "Admins cannot be banned. Remove them first.", - "caching": "Caching", - "downloading": "Downloading", - "uploading": "Uploading", - "rawVideo": "Raw video", - "videos": "videos", - "addedBy": "Added by", - "play": "Play", - "setNext": "Next", - "makePermanent": "Make Permanent", - "makeTemporary": "Make Temporary", - "delete": "Delete", - "account": "Account", - "exportSettings": "Export Settings", - "importSettings": "Import Settings", - "login": "Login", - "exit": "Exit", - "settings": "Settings", - "synchThreshold": "Synch Threshold", - "general": "General", - "hotkeys": "Hotkeys", - "video": "Video", - "channel": "Channel", - "layout": "Layout", - "swapLayout": "Swap Layout", - "chatOnly": "Chat Only", - "setVideoUrl": "Set Video URL", - "setVideoUrlPrompt": "New video URL:\n(Does not affect other users)", - "selectLocalVideo": "Select Local Video", - "removePlayer": "Remove Player", - "restorePlayer": "Restore Player", - "toggleUserList": "Show/Hide Userlist", - "leaderDesc": "Request video control permissions", - "mobileViewBtn": "Mobile View", - "leader": "Leader", - "enterAsGuest": "Enter As Guest:", - "yourName": "Your Name", - "enterUserPassword": "Enter User Password", - "yourPassword": "Your Password", - "emotes": "Emotes", - "chat": "Chat", - "kicked": "Kicked", - "clearChat": "Clear Chat", - "chatlinePlaceholder": "Send a message...", - "leaderDisconnectedServerOnPause": "Leader was disconnected, server on pause.", - "unpause": "Unpause", - "addVideoFromUrl": "Add video from URL", - "embedCustomFrame": "Embed a custom frame", - "clearPlaylist": "Clear playlist", - "shufflePlaylist": "Shuffle playlist", - "playlist": "Playlist", - "playlistOpen": "Playlist open", - "playlistLocked": "Playlist locked", - "expandPlayer": "Expand player", - "toggleVideoSync": "Toggle video synchronization", - "toggleSynchConfirm": "Are you sure you want to turn off video sync?", - "refreshPlayer": "Refresh player", - "fullscreenPlayer": "Fullscreen player", - "retrievePlaylistLinks": "Retrieve playlist links", - "voteForSkip": "Vote for skip", - "addAsTemporary": "Add as temporary", - "cacheOnServer": "Cache on server", - "mediaUrl": "Media URL", - "optionalTitle": "Title (optional)", - "subtitlesUrlOptional": "Subtitles URL (optional)", - "voiceOverAudioTrackUrlOptional": "Voice-over audio URL (optional)", - "addTemplateUrl": "Add template URL", - "queueNext": "Queue next", - "queueLast": "Queue last", - "and": "and", - "or": "or", - "to": "to", - "pasteEmbedCodeAndClick": "Paste the embed code here", - "acceptableEmbedCodesAre": "Acceptable embed codes are", - "customEmbedsCannotBeSynchronized": "CUSTOM EMBEDS CANNOT BE SYNCHRONIZED", - "save": "Save", - "skipItemConfirm": "Are you sure you want to skip current video?", - "clearPlaylistConfirm": "Are you sure you want to clear the playlist?", - "shufflePlaylistConfirm": "Are you sure you want to shuffle the playlist?", - "lockPlaylistConfirm": "Are you sure you want to lock the playlist?", + "connection": "Connection", + "msgConnected": "Connected", + "msgDisconnected": "Disconnected", + "joined": "joined", + "online": "online", + "nothingPlaying": "Nothing Playing", + "hintListStart": "Welcome to Dohee Cinema! Here you can:", + "hintListAddVideo": "$addVideos to watch together (press the + button)", + "hintListRequestLeader": "$requestLeader to pause and rewind videos for everyone", + "hintListRequestLeaderMouse": "(also use right mouse button for quick pause)", + "hintListRequestLeaderTouch": "(also use long tap for quick pause)", + "hintListOpenInApp": "$openInApp this server for better Android experience", + "hintListHide": "$hideThisMessage and send <code>/help</code> in chat to see it again", + "addVideos": "Add Videos", + "requestLeader": "Request Leader", + "openInApp": "Open in App", + "hideThisMessage": "Hide this message", + "usernameError": "Username length must be from 1 to $MAX characters and don't repeat another's. Characters &^<>'\" are not allowed.", + "passwordError": "Password length must be from $MIN to $MAX characters.", + "passwordsMismatchError": "Passwords do not match.", + "passwordMatchError": "Wrong password.", + "accessError": "Access error", + "noPermission": "No '$PERMISSION' permission.", + "totalVideoLimitError": "Playlist video limit has been reached.", + "userVideoLimitError": "Playlist video limit per user has been reached.", + "videoAlreadyExistsError": "The video already exists in playlist.", + "addVideoError": "Failed to add video.", + "adminsCannotBeBannedError": "Admins cannot be banned. Remove them first.", + "caching": "Caching", + "downloading": "Downloading", + "uploading": "Uploading", + "rawVideo": "Raw video", + "videos": "videos", + "addedBy": "Added by", + "play": "Play", + "setNext": "Next", + "makePermanent": "Make Permanent", + "makeTemporary": "Make Temporary", + "delete": "Delete", + "account": "Account", + "exportSettings": "Export Settings", + "importSettings": "Import Settings", + "login": "Login", + "exit": "Logout", + "settings": "Settings", + "synchThreshold": "Sync Threshold", + "general": "General", + "hotkeys": "Hotkeys", + "video": "Video", + "channel": "Channel", + "layout": "Layout", + "swapLayout": "Swap Layout", + "chatOnly": "Chat Only", + "setVideoUrl": "Set Video URL", + "setVideoUrlPrompt": "New video URL:\n(Does not affect other users)", + "selectLocalVideo": "Select Local Video", + "removePlayer": "Remove Player", + "restorePlayer": "Restore Player", + "toggleUserList": "Show/Hide Userlist", + "leaderDesc": "Request video control permissions", + "mobileViewBtn": "Mobile View", + "leader": "Leader", + "enterAsGuest": "Login/Register", + "yourName": "Username", + "enterUserPassword": "Welcome back! Enter Password", + "yourPassword": "Your Password", + "emotes": "Emotes", + "chat": "Chat", + "kicked": "Kicked", + "clearChat": "Clear Chat", + "chatlinePlaceholder": "Send a message...", + "leaderDisconnectedServerOnPause": "Leader was disconnected, server on pause.", + "unpause": "Unpause", + "addVideoFromUrl": "Add video from URL", + "embedCustomFrame": "Embed a custom frame", + "clearPlaylist": "Clear playlist", + "shufflePlaylist": "Shuffle playlist", + "playlist": "Playlist", + "playlistOpen": "Playlist open", + "playlistLocked": "Playlist locked", + "expandPlayer": "Expand player", + "toggleVideoSync": "Toggle video synchronization", + "toggleSynchConfirm": "Are you sure you want to turn off video sync?", + "refreshPlayer": "Refresh player", + "fullscreenPlayer": "Fullscreen player", + "retrievePlaylistLinks": "Retrieve playlist links", + "voteForSkip": "Vote for skip", + "addAsTemporary": "Add as temporary", + "cacheOnServer": "Cache on server", + "mediaUrl": "Media URL", + "optionalTitle": "Title (optional)", + "subtitlesUrlOptional": "Subtitles URL (optional)", + "voiceOverAudioTrackUrlOptional": "Voice-over audio URL (optional)", + "addTemplateUrl": "Add template URL", + "queueNext": "Queue next", + "queueLast": "Queue last", + "and": "and", + "or": "or", + "to": "to", + "pasteEmbedCodeAndClick": "Paste the embed code here", + "acceptableEmbedCodesAre": "Acceptable embed codes are", + "customEmbedsCannotBeSynchronized": "CUSTOM EMBEDS CANNOT BE SYNCHRONIZED", + "save": "Save", + "skipItemConfirm": "Are you sure you want to skip current video?", + "clearPlaylistConfirm": "Are you sure you want to clear the playlist?", + "shufflePlaylistConfirm": "Are you sure you want to shuffle the playlist?", + "lockPlaylistConfirm": "Are you sure you want to lock the playlist?", - "yes": "Yes", - "no": "No", - "on": "On", - "off": "Off", + "yes": "Yes", + "no": "No", + "on": "On", + "off": "Off", - "areYouSure": "Are you sure?", - "dataWillBeLost": "The data will be lost.", + "areYouSure": "Are you sure?", + "dataWillBeLost": "The data will be lost.", - "setupTitle": "Welcome to SyncTube!" + "setupTitle": "Welcome to Dohee Cinema!" } diff --git a/res/setup.html b/res/setup.html index 8068a9d..3a0a4ca 100644 --- a/res/setup.html +++ b/res/setup.html @@ -11,6 +11,9 @@ <link rel="manifest" href="manifest.json"> <title>SyncTube</title> <link rel="icon" href="img/favicon.svg" type="image/svg+xml"> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&display=swap" rel="stylesheet"> <link id="usertheme" href="css/des.css" rel="stylesheet"> <link id="usertheme" href="css/setup.css" rel="stylesheet"> <link id="customcss" href="css/custom.css" rel="stylesheet"> @@ -19,17 +22,16 @@ <body> <main class="setup"> - <h1 class="setup-title">SyncTube</h1> - <p>Create your admin account</p> + <img class="welcome-image" src="img/welcome.webp" alt="Welcome to Dohee Cinema"> + <h1 class="setup-title">ABSOLUTE CINEMA</h1> + <p>Enter password for access</p> - <form id="setup-form" class="setup-form" action="/setup" method="POST"> - <input type="text" name="name" placeholder="Name"> + <form id="setup-form" class="setup-form"> <input type="password" name="password" placeholder="Password"> - <input type="password" name="confirmation" placeholder="Repeat password"> <div id="form-errors" class="form-errors"></div> - <button type="submit">Create</button> + <button type="submit">Enter</button> </form> </main> @@ -37,33 +39,34 @@ const formElement = document.getElementById("setup-form"); const errorsElement = document.getElementById("form-errors"); + function submitPassword(pw) { + const payload = { password: pw }; + return fetch("/gate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) + .then(res => res.json()) + .then(response => handleResponse(response, pw)) + .catch(() => handleResponse(null, pw)); + } + formElement.addEventListener("submit", function (e) { e.preventDefault(); - - const { name, password, confirmation } = formElement.elements; - const payload = { - name: name.value, - password: password.value, - passwordConfirmation: confirmation.value, - } - - fetch("/setup", { method: "POST", body: JSON.stringify(payload) }) - .then(res => res.json()) - .then(response => handleResponse(response)) - .catch(() => handleResponse(null)); + const { password } = formElement.elements; + submitPassword(password.value); }, true); + const savedPassword = localStorage.getItem("gate_password"); + if (savedPassword) { + submitPassword(savedPassword); + } - function handleResponse(response) { - if (response.success) { - return window.location.reload(); + function handleResponse(response, pw) { + if (response && response.success === true) { + localStorage.setItem("gate_password", pw); + window.location.href = "/"; + return; } - const errors = !response - ? ["Unknown error"] - : (response.errors ?? []).map(item => item.error); - - showErrors(errorsElement, errors); + localStorage.removeItem("gate_password"); + showErrors(errorsElement, ["Incorrect password. Not cool man"]); } function showErrors(container, errors) { diff --git a/src/Types.hx b/src/Types.hx index 22b6ec2..1b1997a 100644 --- a/src/Types.hx +++ b/src/Types.hx @@ -31,6 +31,8 @@ typedef VideoData = { typedef ServerConfig = Config & { serverChatHistory:Int, + gatePassword:String, + adminToken:String, localAdmins:Bool, allowProxyIps:Bool, localNetworkOnly:Bool, diff --git a/src/client/Main.hx b/src/client/Main.hx index 5e05690..e23acbe 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -85,7 +85,7 @@ class Main { name: "", hash: "", chatSize: 300, - synchThreshold: 2, + synchThreshold: 1, isSwapped: false, isUserListHidden: true, latestLinks: [], diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index f7c6d8c..f0f565b 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -1,6 +1,7 @@ package server; import Types.UploadResponse; +import haxe.crypto.Sha256; import haxe.io.Path; import js.node.Buffer; import js.node.Fs.Fs; @@ -24,10 +25,15 @@ private class HttpServerConfig { public final cache:Cache = null; } -typedef SetupAdminRequest = { +typedef GateRequest = { + password:String, +} + +typedef AdminRegisterRequest = { name:String, password:String, passwordConfirmation:String, + token:String, } class HttpServer { @@ -100,8 +106,10 @@ class HttpServer { } } switch url.pathname { - case "/setup": - finishSetup(req, res); + case "/gate": + verifyGate(req, res); + case "/admin-register": + registerAdmin(req, res); } return; } @@ -121,8 +129,8 @@ class HttpServer { return; } - if (url.pathname == "/setup") { - if (main.hasAdmins()) { + if (url.pathname == "/gate") { + if (!hasGatePassword() || hasValidGateCookie(req)) { res.redirect("/"); return; } @@ -135,6 +143,23 @@ class HttpServer { return; } + if (url.pathname == "/admin-register") { + if (!hasAdminToken()) { + res.redirect("/"); + return; + } + Fs.readFile('$dir/admin-register.html', (err:Dynamic, data:Buffer) -> { + if (err != null) { + readFileError(err, res, '$dir/admin-register.html'); + return; + } + data = Buffer.from(localizeHtml(data.toString(), req.headers["accept-language"])); + res.setHeader("content-type", getMimeType("html")); + res.end(data); + }); + return; + } + if (url.pathname == "/proxy") { if (!proxyUrl(req, res)) res.end('Proxy error: ${req.url}'); return; @@ -158,8 +183,8 @@ class HttpServer { } if (ext == "html") { - if (!main.isNoState && !main.hasAdmins()) { - res.redirect("/setup"); + if (hasGatePassword() && !hasValidGateCookie(req)) { + res.redirect("/gate"); return; } // replace ${textId} to localized strings @@ -244,8 +269,8 @@ class HttpServer { }); } - function finishSetup(req:IncomingMessage, res:ServerResponse) { - if (main.hasAdmins()) { + function verifyGate(req:IncomingMessage, res:ServerResponse) { + if (!hasGatePassword()) { return res.redirect("/"); } @@ -257,52 +282,87 @@ class HttpServer { req.on("end", () -> { final body = Buffer.concat(bodyChunks).toString(); - final jsonParser = new JsonParser<SetupAdminRequest>(); + final jsonParser = new JsonParser<GateRequest>(); + final jsonData = jsonParser.fromJson(body); + if (jsonParser.errors.length > 0) { + res.status(400).json({success: false}); + return; + } + final password = jsonData.password; + if (password == main.config.gatePassword) { + final token = getGateToken(); + res.setHeader("set-cookie", 'gate_auth=$token; Path=/; HttpOnly; SameSite=Strict'); + res.status(200).json({success: true}); + } else { + res.status(401).json({success: false}); + } + }); + } + + function hasGatePassword():Bool { + final gp = main.config.gatePassword; + return gp != null && gp.length > 0; + } + + function hasValidGateCookie(req:IncomingMessage):Bool { + final cookieHeader:String = req.headers["cookie"]; + if (cookieHeader == null) return false; + final token = getGateToken(); + final needle = 'gate_auth=$token'; + for (cookie in cookieHeader.split(";")) { + if (cookie.trim() == needle) return true; + } + return false; + } + + function getGateToken():String { + return Sha256.encode('gate_${main.config.gatePassword}_${main.config.salt}'); + } + + function hasAdminToken():Bool { + final t = main.config.adminToken; + return t != null && t.length > 0; + } + + function registerAdmin(req:IncomingMessage, res:ServerResponse) { + if (!hasAdminToken()) { + res.status(403).json({success: false, error: "Admin registration is disabled"}); + return; + } + + final bodyChunks:Array<Buffer> = []; + req.on("data", chunk -> bodyChunks.push(chunk)); + req.on("end", () -> { + final body = Buffer.concat(bodyChunks).toString(); + final jsonParser = new JsonParser<AdminRegisterRequest>(); final jsonData = jsonParser.fromJson(body); if (jsonParser.errors.length > 0) { - final errors = ErrorUtils.convertErrorArray(jsonParser.errors); - trace(errors); - res.status(400).json({success: false, errors: []}); + res.status(400).json({success: false, error: "Invalid request"}); return; } - final name = jsonData.name; + final name = jsonData.name.trim(); final password = jsonData.password; final passwordConfirmation = jsonData.passwordConfirmation; - final lang = req.headers["accept-language"] ?? "en"; - final errors:Array<{type:String, error:String}> = []; + final token = jsonData.token; + if (token != main.config.adminToken) { + res.status(401).json({success: false, error: "Invalid admin token"}); + return; + } if (main.isBadClientName(name)) { - final error = Lang.get(lang, "usernameError") - .replace("$MAX", '${main.config.maxLoginLength}'); - errors.push({ - type: "name", - error: error - }); + res.status(400).json({success: false, error: "Invalid username"}); + return; } - final min = Main.MIN_PASSWORD_LENGTH; final max = Main.MAX_PASSWORD_LENGTH; if (password.length < min || password.length > max) { - final error = Lang.get(lang, "passwordError") - .replace("$MIN", '$min').replace("$MAX", '$max'); - errors.push({ - type: "password", - error: error - }); + res.status(400).json({success: false, error: 'Password must be $min-$max characters'}); + return; } - if (password != passwordConfirmation) { - errors.push({ - type: "password", - error: Lang.get(lang, "passwordsMismatchError") - }); - } - - if (errors.length > 0) { - res.status(400).json({success: false, errors: errors}); + res.status(400).json({success: false, error: "Passwords do not match"}); return; } - main.addAdmin(name, password); res.status(200).json({success: true}); }); |
