add login with sessian and cleanup
All checks were successful
Build and Push Docker Images / build (push) Successful in 2m34s

This commit is contained in:
Rene Kievits
2025-10-22 02:07:56 +02:00
parent 673d29b05f
commit 1980e14e88
31 changed files with 830 additions and 68 deletions

27
server/.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
# Node modules
node_modules
**/node_modules
# Git
.git
.gitignore
# Logs
*.log
# Local dev / editor
.vscode
.idea
# Build output
dist
build
.vite
# TypeScript build info
*.tsbuildinfo
# Environment files (if you want them injected via Docker ENV)
.env
.env.local
.env.*.local

View File

View File

@@ -8,9 +8,17 @@
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/static": "^8.2.0",
"@types/cookie-parser": "^1.4.9",
"@types/jsonwebtoken": "^9.0.10",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"fastify": "^5.6.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"ldapts": "^8.0.9",
"mongodb": "^6.20.0",
"mongoose": "^8.19.1",
"node": "^24.10.0",

223
server/pnpm-lock.yaml generated
View File

@@ -14,15 +14,39 @@ importers:
'@fastify/static':
specifier: ^8.2.0
version: 8.2.0
'@types/cookie-parser':
specifier: ^1.4.9
version: 1.4.9(@types/express@5.0.3)
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.5
version: 2.8.5
dotenv:
specifier: ^17.2.3
version: 17.2.3
express:
specifier: ^5.1.0
version: 5.1.0
express-rate-limit:
specifier: ^8.1.0
version: 8.1.0(express@5.1.0)
fastify:
specifier: ^5.6.1
version: 5.6.1
helmet:
specifier: ^8.1.0
version: 8.1.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
ldapts:
specifier: ^8.0.9
version: 8.0.9
mongodb:
specifier: ^6.20.0
version: 6.20.0
@@ -192,12 +216,20 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/asn1@0.2.4':
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.9':
resolution: {integrity: sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==}
peerDependencies:
'@types/express': '*'
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
@@ -216,9 +248,15 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.7.2':
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
@@ -304,6 +342,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -333,6 +374,9 @@ packages:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -379,6 +423,13 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -406,6 +457,15 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -430,6 +490,10 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -437,6 +501,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -515,6 +582,12 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
express-rate-limit@8.1.0:
resolution: {integrity: sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.1.0:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'}
@@ -649,6 +722,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
@@ -679,6 +756,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -736,6 +817,16 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
kareem@2.6.3:
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
engines: {node: '>=12.0.0'}
@@ -743,6 +834,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
ldapts@8.0.9:
resolution: {integrity: sha512-6UwfVFUX0Yp5XFY8ST0p9sytpmHGNm32GehI/dq4HuA3pL5kh0AceHBSfowv+cutIJFQnfBZmBo/6cnj87JDqA==}
engines: {node: '>=20'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -754,9 +849,30 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
@@ -1111,6 +1227,9 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1202,6 +1321,10 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -1397,6 +1520,10 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@types/asn1@0.2.4':
dependencies:
'@types/node': 24.7.2
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -1406,6 +1533,10 @@ snapshots:
dependencies:
'@types/node': 24.7.2
'@types/cookie-parser@1.4.9(@types/express@5.0.3)':
dependencies:
'@types/express': 5.0.3
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.7.2
@@ -1429,8 +1560,15 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 24.7.2
'@types/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/node@24.7.2':
dependencies:
undici-types: 7.14.0
@@ -1514,6 +1652,10 @@ snapshots:
argparse@2.0.1: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
atomic-sleep@1.0.0: {}
avvio@9.1.0:
@@ -1550,6 +1692,8 @@ snapshots:
bson@6.10.4: {}
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@@ -1599,6 +1743,13 @@ snapshots:
content-type@1.0.5: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -1620,6 +1771,10 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.3(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@@ -1634,6 +1789,8 @@ snapshots:
diff@4.0.2: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1642,6 +1799,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
emoji-regex@8.0.0: {}
@@ -1731,6 +1892,11 @@ snapshots:
etag@1.8.1: {}
express-rate-limit@8.1.0(express@5.1.0):
dependencies:
express: 5.1.0
ip-address: 10.0.1
express@5.1.0:
dependencies:
accepts: 2.0.0
@@ -1919,6 +2085,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.1.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
@@ -1948,6 +2116,8 @@ snapshots:
inherits@2.0.4: {}
ip-address@10.0.1: {}
ipaddr.js@1.9.1: {}
ipaddr.js@2.2.0: {}
@@ -1990,12 +2160,47 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
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.1.1
ms: 2.1.3
semver: 7.7.3
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
kareem@2.6.3: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
ldapts@8.0.9:
dependencies:
'@types/asn1': 0.2.4
asn1: 0.2.6
debug: 4.4.1
strict-event-emitter-types: 2.0.0
uuid: 11.1.0
whatwg-url: 14.2.0
transitivePeerDependencies:
- supports-color
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -2011,8 +2216,22 @@ snapshots:
dependencies:
p-locate: 5.0.0
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.merge@4.6.2: {}
lodash.once@4.1.1: {}
lru-cache@11.2.2: {}
make-error@1.3.6: {}
@@ -2345,6 +2564,8 @@ snapshots:
statuses@2.0.1: {}
strict-event-emitter-types@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -2433,6 +2654,8 @@ snapshots:
dependencies:
punycode: 2.3.1
uuid@11.1.0: {}
v8-compile-cache-lib@3.0.1: {}
vary@1.1.2: {}

View File

@@ -0,0 +1,117 @@
import express, { type Router, type Request, type Response } from 'express'
import jwt from 'jsonwebtoken'
import { UserModel } from '../../../models/user.model.ts'
import ldapAuth from './ldap.ts'
const router = express.Router()
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!
function createAccessToken(user: any) {
return jwt.sign(
{ sub: user._id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' },
)
}
function createRefreshToken(user: any) {
return jwt.sign(
{ sub: user._id },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' },
)
}
router.post('/login', async (req: Request, res: Response) => {
const { email, username, password } = req.body
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
try {
const ldapUser = await ldapAuth({ username, password })
if (!ldapUser.auth) return res.status(401).json({ error: 'Invalid credentials' })
let user = await UserModel.findOne({ username: ldapUser.user.cn })
if (!user) {
user = await UserModel.create({
username: ldapUser.user.cn,
email: ldapUser.user.dn,
refresh_token: '',
})
}
const accessToken = createAccessToken(user)
const refreshToken = createRefreshToken(user)
user.refreshToken = refreshToken
await user.save()
res.cookie('access_token', accessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
})
res.json({
ok: true,
user: {
username: ldapUser.user.cn,
email: ldapUser.user.dn
},
})
} catch (err) {
console.error(err)
res.status(401).json({ error: 'Invalid credentials' })
}
})
router.post('/refresh', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
if (!token) return res.status(401).json({ error: 'No refresh token' })
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
const user = await UserModel.findById(payload.sub)
if (!user || !user.refreshToken === token) return res.status(403).json({ error: 'Invalid refresh token' })
const newAccessToken = createAccessToken(user)
const newRefreshToken = createRefreshToken(user)
user.refreshToken = newRefreshToken
await user.save()
res.cookie('access_token', newAccessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
})
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
})
res.json({ ok: true })
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' })
}
})
router.post('/logout', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
if (token) {
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
const user = await UserModel.findById(payload.sub)
if (user) {
user.refreshToken = ''
await user.save()
}
} catch { }
}
res.clearCookie('access_token')
res.clearCookie('refresh_token')
res.json({ loggedOut: true })
})
export default router as Router

