Browse Source

Initial commit after cutting off history.

master
Zed A. Shaw 2 months ago
commit
3238fb0ad6
  1. 277
      .eslintrc.json
  2. 9
      .git-hooks/pre-commit
  3. 23
      .gitignore
  4. 106
      .stylelintrc.json
  5. 50
      README.md
  6. 11
      api/_errors.js
  7. 171
      api/admin/email.js
  8. 21
      api/admin/schema.js
  9. 143
      api/admin/table.js
  10. 17
      api/authcheck.js
  11. 18
      api/devtools/djenterator.js
  12. 25
      api/devtools/info.js
  13. 35
      api/email.js
  14. 83
      api/livestream.js
  15. 35
      api/login.js
  16. 5
      api/logout.js
  17. 26
      api/media.js
  18. 66
      api/password_reset.js
  19. 140
      api/payments/btcpay.js
  20. 60
      api/payments/fake.js
  21. 71
      api/payments/paypal.js
  22. 69
      api/register.js
  23. 20
      api/user/payments.js
  24. 39
      api/user/profile.js
  25. 83
      bando.js
  26. 1
      bando.ps1
  27. 15
      client/App.svelte
  28. 184
      client/api.js
  29. 35
      client/assert.js
  30. 117
      client/bando/Bandolier.svelte
  31. 222
      client/bando/Components.svelte
  32. 213
      client/bando/DevInfo.svelte
  33. 257
      client/bando/Djenterator.svelte
  34. 127
      client/bando/IconFinder.svelte
  35. 21
      client/bando/demos/Accordion.svelte
  36. 7
      client/bando/demos/Accordion.svelte.md
  37. 26
      client/bando/demos/AspectRatio.svelte
  38. 12
      client/bando/demos/AspectRatio.svelte.md
  39. 51
      client/bando/demos/Badge.svelte
  40. 3
      client/bando/demos/Badge.svelte.md
  41. 25
      client/bando/demos/ButtonGroup.svelte
  42. 1
      client/bando/demos/ButtonGroup.svelte.md
  43. 27
      client/bando/demos/Calendar.svelte
  44. 1
      client/bando/demos/Calendar.svelte.md
  45. 37
      client/bando/demos/Callout.svelte
  46. 8
      client/bando/demos/Callout.svelte.md
  47. 47
      client/bando/demos/Cards.svelte
  48. 1
      client/bando/demos/Cards.svelte.md
  49. 25
      client/bando/demos/Carousel.svelte
  50. 1
      client/bando/demos/Carousel.svelte.md
  51. 14
      client/bando/demos/Chat.svelte
  52. 1
      client/bando/demos/Chat.svelte.md
  53. 53
      client/bando/demos/Code.svelte
  54. 25
      client/bando/demos/Code.svelte.md
  55. 16
      client/bando/demos/Countdown.svelte
  56. 1
      client/bando/demos/Countdown.svelte.md
  57. 6
      client/bando/demos/Darkmode.svelte
  58. 21
      client/bando/demos/Darkmode.svelte.md
  59. 71
      client/bando/demos/DataTable.svelte
  60. 3
      client/bando/demos/DataTable.svelte.md
  61. 252
      client/bando/demos/FairPay.svelte
  62. 16
      client/bando/demos/FairPay.svelte.md
  63. 52
      client/bando/demos/Flipper.svelte
  64. 13
      client/bando/demos/Flipper.svelte.md
  65. 78
      client/bando/demos/Form.svelte
  66. 10
      client/bando/demos/Form.svelte.md
  67. 10
      client/bando/demos/HLSVideo.svelte
  68. 114
      client/bando/demos/Icon.svelte
  69. 56
      client/bando/demos/IconImage.svelte
  70. 27
      client/bando/demos/LiveStream.svelte
  71. 5
      client/bando/demos/LiveStream.svelte.md
  72. 17
      client/bando/demos/LoggedIn.svelte
  73. 16
      client/bando/demos/LoggedIn.svelte.md
  74. 13
      client/bando/demos/Login.svelte
  75. 5
      client/bando/demos/Login.svelte.md
  76. 51
      client/bando/demos/Markdown.svelte
  77. 32
      client/bando/demos/Modal.svelte
  78. 8
      client/bando/demos/Modal.svelte.md
  79. 42
      client/bando/demos/OGPreview.svelte
  80. 32
      client/bando/demos/OGPreview.svelte.md
  81. 33
      client/bando/demos/Pagination.svelte
  82. 1
      client/bando/demos/Pagination.svelte.md
  83. 6
      client/bando/demos/Panels.svelte
  84. 21
      client/bando/demos/PlaceHolder.svelte
  85. 3
      client/bando/demos/PlaceHolder.svelte.md
  86. 33
      client/bando/demos/Progress.svelte
  87. 13
      client/bando/demos/Progress.svelte.md
  88. 26
      client/bando/demos/Sidebar.svelte
  89. 6
      client/bando/demos/Sidebar.svelte.md
  90. 35
      client/bando/demos/SidebarCSS.svelte
  91. 3
      client/bando/demos/SidebarCSS.svelte.md
  92. 15
      client/bando/demos/SnapImage.svelte
  93. 29
      client/bando/demos/SnapImage.svelte.md
  94. 13
      client/bando/demos/Spinner.svelte
  95. 16
      client/bando/demos/Spinner.svelte.md
  96. 39
      client/bando/demos/StackLayer.svelte
  97. 25
      client/bando/demos/StackLayer.svelte.md
  98. 48
      client/bando/demos/Switch.svelte
  99. 4
      client/bando/demos/Switch.svelte.md
  100. 22
      client/bando/demos/Tabs.svelte

