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