Merge branch 'develop' into 'master'

Develop -> Master

See merge request knotteye/satyr!29
pull/2/head^2 v0.10.0
knotteye 4 years ago
commit 4d36c2c429
  1. 1
      .gitignore
  2. 10
      README.md
  3. 14
      docs/REST.md
  4. 14
      install/config.example.yml
  5. 173
      package-lock.json
  6. 16
      package.json
  7. 23
      site/index.html
  8. 170
      site/index.js
  9. 43
      src/api.ts
  10. 42
      src/cleanup.ts
  11. 13
      src/cli.ts
  12. 289
      src/cluster.ts
  13. 14
      src/config.ts
  14. 13
      src/database.ts
  15. 8
      src/db/0.ts
  16. 9
      src/db/1.ts
  17. 8
      src/db/2.ts
  18. 67
      src/http.ts
  19. 3
      src/index.ts
  20. 9
      src/migrate.ts
  21. 9
      src/server.ts
  22. 2
      templates/base.njk
  23. 20
      templates/invite.njk
  24. 2
      templates/managevods.njk
  25. 4
      templates/profile.njk

1
.gitignore vendored

@ -4,3 +4,4 @@ config/**/*
!config/.gitkeep
install/db_setup.sql
build/**
site/templates.js

@ -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

@ -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: ""}`

@ -9,11 +9,15 @@ media:
ffmpeg: '<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: '<dbuser>'
@ -56,4 +60,12 @@ chat:
enabled: false
username:
#https://twitchapps.com/tmi/
password:
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/

173
package-lock.json generated

@ -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",

@ -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"
}
}

@ -0,0 +1,23 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" type="text/css" href="/styles.css">
<link rel="stylesheet" type="text/css" href="/local.css">
<link rel="icon" type="image/svg" href="/logo.svg">
<script src="/nunjucks-slim.js"></script>
<script src="/templates.js"></script>
<script>
nunjucks.configure({ autoescape: true });
</script>
<script>
//should check for and refresh login tokens on pageload..
if(document.cookie.match(/^(.*;)?\s*X-Auth-As\s*=\s*[^;]+(.*)?$/) !== null) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/login", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.send("");
}
</script>
</head>
<body onload="render(window.location.pathname)">
<script src="/index.js"></script>
</body>

@ -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<numLinks; i++){
if(ls[i].href.indexOf(location.protocol+'//'+location.host) !== -1) {
//should be a regular link
ls[i].setAttribute('onclick', 'return internalLink(\"'+ls[i].href.substring((location.protocol+'//'+location.host).length)+'\")');
}
}
}
function internalLink(path){
this.render(path);
return false;
}

@ -1,9 +1,10 @@
import * as db from "./database";
import * as base64id from "base64id";
import { config } from "./config";
import {unlink} from "fs";
async function register(name: string, password: string, confirm: string): Promise<object> {
if(!config['satyr']['registration']) return {"error":"registration disabled"};
async function register(name: string, password: string, confirm: string, invite?: boolean): Promise<object> {
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<config['satyr']['restrictedNames'].length;i++){
@ -18,7 +19,7 @@ async function register(name: string, password: string, confirm: string): Promis
}
async function update(fields: object): Promise<object>{
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<object>{
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<object>{
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<object>{
return t;
}
export { register, update, changepwd, changesk, login, updateChat, deleteVODs, getConfig };
async function genInvite(): Promise<string>{
var invitecode: string = base64id.generateId();
await db.query('INSERT INTO invites (code) VALUES (\"'+invitecode+'\")');
return invitecode;
}
async function validInvite(code: string): Promise<boolean>{
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<void>{
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 };

@ -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<void>{
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<diff;i++){
console.log('Migration to version '+Math.floor(scripts.length-(diff-i)));
await require('./db/'+scripts[Math.floor(scripts.length-(diff-i))]).run();
}
console.log('Done migrating database.');
}
}
export { init };

@ -1,4 +1,5 @@
import * as db from "./database"
import * as db from "./database";
import * as api from "./api";
import * as flags from "flags";
db.init();
@ -6,6 +7,7 @@ db.init();
flags.defineString('adduser', '', 'User to add');
flags.defineString('rmuser', '', 'User to remove');
flags.defineString('password', '', 'password to hash');
flags.defineBoolean('invite', false, 'generate invite code');
flags.parse();
@ -23,4 +25,13 @@ if(flags.get('rmuser') !== ''){
else console.log("Could not remove user.");
process.exit();
});
}
if(flags.get('invite')){
var config = require("./config").config;
api.genInvite().then((r: string) => {
console.log('invite code: '+r);
console.log('Direct the user to https://'+config['satyr']['domain']+'/invite/'+r);
process.exit();
});
}

@ -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<number>{
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<string[]>{
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<config['transcode']['variants'];i++){
args = args.concat(['-map', '0:2']);
}
args = args.concat(['-map', '0:1', '-c:a', 'aac', '-c:v:0', 'libx264']);
for(let i=1;i<config['transcode']['variants'];i++){
args = args.concat(['-c:v:'+i, 'libx264',]);
}
for(let i=1;i<config['transcode']['variants'];i++){
let crf: number = Math.floor(18 + (i * 8)) > 51 ? 51 : Math.floor(18 + (i * 7));
args = args.concat(['-crf:'+i, ''+crf]);
}
for(let i=1;i<config['transcode']['variants'];i++){
let bv: number = Math.floor((5000 / config['transcode']['variants']) * (config['transcode']['variants'] - i));
args = args.concat(['-b:v:'+i, ''+bv]);
}
}
else {
args = args.concat(['-c:a', 'aac', '-c:v', 'libx264']);
}
args = args.concat(['-preset', 'veryfast', '-tune', 'zerolatency']);
//if(config['transcode']['format'] === 'dash')
args = args.concat(['-remove_at_exit', '1', '-seg_duration', '1', '-window_size', '30']);
if(config['transcode']['outputflags'] !== null && config['transcode']['outputflags'] !== "") args = args.concat(config['transcode']['outputflags'].split(" "));
args = args.concat(['-f', 'dash', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.mpd']);
//else if(config['transcode']['format'] === 'hls')
//args = args.concat(['-remove_at_exit', '1', '-hls_time', '1', '-hls_list_size', '30', '-f', 'hls', config['http']['directory']+'/'+config['media']['publicEndpoint']+'/'+user+'/index.m3u8']);
return args;
}
function handleMsgMaster(msg, index) {
if(msg['type'] === 'handle-publish'){
workerMap[msg['name']] = index;
nextWorker++;
if(nextWorker >= 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});
}
}
}

@ -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 };

@ -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 };
export { query, raw, init, addUser, rmUser, validatePassword, hash, genKey, initRTMPCluster };

@ -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 }

@ -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 }

@ -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 }

@ -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));

@ -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`);
}

@ -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()});

@ -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{

@ -6,7 +6,7 @@
<title>{{ sitename }}</title>
<script>
//should check for and refresh login tokens on pageload..
if(document.cookie.match(/^(.*;)?\s*Authorization\s*=\s*[^;]+(.*)?$/) !== null) {
if(document.cookie.match(/^(.*;)?\s*X-Auth-As\s*=\s*[^;]+(.*)?$/) !== null) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/api/login", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

@ -0,0 +1,20 @@
{% extends "base.njk" %}
{% block content %}
<h3>You've been invited to {{ sitename }}</h3><span style="font-size: small;">Already registered? Log in <a href="/login">here</a>.</br></br></span>
<!--<div id="jscontainer" style="height: 100%;">
<div id="jschild" style="width: 50%;height: 100%;text-align: left;margin: 20px;">-->
<form action="/api/register" method="POST" target="responseFrame">
Username: </br><input type="text" name="username" style="min-width: 300px" placeholder="e.g. lain"/></br>
Password: </br><input type="password" name="password" style="min-width: 300px"/></br>
Confirm: </br><input type="password" name="confirm" style="min-width: 300px"/></br></br>
<input type="hidden" name="invite" style="min-width: 300px" value="{{icode}}"/>
<input type="submit" value="Submit">
</form></br>
<!--</div>
<div id="jschild" style="width: 50%;height: 100%;text-align: left;margin: 20px;">-->
{% include "tos.html" %}</br>
<iframe name="responseFrame" border="0" frameborder="0" style="display: inline;"></iframe>
<!--</div>
</div>-->
{% endblock %}

@ -8,7 +8,7 @@
{% else %}
No recordings found!
{% endeach %}
<input type="submit" value="Delete">
</br><input type="submit" value="Delete">
</form>
<iframe name="responseFrame" border="0" frameborder="0" style="display: inline;"></iframe>
{% endblock %}

@ -5,7 +5,9 @@
<form action="/api/user/update" method="POST" target="responseFrame" id="profile">
Stream Title: </br><textarea form="profile" name="title" style="min-width: 320px;resize: none;font-size: large;text-align: center;" value="{{meta.title}}">{{meta.title}}</textarea></br>
Bio: </br><textarea form="profile" name="bio" style="min-width: 320px; min-height: 150px;resize: none;font-size: inherit;" value="{{meta.about}}">{{meta.about}}</textarea></br>
Record VODs: <input type="radio" name="record" value="true" {% if rflag.record_flag %}checked{% endif %}> Yes<input type="radio" name="record" value="false" {% if rflag.record_flag %}{% else %}checked{% endif %}/> No</br></br>
ReStream to Twitch: <input type="radio" name="twitch" value="true" {% if twitch.enabled %}checked{% endif %}> Yes<input type="radio" name="twitch" value="false" {% if twitch.enabled %}{% else %}checked{% endif %}/> No</br>
Record VODs: <input type="radio" name="record" value="true" {% if rflag.record_flag %}checked{% endif %}> Yes<input type="radio" name="record" value="false" {% if rflag.record_flag %}{% else %}checked{% endif %}/> No</br>
Twitch Key: <textarea form="profile" name="twitch_key" style="max-height: 18px;min-width: 238px;resize: none;font-size: large;text-align: center;"></textarea></br></br>
<input type="submit" value="Update Profile">
</form></br>
<form action="/api/user/streamkey" method="POST" target="responseFrame">