277
.eslintrc.json

@ -0,0 +1,277 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"plugins": ["svelte3"],
"overrides": [
{"files": "*.svelte", "processor": "svelte3/svelte3"}
],
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "module"
},
"rules": {
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": [ "warn", "never" ],
"array-callback-return": "error",
"array-element-newline": "off",
"arrow-body-style": "off",
"arrow-parens": "off",
"arrow-spacing": [
"error",
{
"after": true,
"before": true
}
],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": [
"error",
"1tbs"
],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": "off",
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": [
"error",
"last"
],
"complexity": "error",
"computed-property-spacing": [
"error",
"never"
],
"consistent-return": "error",
"consistent-this": "error",
"curly": "off",
"default-case": "error",
"dot-location": "off",
"dot-notation": "off",
"eol-last": "error",
"eqeqeq": "off",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "error",
"func-style": [
"error",
"expression"
],
"function-paren-newline": "off",
"generator-star-spacing": "error",
"global-require": "off",
"guard-for-in": "error",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": [
"error",
"beside"
],
"indent": "off",
"indent-legacy": "off",
"init-declarations": "warn",
"jsx-quotes": "error",
"key-spacing": "warn",
"keyword-spacing": "off",
"line-comment-position": "off",
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "off",
"lines-around-directive": "error",
"lines-between-class-members": "error",
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": "off",
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "error",
"multiline-comment-style": "off",
"new-cap": ["error", {"newIsCap": false, "capIsNew": true}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "error",
"no-array-constructor": "error",
"no-async-promise-executor": "error",
"no-await-in-loop": "off",
"no-bitwise": "error",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "warn",
"no-continue": "error",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "off",
"no-empty-function": "off",
"no-eq-null": "off",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "off",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-misleading-character-class": "error",
"no-mixed-operators": "error",
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "off",
"no-multi-str": "error",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "error",
"no-negated-condition": "off",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "off",
"no-process-exit": "off",
"no-proto": "error",
"no-prototype-builtins": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "warn",
"no-return-await": "off",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "off",
"no-shadow-restricted-names": "error",
"no-spaced-func": "error",
"no-sync": "warn",
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
"no-trailing-spaces": "off",
"no-undef-init": "off",
"no-undefined": "off",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": ["error", {"functions": true, "classes": false}],
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": "error",
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"no-with": "error",
"nonblock-statement-body-position": "error",
"object-curly-newline": "off",
"object-curly-spacing": "off",
"object-shorthand": "warn",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "error",
"operator-linebreak": "error",
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": "off",
"radix": "error",
"require-atomic-updates": "warn",
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
"rest-spread-spacing": "error",
"semi": "off",
"semi-spacing": "error",
"semi-style": [
"error",
"last"
],
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": [
"error",
"never"
],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": [
"error",
"always"
],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": [ "off", "never" ],
"template-tag-spacing": "error",
"unicode-bom": [
"error",
"never"
],
"valid-jsdoc": "error",
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "error",
"yield-star-spacing": "error",
"yoda": [
"error",
"never"
]
}
}

9
.git-hooks/pre-commit

@ -0,0 +1,9 @@
#!/bin/sh
CANARY=$(git diff --cached | grep -w "bc38cc69-1f88-4c1e-b262-ab1b6621fc82")
if [ ! -z "$CANARY" ]; then
echo "You're checking in a secret file with canary bc38cc69-1f88-4c1e-b262-ab1b6621fc82 in it:"
echo $CANARY
exit 1
fi

23
.gitignore

