feat: major code refactor, add login and register endpoints with swagger
All checks were successful
Update changelog / changelog (push) Successful in 27s

This commit is contained in:
2025-12-29 18:26:50 +01:00
parent 3f225a1ecb
commit 41f3b0f0f2
22 changed files with 1425 additions and 128 deletions

View File

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

578
package-lock.json generated
View File

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

View File

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

View File

@@ -1,34 +1,70 @@
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 = {
AppDataSource.initialize().then(async () => {
await AppDataSource.runMigrations();
const app: express.Express = express();
app.use(express.json());
app.use(inferUser);
app.use(miscRouter, userRouter);
if (process.env['DEBUG'] === 'true') {
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.0',
openapi: '3.0.4',
info: {
title: 'kittyurl',
version: '0.0.1'
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'
}
},
apis: ['./src/routes/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
components: {
securitySchemes: {
BearerJWT: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Authorization header using the Bearer scheme.<br/>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);
});
}
app.use(express.json());
app.use(router);
if (process.env.DEBUG === "true") {
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(function(req, res) {
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(function(req: express.Request, res: express.Response) {
res.status(404);
if (req.accepts('json')) {
@@ -37,6 +73,7 @@ app.use(function(req, res) {
}
res.type('txt').send('Not found');
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
}).catch(error => console.log(error))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> {
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"),

View File

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

30
src/routes/miscRoutes.ts Normal file
View File

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

72
src/routes/userRoutes.ts Normal file
View File

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

82
src/schemas/authSchema.ts Normal file
View File

@@ -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<typeof loginRequestSchemaBody>;
export const loginRequestSchema = z.object({
body: loginRequestSchemaBody
});
export type LoginRequestDTO = z.TypeOf<typeof loginRequestSchema>;
/**
* @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;
};

26
src/schemas/miscSchema.ts Normal file
View File

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

113
src/services/userService.ts Normal file
View File

@@ -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<boolean>} True if an account has been registered successfully.
// */
// async register(name: string, password: string, role: number | null): Promise<boolean> {
//
// // 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<number>} User id
*/
async findIdByCredentials(creds: UserCredentials): Promise<number> {
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|null>)} User entity
*/
async findByCredentials(creds: UserCredentials): Promise<User | null> {
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>} Number of user entities in DB (0...).
*/
async countAll(): Promise<number> {
return await this.userRepo.count();
}
/**
* Simply insert a new user entity.
*
* @param {User} user The user to insert
*/
async add(user: User): Promise<any> {
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();

View File

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

13
src/tools/hasher.ts Normal file
View File

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

117
src/tools/jwt.ts Normal file
View File

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

View File

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

View File

@@ -10,6 +10,10 @@
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"strict": true,
"strictPropertyInitialization": false
}
}