diff --git a/.env.default b/.env.default
index 9185484..5407a4c 100644
--- a/.env.default
+++ b/.env.default
@@ -1,3 +1,6 @@
+# Server config
+ACCESS_TOKEN_PRIVATE_KEY=CHANGE_ME_TO_SOMETHING_RANDOM
+
# TypeORM specific
# Please make sure these match with docker-compose.yml, or your own postgres server.
PG_USER=kitty
diff --git a/package-lock.json b/package-lock.json
index a1f3a82..b6a238b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,14 +10,21 @@
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
+ "jsonwebtoken": "^9.0.3",
+ "lodash": "^4.17.21",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
- "typeorm": "0.3.27"
+ "typeorm": "0.3.27",
+ "zod": "^4.2.1"
},
"devDependencies": {
- "@types/node": "^22.19.1",
+ "@types/express": "^5.0.6",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/lodash": "^4.17.21",
+ "@types/node": "^22.19.3",
+ "nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
@@ -181,22 +188,135 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
+ "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
- "version": "22.19.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
- "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -269,6 +389,20 @@
"node": ">=14"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/app-root-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
@@ -332,6 +466,19 @@
],
"license": "MIT"
},
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
@@ -365,6 +512,19 @@
"balanced-match": "^1.0.0"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -389,6 +549,12 @@
"ieee754": "^1.2.1"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -451,6 +617,31 @@
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -749,6 +940,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -875,6 +1075,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -947,6 +1160,21 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1022,6 +1250,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1034,6 +1275,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -1141,6 +1392,13 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1167,6 +1425,19 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -1179,6 +1450,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1188,6 +1469,29 @@
"node": ">=8"
}
},
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1248,6 +1552,55 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -1255,6 +1608,18 @@
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -1262,12 +1627,42 @@
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -1375,6 +1770,69 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemon": {
+ "version": "3.1.11",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
+ "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/nodemon/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1563,6 +2021,19 @@
"split2": "^4.1.0"
}
},
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -1624,6 +2095,13 @@
"node": ">= 0.10"
}
},
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1663,6 +2141,19 @@
"node": ">= 0.10"
}
},
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -1720,6 +2211,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
@@ -1905,6 +2408,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -2035,6 +2551,19 @@
"node": ">=8"
}
},
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
@@ -2148,6 +2677,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2157,6 +2699,16 @@
"node": ">=0.6"
}
},
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -2363,6 +2915,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -2684,6 +3243,15 @@
"engines": {
"node": "^12.20.0 || >=14"
}
+ },
+ "node_modules/zod": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
+ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index ae0cb7c..f5a987c 100644
--- a/package.json
+++ b/package.json
@@ -1,27 +1,34 @@
{
"name": "kittyBE",
- "version": "0.0.1",
+ "version": "0.0.0",
"description": "Your go-to place for short and memorable URLs.",
"type": "commonjs",
"devDependencies": {
- "@types/node": "^22.19.1",
+ "@types/express": "^5.0.6",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/lodash": "^4.17.21",
+ "@types/node": "^22.19.3",
+ "nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
+ "jsonwebtoken": "^9.0.3",
+ "lodash": "^4.17.21",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
- "typeorm": "0.3.27"
+ "typeorm": "0.3.27",
+ "zod": "^4.2.1"
},
"scripts": {
- "start": "ts-node src/index.ts",
+ "start": "ts-node src/app.ts",
+ "server": "ts-node src/app.ts",
"typeorm": "typeorm-ts-node-commonjs",
"newMigration": "typeorm-ts-node-commonjs migration:generate -p -d ./src/data-source.ts",
- "pendingMigration": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
- "server": "ts-node src/app.ts"
+ "pendingMigration": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts"
}
}
diff --git a/src/app.ts b/src/app.ts
index 2e9b931..883d33d 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,42 +1,79 @@
-import * as express from 'express';
-import router from './routes';
-import * as dotenv from "dotenv";
+import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
-const app = express();
+import express from 'express';
+import { version } from '../package.json';
+import miscRouter from './routes/miscRoutes';
+import userRouter from './routes/userRoutes';
+import { AppDataSource } from './data-source'
+import inferUser from './middleware/inferUser';
-const swaggerJsdocOpts = {
- failOnErrors: true,
- definition: {
- openapi: '3.0.0',
- info: {
- title: 'kittyurl',
- version: '0.0.1'
- }
- },
- apis: ['./src/routes/*.ts']
-};
-const swaggerUi = require('swagger-ui-express');
-const swaggerJsdoc = require('swagger-jsdoc');
-const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
+AppDataSource.initialize().then(async () => {
-app.use(express.json());
-app.use(router);
-if (process.env.DEBUG === "true") {
- app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
-}
+ await AppDataSource.runMigrations();
-// Handle 404s
-// https://stackoverflow.com/a/9802006
-app.use(function(req, res) {
- res.status(404);
+ const app: express.Express = express();
- if (req.accepts('json')) {
- res.json({ status: 'error', error: 'Not found' });
- return;
+ app.use(express.json());
+ app.use(inferUser);
+ app.use(miscRouter, userRouter);
+
+ if (process.env['DEBUG'] === 'true') {
+ const swaggerJsdocOpts = {
+ failOnErrors: true,
+ definition: {
+ openapi: '3.0.4',
+ info: {
+ title: 'kittyurl API',
+ description: 'A Typescript API for the kittyurl url shortener project.',
+ version: version,
+ contact: {
+ name: 'Git repository for entire project',
+ url: 'https://gitea.7o7.cx/kittyteam/kittyurl'
+ },
+ license: {
+ name: 'AGPLv3',
+ url: 'https://www.gnu.org/licenses/agpl-3.0.en.html'
+ }
+ },
+ components: {
+ securitySchemes: {
+ BearerJWT: {
+ type: 'http',
+ scheme: 'bearer',
+ bearerFormat: 'JWT',
+ description: 'JWT Authorization header using the Bearer scheme.
Enter your JWT from /api/v1/user/signIn to authorize.'
+ }
+ }
+ },
+ security: [
+ { bearerAuth: [] }
+ ],
+ },
+ apis: ['./src/routes/*.ts', './src/schemas/*.ts']
+ };
+ const swaggerUi = require('swagger-ui-express');
+ const swaggerJsdoc = require('swagger-jsdoc');
+ const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
+ app.use('/kttydocs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
+ app.get('/kttydocs.json', (req: express.Request, res: express.Response) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.send(swaggerSpec);
+ });
}
- res.type('txt').send('Not found');
-});
-app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
+ // Handle 404s
+ // https://stackoverflow.com/a/9802006
+ app.use(function(req: express.Request, res: express.Response) {
+ res.status(404);
+ if (req.accepts('json')) {
+ res.json({ status: 'error', error: 'Not found' });
+ return;
+ }
+
+ res.type('txt').send('Not found');
+ });
+ app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
+
+}).catch(error => console.log(error))
diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts
new file mode 100644
index 0000000..a5f5774
--- /dev/null
+++ b/src/controllers/authController.ts
@@ -0,0 +1,186 @@
+import { Request, Response } from 'express';
+import { User } from '../entities/User';
+import * as ms from '../schemas/miscSchema';
+import * as as from '../schemas/authSchema';
+import { UserService } from '../services/userService';
+import { generateSha512 } from '../tools/hasher';
+import * as jwt from '../tools/jwt';
+
+/**
+ * Handles requests for user logon
+ *
+ * @param {Request} req The Express request
+ * @param {Response} res The Express resource
+ */
+export async function loginUserHandler(
+ req: Request<{}, {}, as.LoginRequestDTO['body']>,
+ res: Response
+) {
+ const userService: UserService = new UserService();
+ const requestBody = req.body;
+
+ // Compare against what exists in the DB
+ let existingUser: User | null = await userService.findByCredentials({
+ name: requestBody.name
+ });
+
+ if (existingUser === null) {
+ // User not found? Return an error.
+ const error: ms.ErrorDTO = {
+ status: 'error',
+ error: 'User does not exist'
+ };
+ return res.status(404)
+ .json(error);
+ }
+
+ // For the reasoning behind this, check the long
+ // comment inside of createUserHandler().
+ // TL;DR: This is a fail-safe in case of
+ // a server shutdown mid user creation.
+ if (existingUser.dirtyPasswordHashBit) {
+ existingUser.passwordHash = generateSha512(`${existingUser.id}:${existingUser.passwordHash}`);
+ existingUser.dirtyPasswordHashBit = false;
+ userService.save(existingUser);
+ }
+
+ // Compute salted SHA512
+ const passwordHashed: string = generateSha512(`${existingUser.id}:${requestBody.password}`);
+
+ if (passwordHashed !== existingUser.passwordHash) {
+ // User not found? Return an error.
+ const error: ms.ErrorDTO = {
+ status: 'error',
+ error: 'Username <-> password pair not found'
+ };
+ return res.status(404)
+ .json(error);
+ }
+
+ // User found? Generate a JWT.
+ const token = jwt.signJwt(
+ { sub: existingUser.id, role: existingUser.role },
+ 'accessTokenPrivateKey',
+ { expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
+ );
+
+ // Respond with UserInfoDTO
+ const userResponse: as.UserInfoDTO = {
+ status: 'ok',
+ name: requestBody.name,
+ role: existingUser.role,
+ token: token,
+ ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
+ }
+ return res.status(200)
+ .send(userResponse);
+}
+
+/**
+ * Handles requests for user registration
+ *
+ * @param {Request} req The Express request
+ * @param {Response} res The Express resource
+ */
+export async function createUserHandler(
+ req: Request<{}, {}, as.LoginRequestDTO['body']>,
+ res: Response
+) {
+ // Ideally, there would be a registration DTO which would incorporate
+ // some sort of CAPTCHA or CSRF token.
+
+ const userService = new UserService();
+ const requestBody = req.body;
+
+ // Compare against what exists in the DB
+ let usersFound: number = await userService.countAll(); // CAN THIS BE REPLACED WITH userService.lastId()??????
+ let role: number = 0; // Let the default role be a standard user.
+
+ // No users found? Make the new (and only) user an Administrator.
+ if (usersFound == 0)
+ role = 1;
+ // Users found? The new user should NOT be an Administrator.
+ // Since role by default is 0 (non-admin), nothing needs to change.
+
+ // Check if user already exists. If yes, return an error.
+ let existingUserId: number = await userService.findIdByCredentials({
+ name: requestBody.name
+ });
+
+ if (existingUserId >= 0) {
+ const error: ms.ErrorDTO = {
+ status: 'error',
+ error: 'User already exists'
+ };
+ return res.status(403)
+ .json(error);
+ }
+
+ // Otherwise we're free to add him/her.
+ let user: User = {
+ id: undefined,
+ name: requestBody.name,
+ passwordHash: requestBody.password,
+ dirtyPasswordHashBit: true,
+ role: role,
+ createdAt: Date.now(),
+ links: []
+ };
+ await userService.add(user);
+
+ // Note how we're setting the retrieved password as the password hash,
+ // without hashing it first. This is because we don't know ahead of time
+ // what id will the user have (which we could use to salt the password).
+ // We also could hash the password with a user's name,
+ // but then we would either need to deem username unchangeable,
+ // or prompt the user for password once he/she wants to change his/her name.
+ // Out of the three not ideal solutions, the first has been incorporated,
+ // thus we add the user, then retrieve his/her id, to then salt user's
+ // password with it later. This requires setting a "dirty bit" and storing
+ // the password as received, so in a way in "plain text". Then recalculate
+ // the password and remove the dirty bit here, or, in a miniscule case if
+ // it so happens the server crashes or gets restarted while registering a user,
+ // the password will be recalculated on login attempt if dirty bit is set.
+ // TODO: explore alternative options with queryRunner's transactions?
+
+ // Check if user added successfully.
+ // If so, recalculate the salted hash.
+ const insertedUser: User | null = await userService.findByCredentials({
+ name: requestBody.name
+ });
+
+ // Return an error if - for whatever reason - the user has not been added.
+ if (insertedUser === null) {
+ const error: ms.ErrorDTO = {
+ status: 'error',
+ error: 'Could not add a new user',
+ code: 'server_error'
+ }
+ return res.status(500)
+ .json(error);
+ }
+
+ // Rewrite the user's password to use the salted hash
+ const passwordHashed: string = generateSha512(`${insertedUser.id}:${requestBody.password}`);
+ insertedUser.passwordHash = passwordHashed;
+ insertedUser.dirtyPasswordHashBit = false;
+ await userService.save(insertedUser);
+
+ // Generate a JWT.
+ const token = jwt.signJwt(
+ { sub: insertedUser.id, role: role },
+ 'accessTokenPrivateKey',
+ { expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
+ );
+
+ // Respond with UserInfoDTO
+ const userResponse: as.UserInfoDTO = {
+ status: 'ok',
+ name: requestBody.name,
+ role: role,
+ token: token,
+ ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
+ }
+ return res.status(201)
+ .send(userResponse);
+}
diff --git a/src/data-source.ts b/src/data-source.ts
index eab39d6..322c5d8 100644
--- a/src/data-source.ts
+++ b/src/data-source.ts
@@ -1,12 +1,12 @@
-import "reflect-metadata"
-import { DataSource } from "typeorm"
+import "reflect-metadata";
+import { DataSource } from "typeorm";
import * as dotenv from "dotenv";
-dotenv.config();
+dotenv.config({ quiet: true });
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.PG_HOST ?? "localhost",
- port: parseInt(process.env.PG_PORT, 10) || 5432,
+ port: parseInt(process.env.PG_PORT!, 10) || 5432,
username: process.env.PG_USER ?? "kitty",
password: process.env.PG_PASS ?? "CHANGEME", // Please change your password inside of the .env file!
database: process.env.PG_DB ?? "kittyurl",
diff --git a/src/entities/Link.ts b/src/entities/Link.ts
index da24bd1..f353400 100644
--- a/src/entities/Link.ts
+++ b/src/entities/Link.ts
@@ -1,48 +1,49 @@
-import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from "typeorm"
-import { User } from "./User"
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from 'typeorm'
+import { User } from './User'
@Entity("links")
export class Link {
// Unique link id.
@PrimaryGeneratedColumn()
- id: number
+ id: number;
// Experimental: subdomain which should be a part of the short url.
// For instance in the URL "abc.example.com/def", abc is the subdomain.
// "def.example.com/def" won't resolve to the URL that "abc.example.com/def" does.
- @Column({ nullable: true })
- subdomain: string | null
+ // https://stackoverflow.com/a/67535817
+ @Column({ type: 'varchar', nullable: true })
+ subdomain: string | null;
// Shortened Uri.
@Column()
- shortUri: string
+ shortUri: string;
// URL to which the user should be redirected
@Column()
- fullUrl: string
+ fullUrl: string;
// Unix timestamp of link creation date.
@Column('bigint')
- createDate: number
+ createDate: number;
// Unix timestamp of when the link should expire.
// If null, the link will never expire unless deleted.
@Column('bigint', { nullable: true })
- expiryDate: number | null
+ expiryDate: number | null;
// Aggregated amount of visits.
@Column('bigint')
- visits: number
+ visits: number;
// Link privacy:
// - true, if link is private
// - false, if link can be shown in a list of recent links publicly.
@Column()
- privacy: boolean
+ privacy: boolean;
// User to which the shortened URL belongs.
@ManyToOne(() => User, (user) => user.links, { nullable: true })
@JoinTable()
- author: User | null
+ author: User | null;
}
diff --git a/src/entities/User.ts b/src/entities/User.ts
index cbacd28..8a4e699 100644
--- a/src/entities/User.ts
+++ b/src/entities/User.ts
@@ -1,30 +1,35 @@
-import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"
-import { Link } from "./Link"
+import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'
+import { Link } from './Link'
@Entity("users")
export class User {
// Unique user id.
@PrimaryGeneratedColumn()
- id: number
+ id: number | undefined; // Is this a good idea?
// User name, must be unique.
@Column({ unique: true })
- name: string
+ name: string;
// Salted password hash.
@Column()
- passwordHash: string
+ passwordHash: string;
+
+ // Used to tell, whether password hash should
+ // be recalculated (salted) on next login.
+ @Column()
+ dirtyPasswordHashBit: boolean;
// User role:
// - 0 - means unprivileged user,
// - 1 - means administrative user.
@Column()
- role: number
+ role: number;
// Account creation date as a Unix timestamp.
@Column('bigint')
- createdAt: number
+ createdAt: number;
// List of shortened URLs which belong to the user.
@OneToMany(() => Link, (link) => link.author)
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 5d00fbc..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { AppDataSource } from "./data-source"
-import { User } from "./entities/User"
-import { smokeTest } from "./smoke-test";
-
-AppDataSource.initialize().then(async () => {
-
- // console.log("Inserting a new user into the database...");
- // const user = new User();
- // // user.firstName = "Timber";
- // // user.lastName = "Saw";
- // // user.age = 25;
- // await AppDataSource.manager.save(user);
- // console.log("Saved a new user with id: " + user.id);
-
- // console.log("Loading users from the database...");
- // const users = await AppDataSource.manager.find(User);
- // console.log("Loaded users: ", users);
-
- // console.log("Here you can setup and run express / fastify / any other framework.");
-
- await AppDataSource.runMigrations();
-
- await smokeTest(AppDataSource);
-
-}).catch(error => console.log(error))
diff --git a/src/middleware/inferUser.ts b/src/middleware/inferUser.ts
new file mode 100644
index 0000000..3b0bddb
--- /dev/null
+++ b/src/middleware/inferUser.ts
@@ -0,0 +1,50 @@
+// Heavily based on:
+// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
+import { get } from 'lodash';
+import { Request, Response, NextFunction } from 'express';
+import { verifyJwt } from '../tools/jwt';
+
+const inferUser = async (
+ req: Request,
+ res: Response,
+ next: NextFunction
+) => {
+
+ const accessToken = get(req, 'headers.authorization', '').replace(
+ /^Bearer\s/,
+ ''
+ );
+
+ if (!accessToken) return next();
+
+ const { decoded } = verifyJwt(accessToken, 'accessTokenPublicKey');
+
+ // console.log('decoded user:', decoded);
+
+ if (decoded) {
+ res.locals.user = decoded;
+ return next();
+ }
+
+ /*
+ // refresh token handling is not (yet) implemented
+ const refreshToken = get(req, 'headers.x-refresh');
+
+ if (expired && refreshToken) {
+ const newAccessToken = await reIssueAccessToken({ refreshToken });
+
+ if (newAccessToken) {
+ res.setHeader('x-access-token', newAccessToken);
+ }
+
+ const result = verifyJwt(newAccessToken as string, 'accessTokenPublicKey');
+
+ res.locals.user = result.decoded;
+ return next();
+ }
+ */
+
+ return next();
+};
+
+export default inferUser;
\ No newline at end of file
diff --git a/migrations/1765488793696-revisedMigration.ts b/src/migrations/1767018509215-initialMigration.ts
similarity index 91%
rename from migrations/1765488793696-revisedMigration.ts
rename to src/migrations/1767018509215-initialMigration.ts
index f3a48ad..5325373 100644
--- a/migrations/1765488793696-revisedMigration.ts
+++ b/src/migrations/1767018509215-initialMigration.ts
@@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner } from "typeorm";
-export class RevisedMigration1765488793696 implements MigrationInterface {
- name = 'RevisedMigration1765488793696'
+export class InitialMigration1767018509215 implements MigrationInterface {
+ name = 'InitialMigration1767018509215'
public async up(queryRunner: QueryRunner): Promise {
await queryRunner.query(`
@@ -23,6 +23,7 @@ export class RevisedMigration1765488793696 implements MigrationInterface {
"id" SERIAL NOT NULL,
"name" character varying NOT NULL,
"passwordHash" character varying NOT NULL,
+ "dirtyPasswordHashBit" boolean NOT NULL,
"role" integer NOT NULL,
"createdAt" bigint NOT NULL,
CONSTRAINT "UQ_51b8b26ac168fbe7d6f5653e6cf" UNIQUE ("name"),
diff --git a/src/routes/index.ts b/src/routes/index.ts
deleted file mode 100644
index e3fd8dc..0000000
--- a/src/routes/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Router } from 'express';
-
-const router = Router();
-
-/**
- * @swagger
- *
- * /:
- * get:
- * description: Hello world!
- * tags: [Default]
- * responses:
- * 200:
- * description: Returns "Hello world!"
- */
-router.get('/', (req, res) => {
- res.send("Hello world!");
-});
-
-export default router;
\ No newline at end of file
diff --git a/src/routes/miscRoutes.ts b/src/routes/miscRoutes.ts
new file mode 100644
index 0000000..f82d2c7
--- /dev/null
+++ b/src/routes/miscRoutes.ts
@@ -0,0 +1,30 @@
+import { Router, Request, Response } from 'express';
+
+const miscRouter = Router();
+
+/**
+ * @openapi
+ *
+ * /:
+ * get:
+ * description: Hello world!
+ * tags: [Default]
+ * responses:
+ * 200:
+ * description: Returns "Hello world!"
+ */
+miscRouter.get('/', (req: Request, res: Response) => res.send("Hello world!"));
+
+/**
+ * @openapi
+ * /healthcheck:
+ * get:
+ * tags: [Healthcheck]
+ * description: Provides a response when backend is running
+ * responses:
+ * 200:
+ * description: Backend is online
+ */
+miscRouter.get('/healthcheck', (req: Request, res: Response) => res.sendStatus(200));
+
+export default miscRouter;
diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts
new file mode 100644
index 0000000..524e3d9
--- /dev/null
+++ b/src/routes/userRoutes.ts
@@ -0,0 +1,72 @@
+import { Router, Request, Response } from 'express';
+import validateSchema from '../tools/validateSchema';
+import * as ac from '../controllers/authController';
+import * as as from '../schemas/authSchema';
+
+const userRouter = Router();
+
+/**
+ * @openapi
+ *
+ * /api/v1/user/signUp:
+ * post:
+ * description: Add user
+ * tags: [User]
+ * summary: Register a user
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/LoginRequestDTO'
+ * produces:
+ * - application/json
+ * responses:
+ * 200:
+ * description: User created successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/UserInfoDTO'
+ * 400:
+ * description: Bad request
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ErrorDTO'
+ */
+userRouter.post('/api/v1/user/signUp', validateSchema(as.loginRequestSchema), ac.createUserHandler);
+
+/**
+ * @openapi
+ *
+ * /api/v1/user/signIn:
+ * post:
+ * description: Log in
+ * tags: [User]
+ * summary: Log in to an account
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/LoginRequestDTO'
+ * produces:
+ * - application/json
+ * responses:
+ * 200:
+ * description: User logged in successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/UserInfoDTO'
+ * 400:
+ * description: Wrong password/non-existent user
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ErrorDTO'
+ */
+userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler);
+
+export default userRouter;
\ No newline at end of file
diff --git a/src/schemas/authSchema.ts b/src/schemas/authSchema.ts
new file mode 100644
index 0000000..491d472
--- /dev/null
+++ b/src/schemas/authSchema.ts
@@ -0,0 +1,82 @@
+import z from 'zod';
+
+// Maybe load this from .env? 24 hours in seconds.
+export const DEFAULT_TOKEN_LIFETIME = 86_400;
+
+/**
+ * @openapi
+ * components:
+ * schemas:
+ * LoginRequestDTO:
+ * type: object
+ * required:
+ * - name
+ * - password
+ * properties:
+ * name:
+ * type: string
+ * default: mail@example.com or username
+ * password:
+ * type: string
+ * default: sha512(Hunter2)
+ * ttl:
+ * type: number
+ * default: 86400 # 24 hours * 60 minutes * 60 seconds
+ */
+const loginRequestSchemaBody = z.object({
+ name: z.string({
+ error: (e) => e.input === undefined ? 'Name is required' : 'Name must be a string'
+ })
+ .min( 3, 'Name is too short (try something longer than 3 characters)')
+ .max(64, 'Name is too long (try something shorter than 64 characters)'),
+ password: z.hash('sha512', {
+ error: (e) => e.input === undefined ? 'Password is required' : 'Password must be a SHA512 hash'
+ }),
+ ttl: z.number('TTL must be a number between 120 and 2 592 000 seconds')
+ .min( 120, 'TTL is too short (try something longer than 120 seconds)') // 120s
+ .max(2_592_000, 'TTL is too long (try something shorter than 30 days)') // 30d
+ .optional()
+});
+// export type LoginRequestBodyDTO = z.TypeOf;
+
+export const loginRequestSchema = z.object({
+ body: loginRequestSchemaBody
+});
+export type LoginRequestDTO = z.TypeOf;
+
+/**
+ * @swagger
+ * components:
+ * schemas:
+ * UserInfoDTO:
+ * type: object
+ * required:
+ * - status
+ * - name
+ * - role
+ * - token
+ * properties:
+ * status:
+ * type: string
+ * default: ok on success otherwise ErrorDTO with error
+ * name:
+ * type: string
+ * default: username
+ * role:
+ * type: number
+ * default: 0 # 0 - standard user, 1 - administrator
+ * token:
+ * type: string
+ * default: JWT
+ * ttl:
+ * type: number
+ * default: 86400 # 24 hours * 60 minutes * 60 seconds
+ */
+export type UserInfoDTO = {
+ status: 'ok';
+ name: string;
+ role: number;
+ token: string;
+ ttl: number | null;
+};
+
diff --git a/src/schemas/miscSchema.ts b/src/schemas/miscSchema.ts
new file mode 100644
index 0000000..f7be6a7
--- /dev/null
+++ b/src/schemas/miscSchema.ts
@@ -0,0 +1,26 @@
+
+/**
+ * @openapi
+ * components:
+ * schemas:
+ * ErrorDTO:
+ * type: object
+ * required:
+ * - status
+ * - error
+ * properties:
+ * status:
+ * type: string
+ * default: error
+ * error:
+ * type: string
+ * default: error message
+ * code:
+ * type: string
+ * default: error code (may not be returned for every request)
+ */
+export type ErrorDTO = {
+ status: 'error';
+ error: string;
+ code?: string | undefined;
+};
diff --git a/src/services/userService.ts b/src/services/userService.ts
new file mode 100644
index 0000000..5cb9ae0
--- /dev/null
+++ b/src/services/userService.ts
@@ -0,0 +1,113 @@
+import { User } from '../entities/User';
+import { AppDataSource } from '../data-source';
+
+export type UserCredentials = {
+ name?: string | undefined;
+ password?: string | undefined;
+}
+
+export class UserService {
+ dataSource = AppDataSource;
+ userRepo = this.dataSource.getRepository(User);
+
+ //
+ // Phased out in favor of in-controller logic.
+ //
+ // /**
+ // * Register a new user. Checks if a user with provided name exists.
+ // * Returns `true` if user has been registered successfully.
+ // *
+ // * @param {string} name Username
+ // * @param {string} password User's password
+ // * @param {(null|number)} role User's role (0 | null = standard user, 1 = admin)
+ // * @return {Promise} True if an account has been registered successfully.
+ // */
+ // async register(name: string, password: string, role: number | null): Promise {
+ //
+ // // Check if user by this name already exists.
+ // // If so, return here to not create a new account.
+ // if (await this.userRepo.existsBy({ name: name }))
+ // return false;
+ //
+ // // TODO: insert "dirty" entity...
+ // // await this.userRepo.insert();
+ //
+ // // TODO: salt the password...
+ //
+ // return true;
+ // }
+
+ /**
+ * Finds entity id by any credentials passed.
+ * Returns user id (typically number > 0) on success,
+ * -1 on no match, and -2 on multiple matches (if that were to ever occur).
+ *
+ * @param {UserCredentials} creds The creds
+ * @return {Promise} User id
+ */
+ async findIdByCredentials(creds: UserCredentials): Promise {
+
+ let users: User[] = await this.userRepo.find({
+ where: { ...creds }
+ });
+
+ if (users.length === 0) return -1; // no match
+ else if (users.length === 1) return users[0].id!; // exact match, return id
+ else return -2; // sus, too many matches
+
+ }
+
+ /**
+ * Finds the user entity by any credentials passed.
+ * Returns user on success, otherwise null.
+ *
+ * @param {UserCredentials} creds The creds
+ * @return {(Promise)} User entity
+ */
+ async findByCredentials(creds: UserCredentials): Promise {
+
+ let users: User[] = await this.userRepo.find({
+ where: { ...creds }
+ });
+
+ if (users.length !== 1) return null; // no match, or too many matches, sus
+ else return users[0]; // exact match, return user
+
+ }
+
+ /**
+ * Counts all the user entities in DB.
+ * Used to tell whether the next created account should be an admin
+ * (No users in DB? That means most likely it's a fresh instance.
+ * Then the next user should be an admin.)
+ *
+ * @return {Promise} Number of user entities in DB (0...).
+ */
+ async countAll(): Promise {
+ return await this.userRepo.count();
+ }
+
+ /**
+ * Simply insert a new user entity.
+ *
+ * @param {User} user The user to insert
+ */
+ async add(user: User): Promise {
+ await this.userRepo.insert(user);
+ }
+
+ /**
+ * Simply save user entity to DB.
+ * New user passed? New entity will get created.
+ * Existing entity passed? It'll get updated.
+ *
+ * @param {User} user The user to insert or update
+ */
+ async save(user: User) {
+ // assert(user.id !== undefined, new Error("Passed user MUST contain an id!"));
+ await this.userRepo.save(user);
+ }
+
+}
+
+// export default new UserService();
\ No newline at end of file
diff --git a/src/smoke-test.ts b/src/smoke-test.ts
deleted file mode 100644
index aabe324..0000000
--- a/src/smoke-test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Connection } from "typeorm";
-import { User } from "./entities/User";
-
-export async function smokeTest(connection: Connection) {
- const user = new User();
-
- user.name = "admin";
- user.role = "admin";
- user.createdAt = Date.now();
- user.passwordHash = "pretend this is a hash";
-
- await connection.manager.save(user);
-}
\ No newline at end of file
diff --git a/src/tools/hasher.ts b/src/tools/hasher.ts
new file mode 100644
index 0000000..8571242
--- /dev/null
+++ b/src/tools/hasher.ts
@@ -0,0 +1,13 @@
+// Credits:
+// https://mojoauth.com/hashing/sha-512-in-typescript/
+import { createHash } from 'crypto';
+/**
+ * Generates a SHA-512 hash for the given input string.
+ * @param input - The input string to hash.
+ * @returns The SHA-512 hash as a hexadecimal string.
+ */
+export function generateSha512(input: string): string {
+ const hash = createHash('sha512');
+ hash.update(input);
+ return hash.digest('hex');
+}
diff --git a/src/tools/jwt.ts b/src/tools/jwt.ts
new file mode 100644
index 0000000..7d29b88
--- /dev/null
+++ b/src/tools/jwt.ts
@@ -0,0 +1,117 @@
+// Heavily based on:
+// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/utils/jwt.utils.ts
+import * as dotenv from 'dotenv';
+dotenv.config({ quiet: true });
+
+import jwt from 'jsonwebtoken';
+import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
+
+type JwtStatus = {
+ valid: boolean;
+ expired: boolean;
+ decoded: string | jwt.JwtPayload | null;
+};
+
+/**
+ * Get the environmental string from .env.
+ * Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
+ *
+ * @param {string} key The key
+ * @param {boolean} [isGlobal=true] Indicates if global
+ * @return {(string|undefined)} The environment string.
+ */
+export function getEnvString(
+ key: string,
+ isGlobal: boolean = true
+): string | undefined {
+ let keyName: string = '';
+
+ if (isGlobal) {
+ // Global values are DECLARED_LIKE_THIS=...
+ for (let i: number = 0; i < key.length; i++) {
+ if (key[i].toLowerCase() === key[i]) {
+ // If is lowercase, skip.
+ keyName += key[i];
+ } else {
+ // If is uppercase, convert to snake case.
+ keyName += `_${key[i].toLowerCase()}`;
+ }
+ }
+ keyName.toUpperCase();
+ } else {
+ // Non-global keys are parsed as passed
+ keyName = key;
+ }
+
+ return process.env[keyName];
+}
+
+/**
+ * Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
+ *
+ * @param {Object} object The object
+ * @param {('accessTokenPrivateKey'|'refreshTokenPrivateKey')} keyName The key name
+ * @param {} options?:jwt.SignOptions|undefined The sign options undefined
+ * @return {string} JWT string
+ */
+export function signJwt(
+ object: Object,
+ keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
+ options?: jwt.SignOptions | undefined
+): string {
+
+ // refresh tokens aren't (yet) supported
+ // const signingKey = Buffer.from(
+ // process.env[keyName]!,
+ // 'base64'
+ // ).toString('utf8');
+
+ const secret: string = getEnvString(keyName, true)!;
+
+ // Use the default expiration time of 24 hours.
+ if (options === undefined)
+ options = { expiresIn: DEFAULT_TOKEN_LIFETIME };
+
+ return jwt.sign(object, secret, {
+ ...options, // (options && options)?
+ // algorithm: 'RS256', // requires a valid private key, not a secret
+ });
+}
+
+/**
+ * Verify a JWT against one of the keys.
+ * Returns JwtStatus, which contains fields for checking validity, expiry and decoded subject claim (id).
+ *
+ * @param {string} token The token
+ * @param {('accessTokenPublicKey'|'refreshTokenPublicKey')} keyName The key name
+ * @return {JwtStatus} JWT status.
+ */
+export function verifyJwt(
+ token: string,
+ keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
+): JwtStatus {
+
+ // refresh tokens aren't (yet) supported
+ // const publicKey = Buffer.from(
+ // process.env[keyName]!,
+ // 'base64'
+ // ).toString('utf8');
+
+ const secret: string = getEnvString(keyName, true)!;
+
+ try {
+ const decoded: string | jwt.JwtPayload = jwt.verify(token, secret);
+ return {
+ valid: true,
+ expired: false,
+ decoded,
+ };
+ } catch (e: any) {
+ console.error('JWT verify error:', e);
+ return {
+ valid: false,
+ expired: e.message === 'jwt expired',
+ decoded: null,
+ };
+ }
+}
diff --git a/src/tools/validateSchema.ts b/src/tools/validateSchema.ts
new file mode 100644
index 0000000..4702e62
--- /dev/null
+++ b/src/tools/validateSchema.ts
@@ -0,0 +1,40 @@
+// Heavily based on:
+// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/validateResource.ts
+import { Request, Response, NextFunction } from 'express';
+import { ErrorDTO } from '../schemas/miscSchema';
+import z from 'zod';
+
+const validate =
+ (schema: z.ZodObject) =>
+ (req: Request, res: Response, next: NextFunction) => {
+ try {
+ schema.parse({
+ body: req.body,
+ query: req.query,
+ params: req.params,
+ });
+ next();
+ } catch (e: any) {
+ if (e instanceof z.ZodError) {
+ let errorResponse: ErrorDTO = {
+ status: 'error',
+ error: e.issues[0]?.message ?? 'Unknown error',
+ code: e.issues[0]?.code
+ };
+ return res.status(400)
+ .json(errorResponse);
+ } else {
+ console.log('Generic error triggered:', e);
+ let errorResponse: ErrorDTO = {
+ status: 'error',
+ error: 'Unknown error',
+ code: 'generic-error'
+ };
+ return res.status(400)
+ .json(errorResponse);
+ }
+
+ }
+ };
+
+export default validate;
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index c9347ca..6c1424d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,6 +10,10 @@
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
- "sourceMap": true
+ "sourceMap": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "strict": true,
+ "strictPropertyInitialization": false
}
}
\ No newline at end of file