@ -0,0 +1,23 @@
/node_modules/
/public/
/rendered/build/
/rendered/public/
.*.sw*
.DS_Store
*.sqlite3*
debug/
static/thumbs
static/videos
secrets/*
coverage/
.coverage
static/module
client/config.js
emails/config.js
media
tests/fixtures
rendered/wip
junk/
static/images/sample_video.mp4
static/js/webtorrent.debug.js

106
.stylelintrc.json

@ -0,0 +1,106 @@
{
"rules": {
"at-rule-empty-line-before": [ "always", { "except": ["blockless-after-same-name-blockless", "first-nested"], "ignore": ["after-comment"]}],
"at-rule-name-case": "lower",
"at-rule-name-space-after": "always-single-line",
"at-rule-no-unknown": true,
"at-rule-semicolon-newline-after": "always",
"block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always-multi-line",
"block-closing-brace-space-before": "always-single-line",
"block-no-empty": true,
"block-opening-brace-newline-after": "always-multi-line",
"block-opening-brace-space-after": "always-single-line",
"block-opening-brace-space-before": "always",
"color-hex-case": "lower",
"color-hex-length": "short",
"color-no-invalid-hex": true,
"comment-empty-line-before": [ "always", { "except": ["first-nested"], "ignore": ["stylelint-commands"]}],
"comment-no-empty": true,
"comment-whitespace-inside": "always",
"custom-property-empty-line-before": [ "always", { "except": ["after-custom-property", "first-nested"], "ignore": ["after-comment", "inside-single-line-block"]}],
"declaration-bang-space-after": "never",
"declaration-bang-space-before": "always",
"declaration-block-no-duplicate-custom-properties": true,
"declaration-block-no-duplicate-properties": [ true, { "ignore": ["consecutive-duplicates-with-different-values"]}],
"declaration-block-no-shorthand-property-overrides": true,
"declaration-block-semicolon-newline-after": "always-multi-line",
"declaration-block-semicolon-space-after": "always-single-line",
"declaration-block-semicolon-space-before": "never",
"declaration-block-single-line-max-declarations": 1,
"declaration-block-trailing-semicolon": "always",
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"declaration-empty-line-before": [ "always", { "except": ["after-declaration", "first-nested"], "ignore": ["after-comment", "inside-single-line-block"]}],
"font-family-no-duplicate-names": true,
"font-family-no-missing-generic-family-keyword": true,
"function-calc-no-invalid": true,
"function-calc-no-unspaced-operator": true,
"function-comma-newline-after": "always-multi-line",
"function-comma-space-after": "always-single-line",
"function-comma-space-before": "never",
"function-linear-gradient-no-nonstandard-direction": true,
"function-max-empty-lines": 0,
"function-name-case": "lower",
"function-parentheses-newline-inside": "always-multi-line",
"function-parentheses-space-inside": "never-single-line",
"function-whitespace-after": "always",
"indentation": 2,
"keyframe-declaration-no-important": true,
"length-zero-no-unit": false,
"max-empty-lines": 10,
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-name-case": "lower",
"media-feature-name-no-unknown": true,
"media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always-multi-line",
"media-query-list-comma-space-after": "always-single-line",
"media-query-list-comma-space-before": "never",
"named-grid-areas-no-invalid": true,
"no-descending-specificity": true,
"no-duplicate-at-import-rules": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"no-eol-whitespace": true,
"no-extra-semicolons": true,
"no-invalid-double-slash-comments": true,
"no-invalid-position-at-import-rule": true,
"no-irregular-whitespace": true,
"no-missing-end-of-source-newline": true,
"number-leading-zero": "always",
"number-no-trailing-zeros": true,
"property-case": "lower",
"property-no-unknown": true,
"rule-empty-line-before": [ "always-multi-line", { "except": ["first-nested"], "ignore": ["after-comment"]}],
"selector-attribute-brackets-space-inside": "never",
"selector-attribute-operator-space-after": "never",
"selector-attribute-operator-space-before": "never",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-descendant-combinator-no-non-space": true,
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"selector-max-empty-lines": 0,
"selector-pseudo-class-case": "lower",
"selector-pseudo-class-no-unknown": true,
"selector-pseudo-class-parentheses-space-inside": "never",
"selector-pseudo-element-case": "lower",
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-element-no-unknown": true,
"selector-type-case": "lower",
"selector-type-no-unknown": [ true, { "ignore": ["custom-elements", "default-namespace"]}],
"string-no-newline": true,
"unit-case": "lower",
"unit-no-unknown": true,
"value-keyword-case": "lower",
"value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never",
"value-list-max-empty-lines": 0
}
}

50
README.md

@ -0,0 +1,50 @@
Install
===
You can install the project with a few commands:
```
git clone git@git.learnjsthehardway.com:zedshaw/ljsthw-project-template.git yourproject
cd yourproject
```
Next you'll want to move the 'origin' to 'upstream' so you can have your own git repo, then rename the 'master' branch to 'ljsthw' so you know exactly what it is:
```
git branch -m master ljsthw
git remote rename origin upstream
git remote set-url upstream --push "This remote is read only."
```
Create your own branch for working on, I like to name mine `dev`:
```
git checkout -b dev
```
Create your origin pointing at your real git repo:
```
git remote add origin git@git.learnjsthehardway.com:USER/yourproject.git
```
Then create a branch for your work so you can freely work without worrying about infecting the LJSTHW source:
``
git push --set-upstream origin dev
git pull -v
```
It should say `origin/dev` when you pull. Now you're ready to work on your own, and pull changes from this project to update your code with new changes.
Install (Linux/OSX)
===
In theory you shouldn't need playwright to download browsers, so just tell it not to.
```
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install
./scripts/init.js
```
This isn't as needed on Linux as OSX, because the Playwright project forces people to upgrade their OS by claiming all versions of their project can't support any slightly older Safari versions. You also just don't need Safari, so skip the download.

11
api/_errors.js

@ -0,0 +1,11 @@
import { log } from "../lib/logging.js";
export const missing = async (req, res) => {
res.status(404).json({message: "Not found", path: req.path});
}
export const exception = (err, req, res, next) => {
log.error(err);
res.status(500).json({message: "Server Error", status: res.status, path: req.path});
next();
}

171
api/admin/email.js

@ -0,0 +1,171 @@
import logging from '../../lib/logging.js';
import { developer_admin, API } from '../../lib/api.js';
import { company } from '../../emails/config.js';
import { get_config, dns_check, send_email, load_templates } from '../../lib/email.js';
const log = logging.create(import.meta.url);
const ERRORS = {
ip4_double_reverse: {
id: 0,
title: "No IP4 Double Reverse",
active: false,
text: "IPv4 addresses need to have both a reverse DNS record and a matching DNS record for the host. You have to add a reverse DNS for the IP Host address and then make sure that exact IPv4 address is listed in your DNS with a A record."
},
ip6_double_reverse: {
id: 1,
title: "No IP6 Double Reverse",
active: false,
text: "IPv6 addresses need to have both a reverse DNS record and a matching DNS record for the host. You have to add a reverse DNS for the IP Host address and then make sure that exact IPv6 address is listed in your DNS with a AAAA record (not A)."
},
no_mx_record: {
id: 2,
title: "No MX Record",
active: false,
text: "You can run your server without an MX record but it makes it less reliable. Add a single record with your main hostname and a 10 Priority."
},
spf_invalid: {
id: 3,
title: "SPF Record Invalid",
active: false,
text: "Your SPF record needs to mention both the IPv4 address with ip4:X and IPv6 address with ip6:X."
},
no_dmarc: {
id: 4,
title: "No DMARC Record",
active: false,
text: "You need a DMARC record, and it's recommended you configure it to p=none but route the errors to an email address on this server so you can monitor failures."
},
no_ip4_address: {
id: 5,
title: "No IP4 Address",
active: false,
text: "Most email providers are now requiring an IPv4 address that is also double reverse lookup, which means you need an IPv4 A record and a reverse DNS for it that exactly matches."
},
no_ip6_address: {
id: 6,
title: "No IP6 Address",
active: false,
text: "Most email providers are now requiring an IPv6 address that is also double reverse lookup, which means you need an IPv6 AAAA record and a reverse DNS for it that exactly matches."
},
no_spf: {
id: 7,
title: "No SPF Record",
active: false,
text: "You need an SPF record. It should be a TXT record and list your IP4 and IP6 addresses in the format \"v=spf1 a mx ip4:X ip6:Y ~all\"."
},
}
const analyze_dns = (dns) => {
let r = [];
// consider using a Rools engine if this gets too insane
if(dns.ip4.error) {
r.push(ERRORS.no_ip4_address);
r.push(ERRORS.ip4_double_reverse);
}
if(dns.ip4.reverse_errors) {
r.push(ERRORS.ip4_double_reverse);
}
if(dns.ip6.error) {
r.push(ERRORS.no_ip6_address);
r.push(ERRORS.ip6_double_reverse);
}
if(dns.ip6.reverse_errors) {
r.push(ERRORS.ip6_double_reverse);
}
if(dns.mx_error) {
r.push(ERRORS.no_mx_record);
}
if(dns.spf_error) {
r.push(ERRORS.no_spf);
}
if(dns.dmarc_error) {
r.push(ERRORS.no_dmarc);
}
if(r.length > 1) r[0].active = true;
return r;
}
export const get = async (req, res) => {
const api = new API(req, res);
const rules = { domain_name: "required"}
const form = api.validate(rules);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
}
try {
if(form._valid) {
const dns = await dns_check(form.domain_name);
const tests = analyze_dns(dns);
log.debug(dns);
return api.reply(200, {dns, tests});
} else {
return api.validation_error(res, form);
}
} catch (error) {
log.error(error);
return api.error(500, "Internal Server Error");
}
}
get.authenticated = !developer_admin;
const send_test = async (email) => {
try {
const test_email = await load_templates("test");
const text = test_email.txt(email);
const html = test_email.html(email);
return send_email({
from: company.mail,
to: email,
subject: `Test email for ${company.website}`,
text, html
});
} catch(error) {
log.error(error);
return error;
}
}
export const post = async (req, res) => {
const rules = { to_address: "required|email" }
const api = new API(req, res);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
}
try {
const form = api.validate(rules);
if(form._valid) {
log.debug(`Sending test email to ${form.to_address}`);
const error = await send_test(form.to_address);
return api.reply(200, {
message: "Email sent. Check server logs.",
error: error === undefined ? error : error.message,
config: get_config()
});
} else {
return api.validation_error(res, form);
}
} catch (error) {
log.error(error);
return api.error(500, "Internal Server Error");
}
}
post.authenticated = !developer_admin;

21
api/admin/schema.js

@ -0,0 +1,21 @@
import { knex } from '../../lib/ormish.js';
import { API } from '../../lib/api.js';
export const get = async (req, res) => {
const api = new API(req, res);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else {
// NOTE: in lib/ormish.js the schema is loaded once, but I do it every time
// here to reflect the current database even if they haven't restarted it yet
let result = await knex("sqlite_schema").where({"type": "table"}).select(['type', 'name']).orderBy('name');
for(let table of result) {
table._columns = await knex(table.name).columnInfo();
}
// TODO: flag that the database has changed so they know to restart
return api.reply(200, result);
}
}

143
api/admin/table.js

@ -0,0 +1,143 @@
import { knex, validation } from '../../lib/ormish.js';
import logging from "../../lib/logging.js";
import { developer_admin, API } from '../../lib/api.js';
const log = logging.create("api/admin/table.js");
export const get = async (req, res) => {
const api = new API(req, res);
const { name, search, row_id, currentPage } = req.query;
try {
const page = parseInt(currentPage || "0", 10);
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && search) {
// get the list of columns to search
const col_info = await knex(name).columnInfo();
const query = knex(name);
let columns = Object.getOwnPropertyNames(col_info);
for(let col of columns) {
// SECURITY: string interpolation but not sure how to do this without it
query.orWhere(col, "like", `%${search}%`);
}
// loop through and construct a mega where clause
let result = await query.paginate({perPage: 20, currentPage: page});
return api.reply(200, result);
} else if(name && !row_id) {
// TODO: restrict the tables that can be queried?
let result = await knex(name).paginate({perPage: 20, currentPage: page});
return api.reply(200, result);
} else if(name && row_id) {
let result = await knex(name).where({id: row_id}).first();
return api.reply(200, result);
} else {
return api.error(500, "name query parameter required, or name and row_id.");
}
} catch(error) {
log.error(error);
return api.error(500, "Internal server error.");
}
}
get.authenticated = !developer_admin;
export const del = async (req, res) => {
const api = new API(req, res);
const { name, row_id } = req.query;
try {
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && row_id) {
let res = await knex(name).where({id: row_id}).delete();
return api.reply(200, {message: "OK", result: res});
} else {
return api.error(500, "name and row_id required for delete");
}
} catch(error) {
log.error(error);
return api.error(500, "Internal server error.");
}
}
del.authenticated = !developer_admin;
export const post = async (req, res) => {
const api = new API(req, res);
const { name, row_id } = req.query;
try {
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name && row_id) {
// normally the validation rules come from the
// model class once and placed at the top of the
// module, but here we get it dynamically based
// on the name.
const rules = validation(name, {}, true);
const data = api.validate(rules);
// remove the id since they can't set it
delete data["id"];
if(!data._valid) {
return api.validation_error(res, data);
} else {
// remove id so it's not updated too
api.clean_form(data, rules, ["id"]);
let res = await knex(name).
where({id: row_id}).update(data);
return api.reply(200, {message: "OK", result: res});
}
} else {
return api.error(500, "name and row_id required for delete");
}
} catch(error) {
log.error(error);
return api.error(500, error.message || "Internal server error.");
}
}
post.authenticated = !developer_admin;
export const put = async (req, res) => {
const api = new API(req, res);
const { name } = req.query;
try {
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(name) {
const rules = validation(name, {}, true);
// don't validate id or accept it
const data = api.validate(rules);
// remove the id since they can't set it
delete data["id"];
if(!data._valid) {
return api.validation_error(res, data);
} else {
api.clean_form(data, rules, ["id"]);
let res = await knex(name).insert(data);
return api.reply(200, {message: "OK", id: res[0]});
}
} else {
return api.error(500, "name required for delete");
}
} catch(error) {
log.error(error);
return api.error(500, "Internal server error.");
}
}
put.authenticated = !developer_admin;

17
api/authcheck.js

@ -0,0 +1,17 @@
import { Payment } from "../lib/models.js";
import { API } from "../lib/api.js";
export const get = async (req, res) => {
const api = new API(req, res);
// the user should be authenticated already
const [paid, payment] = await Payment.paid(api.user);
if(!paid) {
api.error(402, "Payment required to access this course.");
} else {
res.status(200).send();
}
}
get.authenticated = true;

18
api/devtools/djenterator.js

@ -0,0 +1,18 @@
import logging from '../../lib/logging.js';
import { API } from '../../lib/api.js';
import glob from "fast-glob";
import path from "path";
const log = logging.create("/api/devtools/djenterator.js");
export const get = async (req, res) => {
const api = new API(req, res);
if(process.env.DANGER_ADMIN) {
const files = glob.sync("./static/djenterator/**/!(*.vars)");
return api.reply(200, files.map(f => path.basename(f)));
} else {
return api.error(404, "Not Found");
}
}