View File

@@ -0,0 +1,31 @@
import { Client } from 'ldapts'
const LDAP_URL = 'ldap://192.168.0.26:389';
const BASE_DN = 'DC=ldap,DC=goauthentik,DC=io';
async function ldapAuth(userOptions: any) {
const { username, password } = userOptions;
if (!username || !password) return { auth: false }
const client = new Client({ url: LDAP_URL });
try {
const userDN = `cn=${username},ou=users,${BASE_DN}`;
await client.bind(userDN, password);
const { searchEntries } = await client.search(BASE_DN, {
scope: 'sub',
filter: `(cn=${username})`,
attributes: ['cn', 'mail'],
});
return { auth: true, user: searchEntries[0] }
} catch (err) {
return { auth: false }
} finally {
await client.unbind().catch(() => { })
}
}
export default ldapAuth

View File

@@ -1,12 +1,15 @@
import express, { type Router, type Request, type Response } from 'express'
import express, { type Router, type Response } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
router.use(verifyAccessToken)
router.get('/', async (_req: Request, res: Response) => {
router.get('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const doc = await ApiKeyModel.findOne({})
const userId = req.userId
const doc = await ApiKeyModel.findOne({ userId: userId })
const apiKey = doc?.apiKey || ''
@@ -17,17 +20,19 @@ router.get('/', async (_req: Request, res: Response) => {
}
})
router.post('/', async (req: Request, res: Response) => {
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const { apiKey } = req.body as { apiKey?: string }
const { apiKey } = req.body
if (!apiKey || !apiKey.trim()) {
return res.status(400).json({ error: 'Invalid API key' })
}
console.log(req.body)
const userId = req.userId
await ApiKeyModel.updateOne(
{},
{ $set: { apiKey: apiKey } },
{ upsert: true }
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
{ upsert: true },
)
res.json({ success: true })
@@ -37,9 +42,10 @@ router.post('/', async (req: Request, res: Response) => {
}
})
router.delete('/', async (_req: Request, res: Response) => {
router.delete('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const result = await ApiKeyModel.deleteOne({})
const userId = req.userId
const result = await ApiKeyModel.deleteOne({ userId: userId })
if (result.deletedCount === 0) {
console.log('No API key found to delete.')

View File

@@ -7,7 +7,6 @@ const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await KanjiModel.find()
console.log(doc)
res.json(doc)
} catch (error) {
console.error('Error fetching Kanji Subjects', error)

View File

@@ -0,0 +1,22 @@
import express, { type Router } from 'express'
import { UserModel } from '../../../models/user.model.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
router.get('/info', verifyAccessToken, async (req: AuthRequest, res) => {
try {
if (!req.userId) return res.status(401).json({ ok: false, message: 'Unauthorized' })
const user = await UserModel.findById(req.userId).select('-refreshToken -__v -createdAt -updatedAt')
if (!user) return res.status(404).json({ ok: false, message: 'User not found' })
return res.json({ ok: true, user })
} catch (err) {
console.error(err)
return res.status(500).json({ ok: false, message: 'Server error' })
}
})
export default router as Router

View File

@@ -1,26 +1,22 @@
import express, { Router } from 'express'
import express, { type Router, type Response } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
interface ApiKeyDocument {
apiKey?: string;
}
router.get('/sync', async (req, res) => {
router.get('/sync', verifyAccessToken, async (req: AuthRequest, res: Response) => {
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' })
try {
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null
const apiKeyDoc = await ApiKeyModel.findOne({ userId: req.userId })
const apiKey = apiKeyDoc?.apiKey
console.log(apiKey, apiKeyDoc)
if (!apiKey || apiKey.trim() === '') {
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
}
await syncWanikaniData(apiKey)
await syncWanikaniData(apiKey, req.userId)
res.json({ success: true })
} catch (err) {
console.error(err)

View File

@@ -1,7 +1,7 @@
import mongoose from 'mongoose'
export async function connectMongo() {
const url = process.env.NODE_ENV === 'DEV' ? 'mongodb://mongo-srs:27017/srs' : 'mongodb://192.168.0.26:27017/srs'
const url = process.env.NODE_ENV === 'DEV' ? 'mongodb://mongo:27017/srs' : 'mongodb://192.168.0.26:27017/srs'
if (mongoose.connection.readyState === 1) return
await mongoose.connect(url)
console.log('✅ Connected to MongoDB at', url)

View File

@@ -1,19 +1,68 @@
import dotenv from 'dotenv'
dotenv.config()
import express from 'express'
import { connectMongo } from './db/connect.ts'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { connectMongo } from './db/connect.ts'
import keyRouter from './api/v1/key/index.ts'
import syncRouter from './api/v1/wanikani/sync.ts'
import kanjiRouter from './api/v1/subject/kanji.ts'
import vocabRouter from './api/v1/subject/vocab.ts'
import authRouter from './api/v1/auth/index.ts'
import userRoutes from './api/v1/user/index.ts'
import { verifyAccessToken } from './middleware/auth.ts'
const allowedOrigins = [
'http://localhost:5173',
'https://srs.crylia.de',
]
const app = express()
app.use(cors())
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true)
if (allowedOrigins.includes(origin)) return callback(null, true)
callback(new Error('Not allowed by CORS'))
},
credentials: true,
}))
app.use(express.json())
app.use(cookieParser())
if (process.env.NODE_ENV === 'production') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", "https://srs.crylia.de"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginResourcePolicy: { policy: "same-origin" },
})
)
} else {
app.use(
helmet({
contentSecurityPolicy: false,
})
)
}
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
app.use('/api/v1/key', keyRouter)
app.use('/api/v1/wanikani', syncRouter)
app.use('/api/v1/subject/kanji', kanjiRouter)
app.use('/api/v1/subject/vocab', vocabRouter)
app.use('/api/v1/key', verifyAccessToken, keyRouter)
app.use('/api/v1/subject/kanji', verifyAccessToken, kanjiRouter)
app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
app.use('/api/v1/user', verifyAccessToken, userRoutes)
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
app.use('/api/v1/auth', authRouter)
const PORT = process.env.PORT || 3000
connectMongo().then(() => {

View File

@@ -0,0 +1,21 @@
import { type Request, type Response, type NextFunction } from 'express'
import jwt from 'jsonwebtoken'
export interface AuthRequest extends Request {
userId?: string
}
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
export function verifyAccessToken(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.cookies.access_token
if (!token) return res.status(401).json({ ok: false, message: 'No token provided' })
try {
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET!)
req.userId = (payload as any).sub
next()
} catch {
return res.status(401).json({ ok: false, message: 'Invalid token' })
}
}

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
const ApiKeySchema = new mongoose.Schema({
userId: { type: String, required: true },
apiKey: { type: String, required: true },
})

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
const AssignmentSchema = new mongoose.Schema({
userId: { type: String, required: true },
subject_type: String,
subject_ids: [Number],
})

View File

@@ -2,6 +2,7 @@ import mongoose from 'mongoose'
import type { KanjiItem } from '../types/wanikani.ts'
const KanjiSchema = new mongoose.Schema<KanjiItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,

View File

@@ -0,0 +1,9 @@
import mongoose from "mongoose";
const UserSchema = new mongoose.Schema({
username: { type: String, unique: true, required: true },
email: { type: String, unique: true },
refreshToken: String,
}, { timestamps: true })
export const UserModel = mongoose.model('User', UserSchema)

View File

@@ -2,6 +2,7 @@ import mongoose from 'mongoose'
import type { VocabularyItem } from '../types/wanikani.ts'
const VocabSchema = new mongoose.Schema<VocabularyItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,

View File

@@ -63,7 +63,8 @@ const fetchSubjects = async (
return results
}
const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
const mapKanji = (item: WaniKaniSubject, userId: string): KanjiItem => ({
userId: userId,
characters: item.data.characters,
meanings: item.data.meanings,
readings: item.data.readings,
@@ -73,7 +74,8 @@ const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
srs_score: 0,
})
const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
const mapVocab = (item: WaniKaniSubject, userId: string): VocabularyItem => ({
userId: userId,
characters: item.data.characters,
meanings: item.data.meanings,
readings: item.data.readings ?? [],
@@ -84,13 +86,13 @@ const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
srs_score: 0,
})
export const syncWanikaniData = async (apiKey: string): Promise<void> => {
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
const headers = { Authorization: `Bearer ${apiKey}` }
try {
await ApiKeyModel.updateOne(
{},
{ $set: { value: apiKey, lastUsed: new Date() } },
{ $set: { user: userId, apiKey: apiKey, lastUsed: new Date() } },
{ upsert: true },
)
@@ -112,13 +114,13 @@ export const syncWanikaniData = async (apiKey: string): Promise<void> => {
}
await AssignmentModel.updateOne(
{ subject_type: 'kanji' },
{ userId: userId, subject_type: 'kanji' },
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
{ upsert: true },
)
await AssignmentModel.updateOne(
{ subject_type: 'vocabulary' },
{ userId: userId, subject_type: 'vocabulary' },
{ $set: { subject_ids: unlockedVocabSubjectIds } },
{ upsert: true },
)
@@ -126,12 +128,12 @@ export const syncWanikaniData = async (apiKey: string): Promise<void> => {
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(mapKanji))
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(k => mapKanji(k, userId)))
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
const vocabSubjects = await fetchSubjects(unlockedVocabSubjectIds, headers)
const newVocab = vocabSubjects.filter(s => !existingVocabSlugs.has(s.data.slug))
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(mapVocab))
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(v => mapVocab(v, userId)))
console.log('✅ Sync complete')
} catch (err) {

View File

@@ -1,4 +1,5 @@
export interface KanjiItem {
userId: string
characters: string
meanings: {
meaning: string,
@@ -21,6 +22,7 @@ export interface KanjiItem {
}
export interface VocabularyItem {
userId: string
characters: string
meanings: {
meaning: string
@@ -51,6 +53,7 @@ export interface VocabularyItem {
}
export interface Assignment {
userId: string
unlocked_at?: Date
subject_ids: number[]
subject_type: string