diff --git a/.gitignore b/.gitignore index 8c8153b..4714669 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ config/**/* !config/.gitkeep install/db_setup.sql build/** +site/templates.js \ No newline at end of file diff --git a/README.md b/README.md index cddcc6a..db204c1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,15 @@ Follow the instructions after setup runs. ### Run the server ```bash -npm start +npm run start +``` +You can also skip checking the database version and compiling templates (if you don't use server-side rendering) on startup. +```bash +npm run start -- --skip-migrate --skip-compile +# don't forget to migrate manually when you update +npm run migrate +# and compile templates after any changes +npm run make-templates ``` ## Contributing diff --git a/docs/REST.md b/docs/REST.md index 3f52bb7..9058efb 100644 --- a/docs/REST.md +++ b/docs/REST.md @@ -77,7 +77,9 @@ The array will be wrapped in a JSON object under the key 'users'. ## /api/users/all -Same as above, but returns all users regardless of whether they are streaming. Also unfinished. +Same as above, but returns all users regardless of whether they are streaming and if they're streaming or not. + +**Example**: `{users: [{username:"foo", title:"bar", live:1}] }` @@ -89,7 +91,9 @@ Register a new user. **Authentication**: no -**Parameters**: Username, password, confirm +**Parameters**: Username, password, confirm, invite(optional) + +Invite is an optional invite code to bypass disabled registration. **Response**: If successful, returns a json object with the users stream key. Otherwise returns `{error: "error reason"}` @@ -111,7 +115,7 @@ Obtain a signed json web token for authentication **Response**: If succesful, will return `{success: ""}` or `{success: "already verified"}` if the JWT provided is too early to be renewed. If unsuccesful, will return `{error: "invalid password"}` or `{error: "Username or Password Incorrect"}` depending on the authentication method. Note that if a JWT is available, the parameters will be ignored. -**Notes**: I've already listed nearly every response. My final note is that the JWT is set as the cookie 'Authorization', not returned in the response. +**Notes**: The returned JWT is set as the cookie httponly 'Authorization'. It will also return a non httponly cookie X-Auth-As with the username of the authenticated user. ## /api/user/update @@ -122,9 +126,9 @@ Update the current user's information **Authentication**: yes -**Parameters**: title, bio, rec +**Parameters**: title, bio, rec, twitch, twitch_key -Rec is a boolean (whether to record VODs), others are strings. Parameters that are not included in the request will not be updated. +Rec is a boolean (whether to record VODs), twitch is a boolean (whether to mirror video streams to twitch) others are strings. Twitch_key is the stream key to use for twitch. Parameters that are not included in the request will not be updated. **Response**: Returns `{error: "error code"}` or `{success: ""}` diff --git a/install/config.example.yml b/install/config.example.yml index da27427..8925c03 100644 --- a/install/config.example.yml +++ b/install/config.example.yml @@ -9,11 +9,15 @@ media: ffmpeg: '' rtmp: + # enable cluster mode this will pretty much entirely + # break the ability to play rtmp for clients + cluster: false port: 1935 http: # uncomment to set HSTS when SSL is ready #hsts: true + server_side_render: false database: user: '' @@ -56,4 +60,12 @@ chat: enabled: false username: #https://twitchapps.com/tmi/ - password: \ No newline at end of file + password: + +twitch_mirror: +# enable to allow users to mirror video streams to twitch +# for those with truly no bandwidth limits + enabled: false + # https://stream.twitch.tv/ingests/ + # do not include {stream_key} + ingest: 'rtmp://live-ord02.twitch.tv/app/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7428239..c5e1872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "satyr", - "version": "0.7.2", + "version": "0.10.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -18,9 +18,9 @@ } }, "@types/node": { - "version": "12.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz", - "integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==" + "version": "12.12.67", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.67.tgz", + "integrity": "sha512-R48tgL2izApf+9rYNH+3RBMbRpPeW3N8f0I9HMhggeq4UXwBDqumJ14SDs4ctTMhG11pIOduZ4z3QWGOiMc9Vg==" }, "a-sync-waterfall": { "version": "1.0.1", @@ -149,12 +149,12 @@ "integrity": "sha1-/bC0OWLKe0BFanwrtI/hc9otISI=" }, "bcrypt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-3.0.6.tgz", - "integrity": "sha512-taA5bCTfXe7FUjKroKky9EXpdhkVvhE5owfxfLYodbrAR1Ul3juLmIQmIQBK4L9a5BuUcE6cqmwT+Da20lF9tg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.0.tgz", + "integrity": "sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg==", "requires": { - "nan": "2.13.2", - "node-pre-gyp": "0.12.0" + "node-addon-api": "^3.0.0", + "node-pre-gyp": "0.15.0" } }, "better-assert": { @@ -278,9 +278,9 @@ } }, "chownr": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", - "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, "code-point-at": { "version": "1.1.0", @@ -773,9 +773,9 @@ } }, "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -861,9 +861,9 @@ } }, "ignore-walk": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.2.tgz", - "integrity": "sha512-EXyErtpHbn75ZTsOADsfx6J/FPo6/5cjev46PXrcTpd8z3BoRkXgYu9/JVqrI7tusjmwCZutGeRJeU0Wo1e4Cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", "requires": { "minimatch": "^3.0.4" } @@ -950,6 +950,11 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "optional": true }, + "is-port-available": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/is-port-available/-/is-port-available-0.1.5.tgz", + "integrity": "sha512-/r7UZAQtfgDFdhxzM71jG0mkC4oSRA513cImMILdRe/+UOIe0Se/D/Z7XCua4AFg5k4Zt3ALMGaC1W3FzlrR2w==" + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -964,9 +969,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash.camelcase": { "version": "4.3.0", @@ -1042,21 +1047,26 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, "minipass": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.6.5.tgz", - "integrity": "sha512-ewSKOPFH9blOLXx0YSE+mbrNMBFPS+11a2b03QZ+P4LVrUHW/GAlqeYC7DBknDyMWkHzrzTpDhUvy7MUxqyrPA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "minizlib": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.2.tgz", - "integrity": "sha512-hR3At21uSrsjjDTWrbu0IMLTpnkpv8IIMFDFaoz43Tmu4LkmAXfH44vNNzpTnf+OAQQCHrb91y/wc2J4x5XgSQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", "requires": { - "minipass": "^2.2.1" + "minipass": "^2.9.0" } }, "mkdirp": { @@ -1065,13 +1075,6 @@ "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "requires": { "minimist": "^1.2.5" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - } } }, "moment": { @@ -1098,12 +1101,13 @@ "nan": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz", - "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==" + "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==", + "optional": true }, "needle": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", - "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.5.2.tgz", + "integrity": "sha512-LbRIwS9BfkPvNwNHlsA41Q29kL2L/6VaOJ0qisM5lLWsTV3nP15abO5ITL6L81zqFhzjRKDAYjpcBcwM0AVvLQ==", "requires": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", @@ -1115,6 +1119,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "node-addon-api": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.0.2.tgz", + "integrity": "sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg==" + }, "node-icu-charset-detector": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", @@ -1125,40 +1134,47 @@ } }, "node-media-server": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.1.3.tgz", - "integrity": "sha512-BZf39fpVDSVQT2E+8DqSVOb7oo31rcbA36l9sqtSuyZhBdxjidL5Nk2/G/2vqMGR9Q4JKzkTskGay2dWy5ZsUQ==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.2.4.tgz", + "integrity": "sha512-2Y5hZ+BI2YxM5+PiEXM9isAZUPSJoENTb0xXVzg8MzP9nFtVVv+X7+iGnFeyXB0BWaCsdBFD5A/rTL4dfaCw+Q==", "requires": { "basic-auth-connect": "^1.0.0", "chalk": "^2.4.2", "dateformat": "^3.0.3", "express": "^4.16.4", "lodash": ">=4.17.13", - "mkdirp": "^0.5.1", + "mkdirp": "1.0.3", "ws": "^5.2.2" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.3.tgz", + "integrity": "sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==" + } } }, "node-pre-gyp": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", - "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.15.0.tgz", + "integrity": "sha512-7QcZa8/fpaU/BKenjcaeFF9hLz2+7S9AqyXFhlH/rilsQ/hPZKK32RtR5EQHJElgu+q5RfbJ34KriI79UWaorA==", "requires": { "detect-libc": "^1.0.2", - "mkdirp": "^0.5.1", - "needle": "^2.2.1", + "mkdirp": "^0.5.3", + "needle": "^2.5.0", "nopt": "^4.0.1", "npm-packlist": "^1.1.6", "npmlog": "^4.0.2", "rc": "^1.2.7", "rimraf": "^2.6.1", "semver": "^5.3.0", - "tar": "^4" + "tar": "^4.4.2" } }, "nopt": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", - "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", "requires": { "abbrev": "1", "osenv": "^0.1.4" @@ -1171,17 +1187,26 @@ "optional": true }, "npm-bundled": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", - "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz", + "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==", + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==" }, "npm-packlist": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.4.tgz", - "integrity": "sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", + "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", "requires": { "ignore-walk": "^3.0.1", - "npm-bundled": "^1.0.1" + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" } }, "npmlog": { @@ -1368,13 +1393,6 @@ "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - } } }, "readable-stream": { @@ -1505,9 +1523,9 @@ "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, "signal-exit": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, "simple-websocket": { "version": "9.0.0", @@ -1790,17 +1808,6 @@ "mkdirp": "^0.5.0", "safe-buffer": "^5.1.2", "yallist": "^3.0.3" - }, - "dependencies": { - "minipass": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", - "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", - "requires": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - } } }, "to-array": { @@ -1905,9 +1912,9 @@ "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" }, "yallist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "yeast": { "version": "0.1.2", diff --git a/package.json b/package.json index f77afd1..bee4cbf 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,23 @@ { "name": "satyr", - "version": "0.9.2", + "version": "0.10.0", "description": "A livestreaming server.", "license": "AGPL-3.0", "author": "knotteye", "scripts": { "start": "ts-node src/index.ts", - "user": "ts-node src/cli.ts", - "setup": "sh install/setup.sh" + "cli": "ts-node src/cli.ts", + "setup": "sh install/setup.sh", + "migrate": "ts-node src/migrate.ts", + "invite": "ts-node src/cli.ts --invite", + "make-templates": "nunjucks-precompile -i [\"\\.html$\",\"\\.njk$\"] templates > site/templates.js" }, "repository": { "type": "git", "url": "https://gitlab.com/knotteye/satyr.git" }, "dependencies": { - "bcrypt": "^3.0.6", + "bcrypt": "^5.0.0", "body-parser": "^1.19.0", "cookie-parser": "^1.4.4", "dank-twitch-irc": "^3.2.6", @@ -23,9 +26,10 @@ "express": "^4.17.1", "flags": "^0.1.3", "irc": "^0.5.2", + "is-port-available": "^0.1.5", "jose": "^1.15.1", "mysql": "^2.17.1", - "node-media-server": ">=2.1.3 <3.0.0", + "node-media-server": "^2.2.4", "nunjucks": "^3.2.1", "parse-yaml": "^0.1.0", "recursive-readdir": "^2.2.2", @@ -36,6 +40,6 @@ "typescript": "^3.6.3" }, "devDependencies": { - "@types/node": "^12.7.5" + "@types/node": "^12.12.67" } } diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..cd6342b --- /dev/null +++ b/site/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/site/index.js b/site/index.js new file mode 100644 index 0000000..8a06b30 --- /dev/null +++ b/site/index.js @@ -0,0 +1,170 @@ +async function render(path){ + var context = await getContext(); + switch(path){ + //nothing but context + case (path.match(/^\/about\/?$/) || {}).input: + document.body.innerHTML = nunjucks.render('about.njk', context); + modifyLinks(); + break; + case (path.match(/^\/login\/?$/) || {}).input: + document.body.innerHTML = nunjucks.render('login.njk', context); + modifyLinks(); + break; + case (path.match(/^\/register\/?$/) || {}).input: + if(!context.registration) window.location = '/'; + document.body.innerHTML = nunjucks.render('registration.njk', context); + modifyLinks(); + break; + case (path.match(/^\/changepwd\/?$/) || {}).input: + document.body.innerHTML = nunjucks.render('changepwd.njk', context); + modifyLinks(); + break; + case (path.match(/^\/chat\/?$/) || {}).input: + document.body.innerHTML = nunjucks.render('chat.html', context); + modifyLinks(); + break; + case (path.match(/^\/help\/?$/) || {}).input: + document.body.innerHTML = nunjucks.render('help.njk', context); + modifyLinks(); + break; + //need to hit the API + case (path.match(/^\/users\/live\/?$/) || {}).input: + var list = JSON.parse(await makeRequest("POST", "/api/users/live", JSON.stringify({num: 50}))); + document.body.innerHTML = nunjucks.render('live.njk', Object.assign({list: list.users}, context)); + modifyLinks(); + break; + case (path.match(/^\/users\/?$/) || {}).input: + var list = JSON.parse(await makeRequest("POST", "/api/users/all", JSON.stringify({num: 50}))); + document.body.innerHTML = nunjucks.render('list.njk', Object.assign({list: list.users}, context)); + modifyLinks(); + break; + case (path.match(/^\/profile\/chat\/?$/) || {}).input: + if(!context.auth.name) window.location = '/login'; + var config = JSON.parse(await makeRequest("GET", '/api/'+context.auth.name+'/config')); + config = { + integ: { + twitch: config.twitch, + xmpp: config.xmpp, + irc: config.irc, + discord: config.discord + } + }; + document.body.innerHTML = nunjucks.render('chat_integ.njk', Object.assign(config, context)); + modifyLinks(); + break; + case (path.match(/^\/profile\/?$/) || {}).input: + if(!context.auth.name) window.location = '/login'; + var config = JSON.parse(await makeRequest("GET", '/api/'+context.auth.name+'/config')); + config = { + meta: { + title: config.title, + about: config.about + }, + rflag: {record_flag: config.record_flag}, + twitch: config.twitch_mirror + }; + document.body.innerHTML = nunjucks.render('profile.njk', Object.assign(config, context)); + modifyLinks(); + break; + //parsing slugs + case (path.match(/^\/invite\//) || {}).input: // /invite/:code + document.body.innerHTML = nunjucks.render('invite.njk', Object.assign({icode: path.substring(8)}, context)); + modifyLinks(); + break; + //slugs and API + case (path.match(/^\/users\/.+\/?$/) || {}).input: // /users/:user + if(path.substring(path.length - 1).indexOf('/') !== -1) + var usr = path.substring(7, path.length - 1); + else var usr = path.substring(7); + var config = JSON.parse(await makeRequest("GET", '/api/'+usr+'/config')); + if(!config.title){document.body.innerHTML = nunjucks.render('404.njk', context); break;} + document.body.innerHTML = nunjucks.render('user.njk', Object.assign({about: config.about, title: config.title, username: config.username}, context)); + modifyLinks(); + break; + case (path.match(/^\/vods\/.+\/manage\/?$/) || {}).input: // /vods/:user/manage + var usr = path.substring(6, (path.length - 7)); + if(context.auth.name !== usr) window.location = '/vods/'+usr; + var vods = JSON.parse(await makeRequest("GET", '/api/'+usr+'/vods')); + document.body.innerHTML = nunjucks.render('managevods.njk', Object.assign({user: usr, list: vods.vods.filter(fn => fn.name.endsWith('.mp4'))}, context)); + modifyLinks(); + break; + case (path.match(/^\/vods\/.+\/?$/) || {}).input: // /vods/:user + if(path.substring(path.length - 1).indexOf('/') !== -1) + var usr = path.substring(6, path.length - 1); + else var usr = path.substring(6); + var vods = JSON.parse(await makeRequest("GET", '/api/'+usr+'/vods')); + document.body.innerHTML = nunjucks.render('vods.njk', Object.assign({user: usr, list: vods.vods.filter(fn => fn.name.endsWith('.mp4'))}, context)); + modifyLinks(); + break; + //root + case "/": + render('/users/live'); + break; + case "": + render('/users/live'); + break; + //404 + default: + document.body.innerHTML = nunjucks.render('404.njk', context); + modifyLinks(); + } +} + +async function getContext(){ + var info = JSON.parse(await makeRequest('GET', '/api/instance/info')); + info.sitename = info.name; + info.name = null; + info.auth = { + is: document.cookie.match(/^(.*;)?\s*X-Auth-As\s*=\s*[^;]+(.*)?$/) !== null, + name: parseCookie(document.cookie)['X-Auth-As'] + } + return info; +} + +function makeRequest(method, url, payload) { + return new Promise(function (resolve, reject) { + let xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.onload = function () { + if (this.status >= 200 && this.status < 300) { + resolve(xhr.response); + } else { + reject({ + status: this.status, + statusText: xhr.statusText + }); + } + }; + xhr.onerror = function () { + reject({ + status: this.status, + statusText: xhr.statusText + }); + }; + !payload ? xhr.send() : xhr.send(payload); + }); +} + +function parseCookie(c){ + if(typeof(c) !== 'string' || !c.includes('=')) return {}; + return Object.assign({[c.split('=')[0].trim()]:c.split('=')[1].split(';')[0].trim()}, parseCookie(c.split(/;(.+)/)[1])); +} + +function handleLoad() { + var r = JSON.parse(document.getElementById('responseFrame').contentDocument.documentElement.textContent).success + if (typeof(r) !== 'undefined') window.location.href = '/profile' +} + +function modifyLinks() { + for (var ls = document.links, numLinks = ls.length, i=0; i { - if(!config['satyr']['registration']) return {"error":"registration disabled"}; +async function register(name: string, password: string, confirm: string, invite?: boolean): Promise { + if(!config['satyr']['registration'] && !invite) return {"error":"registration disabled"}; if(name.includes(';') || name.includes(' ') || name.includes('\'')) return {"error":"illegal characters"}; if(password !== confirm) return {"error":"mismatched passwords"}; for(let i=0;i{ - if(!fields['title'] && !fields['bio'] && (fields['rec'] !== 'true' && fields['rec'] !== 'false')) return {"error":"no valid fields specified"}; + if(!fields['title'] && !fields['bio'] && (fields['rec'] !== 'true' && fields['rec'] !== 'false') && (fields['twitch'] !== 'true' && fields['twitch'] !== 'false') && !fields['twitch_key']) return {"error":"no valid fields specified"}; let qs: string = ""; let f: boolean = false; if(fields['title']) {qs += ' user_meta.title='+db.raw.escape(fields['title']);f = true;} @@ -30,8 +31,19 @@ async function update(fields: object): Promise{ if(typeof(fields['rec']) === 'boolean' || typeof(fields['rec']) === 'number') { if(f) qs+=','; qs += ' users.record_flag='+db.raw.escape(fields['rec']); + f=true; + } + if(typeof(fields['twitch']) === 'boolean' || typeof(fields['twitch']) === 'number') { + if(f) qs+=','; + qs += ' twitch_mirror.enabled='+db.raw.escape(fields['twitch']); + f=true; + } + if(fields['twitch_key']){ + if(f) qs+=','; + qs += ' twitch_mirror.twitch_key='+db.raw.escape(fields['twitch_key']); + f = true; } - await db.query('UPDATE users,user_meta SET'+qs+' WHERE users.username='+db.raw.escape(fields['name'])+' AND user_meta.username='+db.raw.escape(fields['name'])); + await db.query('UPDATE users,user_meta,twitch_mirror SET'+qs+' WHERE users.username='+db.raw.escape(fields['name'])+' AND user_meta.username='+db.raw.escape(fields['name'])+' AND twitch_mirror.username='+db.raw.escape(fields['name'])); return {success:""}; } @@ -75,9 +87,11 @@ async function getConfig(username: string, all?: boolean): Promise{ let users = await db.query('SELECT stream_key,record_flag FROM users WHERE username='+db.raw.escape(username)); if(users[0]) Object.assign(t, users[0]); let usermeta = await db.query('SELECT title,about FROM user_meta WHERE username='+db.raw.escape(username)); - if(usermeta[0]) Object.assign(t, users[0]); + if(usermeta[0]) Object.assign(t, usermeta[0]); let ci = await db.query('SELECT irc,xmpp,twitch,discord FROM chat_integration WHERE username='+db.raw.escape(username)); if(ci[0]) Object.assign(t, ci[0]); + let tw = await db.query('SELECT enabled,twitch_key FROM twitch_mirror WHERE username='+db.raw.escape(username)); + if(tw[0]) t['twitch_mirror'] = Object.assign({}, tw[0]); } else { let um = await db.query('SELECT title,about FROM user_meta WHERE username='+db.raw.escape(username)); @@ -86,4 +100,21 @@ async function getConfig(username: string, all?: boolean): Promise{ return t; } -export { register, update, changepwd, changesk, login, updateChat, deleteVODs, getConfig }; \ No newline at end of file +async function genInvite(): Promise{ + var invitecode: string = base64id.generateId(); + await db.query('INSERT INTO invites (code) VALUES (\"'+invitecode+'\")'); + return invitecode; +} + +async function validInvite(code: string): Promise{ + if(typeof(code) !== "string" || code === "") return false; + var result = await db.query('SELECT code FROM invites WHERE code='+db.raw.escape(code)); + if(!result[0] || result[0]['code'] !== code) return false; + return true; +} + +async function useInvite(code: string): Promise{ + if(validInvite(code)) await db.query('DELETE FROM invites WHERE code='+db.raw.escape(code)); +} + +export { register, update, changepwd, changesk, login, updateChat, deleteVODs, getConfig, genInvite, useInvite, validInvite }; \ No newline at end of file diff --git a/src/cleanup.ts b/src/cleanup.ts index 00e55ae..4a3fd58 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,10 +1,52 @@ import * as db from "./database"; +import {readdirSync} from "fs"; +import { execSync } from "child_process"; async function init() { + if(process.argv.indexOf('--skip-migrate') === -1){ + console.log('Checking database version.'); + var tmp: string[] = await db.query('show tables like \"db_meta\"'); + if(tmp.length === 0){ + console.log('No database version info, running initial migration.'); + await require('./db/0').run(); + await bringUpToDate(); + } + else { + await bringUpToDate(); + } + } + else { + console.log('Skipping database version check.'); + } + + if(!require('./config').config['http']['server_side_render'] && process.argv.indexOf('--skip-compile') === -1) { + console.log("Compiling templates for client-side frontend."); + execSync(process.cwd()+'/node_modules/.bin/nunjucks-precompile -i [\"\\.html$\",\"\\.njk$\"] templates > site/templates.js'); + } + else if(!require('./config').config['http']['server_side_render']){ + console.log("Skipped compiling templates for client-side frontend."); + } + //If satyr is restarted in the middle of a stream //it causes problems //Live flags in the database stay live await db.query('update user_meta set live=false'); } +async function bringUpToDate(): Promise{ + var versions: Object[] = await db.query('select * from db_meta'); + var scripts: Buffer[] | string[] = readdirSync('./src/db/', {withFileTypes: false}); + var diff: number = scripts.length - versions.length + if(diff === 0){ + console.log('No migration needed.'); + } else { + console.log('Versions differ, migrating now.'); + for(let i=0;i { + console.log('invite code: '+r); + console.log('Direct the user to https://'+config['satyr']['domain']+'/invite/'+r); + process.exit(); + }); } \ No newline at end of file diff --git a/src/cluster.ts b/src/cluster.ts new file mode 100644 index 0000000..4a5181e --- /dev/null +++ b/src/cluster.ts @@ -0,0 +1,289 @@ +import * as cluster from 'cluster'; +import * as net from 'net'; +import * as NodeRtmpSession from '../node_modules/node-media-server/node_rtmp_session'; +import * as logger from '../node_modules/node-media-server/node_core_logger'; +import * as dirty from "dirty"; +import { mkdir, fstat, access } from "fs"; +import * as strf from "strftime"; +import * as ctx from '../node_modules/node-media-server/node_core_ctx'; +import * as db from "./database"; +import {config} from "./config"; +import * as isPortAvailable from "is-port-available"; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const { exec, execFile } = require('child_process'); + +const keystore = dirty(); +const num_processes = require('os').cpus().length; +const workerMap = {}; + +if (cluster.isMaster) { + //master logic + + //store workers in here + var workers = []; + + // Helper function for spawning worker at index 'i'. + var spawn = function(i) { + workers[i] = cluster.fork(); + workers[i].on('message', (msg) => { + handleMsgMaster(msg, i) + }); + + // Restart worker on exit + workers[i].on('exit', function(code, signal) { + console.log('[RTMP Cluster MASTER] Respawning Worker', i); + spawn(i); + }); + }; + + // Spawn initial workers + for (var i = 0; i < num_processes; i++) { + spawn(i); + } + + var nextWorker: number = 0; + + //TODO assign incoming connections correctly + + var server = net.createServer({ pauseOnConnect: true }, function(connection) { + if(nextWorker >= workers.length) nextWorker = 0; + var worker = workers[nextWorker]; + worker.send('rtmp-session:connection', connection); //send connection to worker + }).listen(config['rtmp']['port']); + + console.log('[RTMP Cluster MASTER] Master Ready.'); +} else { + + //worker logic + + //we need our own database pool since we can't share memory anyone else + db.initRTMPCluster(); + + const rtmpcfg = { + logType: 0, + rtmp: Object.assign({port: 1936}, config['rtmp']) + }; + + //find a unique port to listen on + getPort().then((wPort) => { + + // creating the rtmp server + var serv = net.createServer((socket) => { + let session = new NodeRtmpSession(rtmpcfg, socket); + session.run(); + }).listen(wPort); + logger.setLogType(0); + + // RTMP Server Logic + newRTMPListener('postPublish', (id, StreamPath, args) =>{ + console.log(`[RTMP Cluster WORKER ${process.pid}] Publish Hook for stream: ${id}`); + let session = getRTMPSession(id); + let app: string = StreamPath.split("/")[1]; + let key: string = StreamPath.split("/")[2]; + //disallow urls not formatted exactly right + if (StreamPath.split("/").length !== 3 || key.includes(' ')){ + console.log(`[RTMP Cluster WORKER ${process.pid}] Malformed URL, closing connection for stream: ${id}`); + session.reject(); + return false; + } + if(app !== config['media']['privateEndpoint']){ + //app isn't at public endpoint if we've reached this point + console.log(`[RTMP Cluster WORKER ${process.pid}] Wrong endpoint, rejecting stream: ${id}`); + session.reject(); + return false; + } + //if the url is formatted correctly and the user is streaming to the correct private endpoint + //grab the username from the database and redirect the stream there if the key is valid + //otherwise kill the session + db.query('select username,record_flag from users where stream_key='+db.raw.escape(key)+' limit 1').then(async (results) => { + if(results[0]){ + //transcode to mpd after making sure directory exists + keystore[results[0].username] = key; + mkdir(config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+results[0].username, { recursive : true }, ()=>{;}); + while(true){ + if(session.audioCodec !== 0 && session.videoCodec !== 0){ + transCommand(results[0].username, key, wPort).then((r) => { + execFile(config['media']['ffmpeg'], r, {maxBuffer: Infinity}, (err, stdout, stderr) => { + /*console.log(err); + console.log(stdout); + console.log(stderr);*/ + }); + }); + break; + } + await sleep(300); + } + if(results[0].record_flag && config['media']['record']){ + console.log(`[RTMP Cluster WORKER ${process.pid}] Initiating recording for stream: ${id}`); + mkdir(config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+results[0].username, { recursive : true }, (err) => { + if (err) throw err; + execFile(config['media']['ffmpeg'], ['-loglevel', 'fatal', '-i', 'rtmp://127.0.0.1:'+wPort+'/'+config['media']['privateEndpoint']+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+results[0].username+'/'+strf('%d%b%Y-%H%M')+'.mp4'], { + detached : true, + stdio : 'inherit', + maxBuffer: Infinity + }).unref(); + //spawn an ffmpeg process to record the stream, then detach it completely + //ffmpeg can then (probably) finalize the recording if satyr crashes mid-stream + }); + } + else { + console.log(`[RTMP Cluster WORKER ${process.pid}] Skipping recording for stream: ${id}`); + } + db.query('update user_meta set live=true where username=\''+results[0].username+'\' limit 1'); + db.query('SELECT twitch_key,enabled from twitch_mirror where username='+db.raw.escape(results[0].username)+' limit 1').then(async (tm) => { + if(!tm[0]['enabled'] || !config['twitch_mirror']['enabled'] || !config['twitch_mirror']['ingest']) return; + console.log('[NodeMediaServer] Mirroring to twitch for stream:',id) + execFile(config['media']['ffmpeg'], ['-loglevel', 'fatal', '-i', 'rtmp://127.0.0.1:'+wPort+'/'+config['media']['privateEndpoint']+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', '-f', 'flv', config['twitch_mirror']['ingest']+tm[0]['twitch_key']], { + detached: true, + stdio : 'inherit', + maxBuffer: Infinity + }).unref(); + }); + console.log('[NodeMediaServer] Stream key ok for stream:',id); + console.log(`[RTMP Cluster WORKER ${process.pid}] Stream key ok for stream: ${id}`); + //notify master process that we're handling the stream for this user + process.send({type: 'handle-publish', name:results[0].username}); + } + else{ + console.log(`[RTMP Cluster WORKER ${process.pid}] Invalid stream key for stream: ${id}`); + session.reject(); + } + }); + }); + + newRTMPListener('donePublish', (id, StreamPath, args) => { + let app: string = StreamPath.split("/")[1]; + let key: string = StreamPath.split("/")[2]; + if(app === config['media']['privateEndpoint']) { + db.query('update user_meta,users set user_meta.live=false where users.stream_key='+db.raw.escape(key)); + db.query('select username from users where stream_key='+db.raw.escape(key)+' limit 1').then(async (results) => { + if(results[0]) keystore.rm(results[0].username); + //notify master process that we're no longer handling the stream for this user + process.send({type: 'handle-publish-done', name:results[0].username}); + }); + } + }); + + newRTMPListener('prePlay', (id, StreamPath, args) => { + let session = getRTMPSession(id); + let app: string = StreamPath.split("/")[1]; + let key: string = StreamPath.split("/")[2]; + //correctly formatted urls again + if (StreamPath.split("/").length !== 3){ + console.log("[NodeMediaServer] Malformed URL, closing connection for stream:",id); + session.reject(); + return false; + } + //localhost can play from whatever endpoint + //other clients must use private endpoint + if(app !== config['media']['publicEndpoint'] && !session.isLocal) { + console.log("[NodeMediaServer] Non-local Play from private endpoint, rejecting client:",id); + session.reject(); + return false; + } + //rewrite playpath to private endpoint serverside + //(hopefully) + if(app === config['media']['publicEndpoint']) { + if(keystore[key]){ + session.playStreamPath = '/'+config['media']['privateEndpoint']+'/'+keystore[key]; + return true; + } + //here the client is asking for a valid stream that we don't have + //so we are going to ask the master process for it + else session.reject(); + } + }); + + //recieve messages from master + process.on('message', function(message, connection) { + if (message === 'rtmp-session:connection') { + // Emulate a connection event on the server by emitting the + // event with the connection the master sent us. + serv.emit('connection', connection); + connection.resume(); + return; + } + if(message['type'] === 'stream-request:h') { + if(!message['available']) + getRTMPSession(message['id']).reject(); + } + }); + console.log(`[RTMP Cluster WORKER ${process.pid}] Worker Ready.`); + + }); +} + +function newRTMPListener(eventName, listener) { + ctx.nodeEvent.on(eventName, listener); +} + +function getRTMPSession(id) { + return ctx.sessions.get(id); +} + +async function getPort(): Promise{ + let port = 1936+process.pid; + while(true){ + let i=0; + if(await isPortAvailable(port+i)){ + port += i; + break; + } + i++; + } + return port; +} + +async function transCommand(user: string, key: string, wPort): Promise{ + let args: string[] = ['-loglevel', 'fatal', '-y']; + if(config['transcode']['inputflags'] !== null && config['transcode']['inputflags'] !== "") args = args.concat(config['transcode']['inputflags'].split(" ")); + args = args.concat(['-i', 'rtmp://127.0.0.1:'+wPort+'/'+config['media']['privateEndpoint']+'/'+key, '-movflags', '+faststart']); + if(config['transcode']['adaptive']===true && config['transcode']['variants'] > 1) { + for(let i=0;i 51 ? 51 : Math.floor(18 + (i * 7)); + args = args.concat(['-crf:'+i, ''+crf]); + } + for(let i=1;i= workers.length) nextWorker = 0; + } + if(msg['type'] === 'handle-publish-done'){ + workerMap[msg['name']] = undefined; + } + if(msg['type'] === 'stream-request:h'){ + if(workerMap[msg['key']] !== undefined){ + workers[index].send({type: 'stream-request:h', id: msg['id'], key: msg['key'], available: true}); + } + else { + workers[index].send({type: 'stream-request:h', id: msg['id'], key: msg['key'], available: false}); + } + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 91db07f..d9edb2e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import {parseAsYaml as parse} from "parse-yaml"; import {readFileSync as read} from "fs"; try { var localconfig: Object = parse(read('config/config.yml')); + console.log('Config file found.'); } catch (e) { console.log('No config file found. Exiting.'); process.exit(); @@ -15,7 +16,7 @@ const config: Object = { domain: '', registration: false, email: null, - restrictedNames: [ 'live', 'user', 'users', 'register', 'login' ], + restrictedNames: [ 'live', 'user', 'users', 'register', 'login', 'invite' ], rootredirect: '/users/live', version: process.env.npm_package_version, }, localconfig['satyr']), @@ -35,7 +36,10 @@ const config: Object = { ping: 30, ping_timeout: 60 }, localconfig['rtmp']), http: Object.assign({ - hsts: false, directory: './site', port: 8000 + hsts: false, + directory: './site', + port: 8000, + server_side_render: true }, localconfig['http']), media: Object.assign({ record: false, @@ -80,6 +84,10 @@ const config: Object = { username: null, token: null }, localconfig['chat']['twitch']) - } + }, + twitch_mirror: Object.assign({ + enabled: false, + ingest: null + }, localconfig['twitch_mirror']) }; export { config }; \ No newline at end of file diff --git a/src/database.ts b/src/database.ts index 1d9a9c7..0d733e7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -9,6 +9,14 @@ var cryptoconfig: Object; function init (){ raw = mysql.createPool(config['database']); cryptoconfig = config['crypto']; + console.log('Connected to database.'); +} + +function initRTMPCluster(){ + let cfg = config['database']; + cfg['connectionLimit'] = Math.floor(config['database']['connectionLimit'] / require('os').cpus().length); + raw = mysql.createPool(cfg); + cryptoconfig = config['crypto']; } async function addUser(name: string, password: string){ @@ -21,6 +29,7 @@ async function addUser(name: string, password: string){ await query('INSERT INTO users (username, password_hash, stream_key, record_flag) VALUES ('+raw.escape(name)+', '+raw.escape(hash)+', '+raw.escape(key)+', 0)'); await query('INSERT INTO user_meta (username, title, about, live) VALUES ('+raw.escape(name)+',\'\',\'\',false)'); await query('INSERT INTO chat_integration (username, irc, xmpp, twitch, discord) VALUES ('+raw.escape(name)+',\'\',\'\',\'\',\'\')'); + await query('INSERT INTO twitch_mirror (username) VALUES ('+raw.escape(name)+')'); return true; } @@ -29,6 +38,8 @@ async function rmUser(name: string){ if(!exist[0]) return false; await query('delete from users where username='+raw.escape(name)+' limit 1'); await query('delete from user_meta where username='+raw.escape(name)+' limit 1'); + await query('delete from chat_integration where username='+raw.escape(name)+' limit 1'); + await query('delete from twitch_mirror where username='+raw.escape(name)+' limit 1'); return true; } @@ -59,4 +70,4 @@ async function hash(pwd){ return await bcrypt.hash(pwd, cryptoconfig['saltRounds']); } -export { query, raw, init, addUser, rmUser, validatePassword, hash, genKey }; \ No newline at end of file +export { query, raw, init, addUser, rmUser, validatePassword, hash, genKey, initRTMPCluster }; \ No newline at end of file diff --git a/src/db/0.ts b/src/db/0.ts new file mode 100644 index 0000000..d9de386 --- /dev/null +++ b/src/db/0.ts @@ -0,0 +1,8 @@ +import * as db from "../database"; + +async function run () { + await db.query('CREATE TABLE IF NOT EXISTS db_meta(version SMALLINT)'); + await db.query('INSERT INTO db_meta (version) VALUES (0)'); +} + +export { run } \ No newline at end of file diff --git a/src/db/1.ts b/src/db/1.ts new file mode 100644 index 0000000..55b8d89 --- /dev/null +++ b/src/db/1.ts @@ -0,0 +1,9 @@ +import * as db from "../database"; + +async function run () { + await db.query('CREATE TABLE IF NOT EXISTS twitch_mirror(username VARCHAR(25), enabled TINYINT DEFAULT 0, twitch_key VARCHAR(50) DEFAULT \"\")'); + await db.query('INSERT INTO twitch_mirror(username) SELECT username FROM users'); + await db.query('INSERT INTO db_meta (version) VALUES (1)'); +} + +export { run } \ No newline at end of file diff --git a/src/db/2.ts b/src/db/2.ts new file mode 100644 index 0000000..cfe7ae0 --- /dev/null +++ b/src/db/2.ts @@ -0,0 +1,8 @@ +import * as db from "../database"; + +async function run () { + await db.query('CREATE TABLE IF NOT EXISTS invites(code VARCHAR(150))'); + await db.query('INSERT INTO db_meta (version) VALUES (2)'); +} + +export { run } \ No newline at end of file diff --git a/src/http.ts b/src/http.ts index ef4e127..f83b61b 100644 --- a/src/http.ts +++ b/src/http.ts @@ -12,9 +12,6 @@ import * as chatInteg from "./chat"; import { config } from "./config"; import { readdir, readFileSync, writeFileSync } from "fs"; import { JWT, JWK } from "jose"; -import { strict } from "assert"; -import { parse } from "path"; -import { isBuffer } from "util"; const app = express(); const server = http.createServer(app); @@ -55,12 +52,16 @@ async function init(){ }); } app.disable('x-powered-by'); - //site handlers + //server-side site routes + if(config['http']['server_side_render']) await initSite(config['satyr']['registration']); - //api handlers + //api routes await initAPI(); - //static files if nothing else matches first + //static files if nothing else matches app.use(express.static(config['http']['directory'])); + //client-side site routes + if(!config['http']['server_side_render']) + await initFE(); //404 Handler app.use(function (req, res, next) { if(tryDecode(req.cookies.Authorization)) { @@ -73,6 +74,21 @@ async function init(){ server.listen(config['http']['port']); } +async function initFE(){ + app.get('/', (req, res) => { + res.redirect(config['satyr']['rootredirect']); + }); + app.get('/nunjucks-slim.js', (req, res) => { + res.sendFile(process.cwd()+'/node_modules/nunjucks/browser/nunjucks-slim.js'); + }); + app.get('/chat', (req, res) => { + res.sendFile(process.cwd()+'/templates/chat.html'); + }); + app.get('*', (req, res) => { + res.sendFile(process.cwd()+'/'+config['http']['directory']+'/index.html'); + }); +} + async function newNick(socket, skip?: boolean, i?: number) { if(socket.handshake.headers['cookie'] && !skip){ let c = await parseCookie(socket.handshake.headers['cookie']); @@ -156,7 +172,7 @@ async function initAPI() { ping_timeout: config['rtmp']['ping_timeout'] }, media: { - vods: config['config']['media']['record'], + vods: config['media']['record'], publicEndpoint: config['media']['publicEndpoint'], privateEndpoint: config['media']['privateEndpoint'], adaptive: config['transcode']['adaptive'] @@ -194,7 +210,7 @@ async function initAPI() { }); }); app.post('/api/users/all', (req, res) => { - let qs = 'SELECT username,title FROM user_meta'; + let qs = 'SELECT username,title,live FROM user_meta'; if(req.body.sort) { switch (req.body.sort) { @@ -224,6 +240,23 @@ async function initAPI() { }); }); app.post('/api/register', (req, res) => { + if("invite" in req.body){ + api.validInvite(req.body.invite).then((v) => { + if(v){ + api.register(req.body.username, req.body.password, req.body.confirm, true).then((result) => { + if(result[0]) return genToken(req.body.username).then((t) => { + res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); + res.json(result); + api.useInvite(req.body.invite); + return; + }); + res.json(result); + }); + } + else res.json({error: "invalid invite code"}); + }); + } + else api.register(req.body.username, req.body.password, req.body.confirm).then( (result) => { if(result[0]) return genToken(req.body.username).then((t) => { res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); @@ -238,10 +271,14 @@ async function initAPI() { if(t) { if(req.body.record === "true") req.body.record = true; else if(req.body.record === "false") req.body.record = false; + if(req.body.twitch === "true") req.body.twitch = true; + else if(req.body.twitch === "false") req.body.twitch = false; return api.update({name: t['username'], title: "title" in req.body ? req.body.title : false, bio: "bio" in req.body ? req.body.bio : false, - rec: "record" in req.body ? req.body.record : "NA" + rec: "record" in req.body ? req.body.record : "NA", + twitch: "twitch" in req.body ? req.body.twitch: "NA", + twitch_key: "twitch_key" in req.body ? req.body.twitch_key : false }).then((r) => { res.json(r); return; @@ -340,6 +377,7 @@ async function initAPI() { if(req.cookies.Authorization) validToken(req.cookies.Authorization).then((t) => { if(t) { if(t['exp'] - 86400 < Math.floor(Date.now() / 1000)){ + res.cookie('X-Auth-As', t['username'], {maxAge: 604800000, httpOnly: false, sameSite: 'Lax'}); return genToken(t['username']).then((t) => { res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); res.json({success:""}); @@ -361,6 +399,7 @@ async function initAPI() { if(!result){ genToken(req.body.username).then((t) => { res.cookie('Authorization', t, {maxAge: 604800000, httpOnly: true, sameSite: 'Lax'}); + res.cookie('X-Auth-As', req.body.username, {maxAge: 604800000, httpOnly: false, sameSite: 'Lax'}); res.json({success:""}); }) } @@ -482,6 +521,12 @@ async function initSite(openReg) { } else res.render('login.njk',njkconf); }); + app.get('/invite/:code', (req, res) => { + if(tryDecode(req.cookies.Authorization)) { + res.redirect('/profile'); + } + else res.render('invite.njk',Object.assign({icode: req.params.code}, njkconf)); + }); app.get('/register', (req, res) => { if(tryDecode(req.cookies.Authorization) || !openReg) { res.redirect(njkconf.rootredirect); @@ -492,7 +537,9 @@ async function initSite(openReg) { if(tryDecode(req.cookies.Authorization)) { db.query('select * from user_meta where username='+db.raw.escape(JWT.decode(req.cookies.Authorization)['username'])).then((result) => { db.query('select record_flag from users where username='+db.raw.escape(JWT.decode(req.cookies.Authorization)['username'])).then((r2) => { - res.render('profile.njk', Object.assign({rflag: r2[0]}, {meta: result[0]}, {auth: {is: true, name: JWT.decode(req.cookies.Authorization)['username']}}, njkconf)); + db.query('select enabled from twitch_mirror where username='+db.raw.escape(JWT.decode(req.cookies.Authorization)['username'])).then((r3) => { + res.render('profile.njk', Object.assign({twitch: r3[0]}, {rflag: r2[0]}, {meta: result[0]}, {auth: {is: true, name: JWT.decode(req.cookies.Authorization)['username']}}, njkconf)); + }); }); }); //res.render('profile.njk', Object.assign({auth: {is: true, name: JWT.decode(req.cookies.Authorization)['username']}}, njkconf)); diff --git a/src/index.ts b/src/index.ts index 2573948..d2c3f15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,12 +4,13 @@ import {init as initHTTP} from "./http"; import {init as clean} from "./cleanup"; import {init as initChat} from "./chat"; import { config } from "./config"; +import { execFile } from "child_process"; async function run() { await initDB(); await clean(); await initHTTP(); - await initRTMP(); + config['rtmp']['cluster'] ? execFile(process.cwd()+'/node_modules/.bin/ts-node' [process.cwd()+'src/cluster.ts']) : await initRTMP(); await initChat(); console.log(`Satyr v${config['satyr']['version']} ready`); } diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..06284c4 --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,9 @@ +import {init as initDB} from "./database"; +import {init as clean} from "./cleanup"; +import { config } from "./config"; + +async function run() { + await initDB(); + await clean(false); +} +run().then(() => {process.exit()}); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index dd58ee5..dd60538 100644 --- a/src/server.ts +++ b/src/server.ts @@ -68,6 +68,15 @@ function init () { console.log('[NodeMediaServer] Skipping recording for stream:',id); } db.query('update user_meta set live=true where username=\''+results[0].username+'\' limit 1'); + db.query('SELECT twitch_key,enabled from twitch_mirror where username='+db.raw.escape(results[0].username)+' limit 1').then(async (tm) => { + if(!tm[0]['enabled'] || !config['twitch_mirror']['enabled'] || !config['twitch_mirror']['ingest']) return; + console.log('[NodeMediaServer] Mirroring to twitch for stream:',id) + execFile(config['media']['ffmpeg'], ['-loglevel', 'fatal', '-i', 'rtmp://127.0.0.1:'+config['rtmp']['port']+'/'+config['media']['privateEndpoint']+'/'+key, '-vcodec', 'copy', '-acodec', 'copy', '-f', 'flv', config['twitch_mirror']['ingest']+tm[0]['twitch_key']], { + detached: true, + stdio : 'inherit', + maxBuffer: Infinity + }).unref(); + }); console.log('[NodeMediaServer] Stream key ok for stream:',id); } else{ diff --git a/templates/base.njk b/templates/base.njk index 9d57b04..f832bc3 100644 --- a/templates/base.njk +++ b/templates/base.njk @@ -6,7 +6,7 @@ {{ sitename }}