25
api/devtools/info.js

@ -0,0 +1,25 @@
import devtools from '../../lib/devtools.js';
import fs from 'fs';
export const get = async (req, res) => {
if(process.env.DANGER_ADMIN) {
// the devtools module contains all of the errors from the service/api.js for api and sockets
// but to get at the svelte errors we have to read debug/errors/svelte.json
let svelte_errors = [];
try {
svelte_errors = JSON.parse(fs.readFileSync("debug/errors/svelte.json"));
} catch(error) {
return res.status(404).json({message: "File not found."});
}
return res.status(200).json({
api: devtools.api,
sockets: devtools.sockets,
errors: devtools.errors.concat(svelte_errors)});
} else {
return res.status(404).json({ message: "Not Found."});
}
}

35
api/email.js

@ -0,0 +1,35 @@
import logging from "../lib/logging.js";
import { API } from "../lib/api.js";
import assert from "assert";
import { User } from "../lib/models.js";
const log = logging.create(import.meta.url);
const rules = {
unsubkey: "required|alpha_num"
}
export const get = async (req, res) => {
const api = new API(req, res);
try {
const form = api.validate(rules);
if(form._valid) {
const user = await User.first({unsubkey: form.unsubkey});
if(!user) {
return api.error(404, "User not found.");
} else {
const res = await user.emails(false);
assert(res === 1, `Invalid update returned ${res}`);
return api.reply(200, {message: "OK"});
}
} else {
return api.validation_error(res, form);
}
} catch (error) {
log.error(error);
return api.error(500, "Internal Server Error");
}
}

83
api/livestream.js

@ -0,0 +1,83 @@
import { Livestream } from "../lib/models.js";
import logging from '../lib/logging.js';
import { API } from '../lib/api.js';
import { knex } from "../lib/ormish.js";
import { discord, socket } from "../lib/config.js";
import { send_update_viewers, add_view_count } from "../lib/queues.js";
const log = logging.create(import.meta.url);
const next_state = (stream) => {
if(stream.state === "pending") {
return "ready";
} else if(stream.state === "ready") {
return "live";
} else {
return "finished";
}
}
export const get = async (req, res) => {
const api = new API(req, res);
const { livestream_id } = req.query;
try {
if(livestream_id) {
const stream = await Livestream.first({id: livestream_id});
if(stream) {
stream.media = await stream.media();
add_view_count(livestream_id);
api.reply(200, stream);
} else {
api.error(404, "not found");
}
} else {
const streams = await knex(Livestream.table_name).
orderBy("episode", "desc");
api.reply(200, streams);
}
} catch (error) {
log.error(error);
api.error(500, error.message || "Internal Server Error");
}
}
export const post = async (req, res) => {
const api = new API(req, res);
const { livestream_id } = req.body;
try {
if(!api.admin_authenticated) {
return api.error(401, "Admin rights required.");
} else if(livestream_id) {
const stream = await Livestream.first({id: livestream_id});
if(stream) {
const live = next_state(stream);
// change state to live, then the live client
// should listen on the chat and then update the
// UI or reload or something
await Livestream.update({id: livestream_id}, {state: live});
// notify connected clients that the stream has changed
send_update_viewers(socket.api_key);
return api.reply(200, {"message": "OK"});
} else {
return api.error(404, "not found");
}
} else {
return api.error(404, "not found");
}
} catch (error) {
log.error(error);
return api.error(500, error.message || "Internal Server Error");
}
}
post.authenticated = true;

35
api/login.js

@ -0,0 +1,35 @@
export const get = (req, res) => {
// NOTE: we could also set authenticated in models.User.auth but this is more of a
// UI level variable than an authentication variable. If the code gets to this
// point then the get.authenticated passed and the user has to be authenticated.
const reply = {
id: req.user.id,
initials: req.user.initials,
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email,
discord_sent: req.user.discord_sent,
unsubscribe: req.user.unsubscribe,
authenticated: true
}
res.status(200).json(reply);
}
get.authenticated = true;
export const post = (req, res) => {
const reply = {
id: req.user.id,
initials: req.user.initials,
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email,
unsubscribe: req.user.unsubscribe,
discord_sent: req.user.discord_sent,
authenticated: true
}
res.status(200).json(reply);
}
// this flags it as a login endpoint expecting a username/password
post.login = true;

5
api/logout.js

@ -0,0 +1,5 @@
export const get = (req, res) => {
req.logout();
res.status(200).json({message: "OK"});
}

26
api/media.js

@ -0,0 +1,26 @@
import logging from "../lib/logging.js";
import assert from "assert";
import { API } from "../lib/api.js";
import { knex } from "../lib/ormish.js";
const log = logging.create(import.meta.url);
export const get = async (req, res) => {
const api = new API(req, res);
const { media_id } = req.query;
try {
if(media_id) {
const media = await knex("media").where({id: media_id});
api.reply(200, media);
} else {
const media = await knex("media");
api.reply(200, media);
}
} catch (error) {
log.error(error);
api.error(500, "Internal Server Error");
}
}
get.authenticated = false;

66
api/password_reset.js

@ -0,0 +1,66 @@
import { API } from "../lib/api.js";
import logging from "../lib/logging.js";
import assert from "assert";
import { User } from "../lib/models.js";
import { send_reset, send_reset_finished } from '../lib/queues.js';
const rules_request = {
email: 'required|email',
}
const rules_finish = {
code: 'required',
password: 'required|same:password_repeat',
password_repeat: 'required',
}
const log = logging.create("/api/password_reset.js");
// this doesn't change so just do it here
const RESET_MAX = 10;
export const post = async (req, res) => {
const api = new API(req, res);
const finalize = req.body.finalize;
const rules = finalize ? rules_finish : rules_request
const form = api.validate(rules);
try {
if(!form._valid) {
// abort with errors
return api.validation_error(res, form);
} else {
assert(form._valid, "Form is invalid but shouldn't be.");
if(finalize) {
const { email, code, password } = form;
const user = await User.first({email});
assert(user, "User not found.");
// they have a code submitted, so check it
if(user.reset_count > RESET_MAX) {
return api.error(400, "Can't reset your password at this time.");
} else if(user.reset_code !== code.trim()) {
await User.update({id: user.id}, { reset_count: user.reset_count + 1 });
return api.error(400, "Reset code doesn't match.");
} else {
// SECURITY: encrypt the password here so that it's not left in the queue unencrypted
user.password = User.encrypt_password(password);
send_reset_finished(user, req.ip, req.headers['user-agent']);
return api.reply(200, {message: "OK"});
}
} else {
const { email } = form;
const user = await User.first({email});
assert(user, "User not found.");
// new request, send out a code
const user_req = { email, id: user.id};
send_reset(user_req, req.ip, req.headers['user-agent']);
return api.reply(200, {message: "OK"});
}
}
} catch(error) {
log.error(error);
return api.error(500, "Internal Server Error");
}
}

140
api/payments/btcpay.js

@ -0,0 +1,140 @@
import { Payment } from '../../lib/models.js';
import logging from '../../lib/logging.js';
import { API, defer } from "../../lib/api.js";
import fetch from 'node-fetch';
import { btcpay_private } from '../../lib/config.js';
import { product_id, fake_payments, register_enabled } from '../../client/config.js';
import { Product } from "../../lib/models.js";
import assert from "assert";
const product = await Product.first({id: product_id});
const rules = {
amount: "required|numeric|min:0|max:100",
}
const log = logging.create("api/payments/btcpay.js");
export const get = async (req, res) => {
const api = new API(req, res);
if(!register_enabled) {
return api.error(401, {errors: { main: "Registration is disabled."}});
}
try {
assert(!fake_payments, "You have fake_payments set so use /api/user/payment APIs.");
assert(!btcpay_private.disabled, "BTC Payments are disabled in secrets/config.json.");
const form = api.validate(rules);
assert(form.amount === product.price, `Form amount ${form.amount} doesn't match course price ${product.price}`);
if(form._valid) {
let internal_id = Payment.gen_internal_id();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
body: JSON.stringify({
"amount": form.amount,
"currency": product.currency,
"checkout": {
"speedPolicy": "HighSpeed"
},
"metadata": {
"orderId": internal_id,
"itemDesc": product.description,
}
}),
}
const response = await fetch(`${btcpay_private.url}/invoices`, options);
const text = await response.text();
if(response.status != 200) {
log.info(`BTCPAY error: ${response.status}, text: '${text}'`);
return api.error(403, 'BTCPay server failure');
} else {
const data = JSON.parse(text);
const sys_primary_id = data.id;
let payment = await Payment.insert({
system: 'btcpay',
status: 'pending',
user_id: api.user.id,
internal_id,
sys_primary_id,
sys_created_on: new Date(data.createdTime)
});
assert(payment, "Failed to store payment in the database. Email help@learnjsthehardway.com.");
return api.reply(200, {sys_primary_id, internal_id});
}
} else {
log.error(form._errors, "btcpay validation failure");
return api.validation_error(res, form);
}
} catch(error) {
log.error(error);
return api.error(403, 'BTCPay server failure');
}
}
get.authenticated = true;
export const post = async (req, res) => {
const api = new API(req, res);
const msg = req.body;
try{
log.debug(`btcpay POST Recieved message ${JSON.stringify(msg)}`);
const options = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
}
let response = await fetch(`${btcpay_private.url}/invoices/${msg.sys_primary_id}`, options);
let data = await response.json();
// TODO: what are the error conditions on this response?
let waiter = defer("settled_waiting");
setTimeout(() => waiter.resolve(), 5000);
await waiter;
response = await fetch(`${btcpay_private.url}/invoices/${msg.sys_primary_id}`, options);
data = await response.json();
// TODO: what are the error conditions on this response?
console.log("BTCPAY COMPLETE WITH", data);
const sys_primary_id = data.id;
const status = data.status;
const btc_due = data.btcDue;
// BUG: this doesn't seem to actually mark it complete
if(status == 'paid') {
let count = await Payment.update({
sys_primary_id,
internal_id: msg.internal_id
}, {status: 'complete'});
assert(count === 1, "Failed to update payment database.");
}
api.reply(200, {sys_primary_id, status, btc_due});
} catch(error) {
log.error(error);
api.error(403, 'Failed to confirm payment');
}
}
post.authenticated = true;

60
api/payments/fake.js

@ -0,0 +1,60 @@
import { Payment } from '../../lib/models.js';
import { API } from "../../lib/api.js";
import logging from '../../lib/logging.js';
import dayjs from 'dayjs';
import { fake_payments, register_enabled } from "../../client/config.js";
import { paypal_private } from "../../lib/config.js";
import assert from 'assert';
const log = logging.create("api/payments/fake.js");
const rules = {}; // TODO: have some kind of confirm key
export const post = async (req, res) => {
const api = new API(req, res);
if(!register_enabled) {
return api.error(500, {errors: { main: "Registration is disabled."}});
}
try {
assert(fake_payments, "You are using FakePayment but have fake_payments in secrets/config.json");
assert(paypal_private.disabled, "You are using FakePayment but PAYPAL IS ENABLED in secrets/config.json");
const msg = api.validate(rules);
if(msg._valid) {
let internal_id = Payment.gen_internal_id();
// TODO: see if we can use date-fns instead of dayjs here
let payment = await Payment.insert({
user_id: api.user.id,
system: 'fake',
status: 'complete',
internal_id,
sys_primary_id: Payment.gen_internal_id(),
sys_secondary_id: Payment.gen_internal_id(),
sys_created_on: dayjs(msg.sys_created_on)
});
assert(payment && payment.internal_id, "Failed to save payment.");
let result = {
sys_primary_id: payment.sys_primary_id,
status: payment.status,
internal_id: payment.internal_id
}
log.debug(result);
return api.reply(200, result);
} else {
log.error(msg._errors, "FakePayment validation failure");
return api.validation_error(res, msg);
}
} catch(error) {
log.error(error);
return api.error(403, 'FakePayment configuration failed');
}
}
post.authenticated = true;

71
api/payments/paypal.js

@ -0,0 +1,71 @@
import { Payment } from '../../lib/models.js';
import { API } from "../../lib/api.js";
import logging from '../../lib/logging.js';
import dayjs from 'dayjs';
import { fake_payments, register_enabled } from "../../client/config.js";
import { paypal_private } from "../../lib/config.js";
import assert from 'assert';
import * as queues from "../../lib/queues.js";
const pay_queue = queues.create("paypal/validate_order");
const log = logging.create("api/payments/paypal.js");
const rules = {
sys_primary_id: "required",
sys_secondary_id: "required",
sys_created_on: "required|date"
}
export const post = async (req, res) => {
const api = new API(req, res);
if(!register_enabled) {
return api.error(500, {errors: { main: "Registration is disabled."}});
}
try {
assert(!fake_payments, "You have fake_payments set so use /api/user/payment APIs.");
assert(!paypal_private.disabled, "Paypal Payments are disabled in secrets/config.json");
const msg = api.validate(rules);
if(msg._valid) {
log.debug("Paypal recieved message", msg);
let internal_id = Payment.gen_internal_id();
// TODO: see if we can use date-fns instead of dayjs here
let payment = await Payment.insert({
user_id: api.user.id,
system: 'paypal',
status: 'complete',
internal_id,
sys_primary_id: msg.sys_primary_id,
sys_secondary_id: msg.sys_secondary_id,
sys_created_on: dayjs(msg.sys_created_on)
});
// TODO: should we await here?
pay_queue.add({payment_id: payment.id});
assert(payment && payment.internal_id, "Failed to save payment.");
let result = {
sys_primary_id: payment.sys_primary_id,
status: payment.status,
internal_id: payment.internal_id
}
log.debug(result);
return api.reply(200, result);
} else {
log.error(msg._errors, "paypal validation failure");
return api.validation_error(res, msg);
}
} catch(error) {
log.error(error);
return api.error(403, 'PayPal configuration failed');
}
}
post.authenticated = true;

69
api/register.js

@ -0,0 +1,69 @@
import { User, Site, } from "../lib/models.js";
import * as queues from "../lib/queues.js";
import { defer, API } from "../lib/api.js";
import { register_enabled } from "../client/config.js";
const rules = User.validation({
email: '',
full_name: '',
initials: 'required|alpha|max:3',
password_repeat: 'required|same:password',
password: '',
tos_agree: 'required|boolean|accepted',
})
export const post = async (req, res) => {
const api = new API(req, res);
if(!register_enabled) {
return api.error(401, {_errors: { main: "Registration is disabled."}});
} else {
const form = api.validate(rules);
if(!form._valid) {
return api.validation_error(res, form);
} else {
api.clean_form(form, rules);
// register the user with this payment
let good = await User.register({
email: form.email,
full_name: form.full_name,
initials: form.initials,
password: form.password,
password_repeat: form.password_repeat,
});
// confirm the user was saved
if(!good) {
return api.error(400, {_errors: {main: "Failed to register user."}});
} else {
const wait_for = defer();
Site.increment("registered_count", 1);
delete good.password;
queues.send_welcome(good);
// NOTE: we have to use a defer here because
// this function returns which finalizes headers
// in express, but we have to wait for the passport
// login system to do its headers
api.req.login(good, (err) => {
if(err) {
console.error(err, "login error in register");
wait_for.reject(err);
return false;
} else {
api.reply(200, {message: "OK"});
wait_for.resolve("YES");
return true;
}
});
await wait_for;
}
}
}
}

20
api/user/payments.js

@ -0,0 +1,20 @@
import logging from '../../lib/logging.js';
import { API } from '../../lib/api.js';
import { Payment } from "../../lib/models.js";
const log = logging.create("/api/user/payments.js");
export const get = async (req, res) => {
const api = new API(req, res);
try {
const paid = await Payment.paid(api.user);
api.reply(200, {paid});
} catch (error) {
log.error(error);
api.error(500, "Internal Server Error");
}
}
get.authenticated = true;

39
api/user/profile.js

@ -0,0 +1,39 @@
import { User } from "../../lib/models.js";
import logging from '../../lib/logging.js';