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

View File

@@ -10,6 +10,10 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
ACCESS_TOKEN_SECRET: ${{ secrets.ACCESS_TOKEN_SECRET }}
REFRESH_TOKEN_SECRET: ${{ secrets.REFRESH_TOKEN_SECRET }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -32,6 +36,9 @@ jobs:
- name: Build and Push Server - name: Build and Push Server
run: | run: |
echo "🚀 Building Server..." echo "🚀 Building Server..."
docker buildx build --load -t crylia/japanese-srs-trainer-wanikani-server:latest -f server/Dockerfile server docker buildx build \
--build-arg ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET} \
--build-arg REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET} \
--load -t crylia/japanese-srs-trainer-wanikani-server:latest -f server/Dockerfile server
docker tag crylia/japanese-srs-trainer-wanikani-server:latest 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest docker tag crylia/japanese-srs-trainer-wanikani-server:latest 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest
docker push 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest docker push 192.168.0.26:5008/japanese-srs-trainer-wanikani-server:latest

41
.gitignore vendored
View File

@@ -1 +1,40 @@
node_modules # Node modules
node_modules/
**/node_modules/
# Vite / Vue build output
dist/
build/
.vite/
# Logs
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# Environment files
.env
.env.local
.env.*.local
# TypeScript
*.tsbuildinfo
# VSCode
.vscode/
# JetBrains
.idea/
# System
.DS_Store
Thumbs.db
# Lint / Typegen
*.d.ts
*.eslintcache
# Ignore temporary files
*.tmp
*.temp

27
client/.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

@@ -1,5 +1,10 @@
<template lang="pug"> <template lang="pug">
v-container.fill-height.d-flex.justify-center.align-center v-container.fill-height.d-flex.justify-center.align-center.position-relative
v-btn.position-absolute.top-0.right-0.ma-4(
color="error"
variant="outlined"
@click="logoutHandler"
) Logout
v-card.pa-8.pb-6( v-card.pa-8.pb-6(
elevation="12" elevation="12"
rounded="xl" rounded="xl"
@@ -134,12 +139,14 @@ v-container.fill-height.d-flex.justify-center.align-center
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { Reviews } from '../composables/subject.ts' import { Reviews } from '../composables/subject.ts'
const reviews = ref<Reviews | null>() const reviews = ref<Reviews | null>()
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const apiKey = ref<string>('') const apiKey = ref<string>('')
const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en') const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en')
const stats = ref<{ kanjiCount: number, vocabCount: number }>({ const stats = ref<{ kanjiCount: number, vocabCount: number }>({
@@ -153,6 +160,11 @@ const options = ref({
kanji: false, kanji: false,
}) })
async function logoutHandler() {
await auth.logout()
router.push('/login')
}
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') { function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
router.push({ router.push({
path: '/' + type, path: '/' + type,

View File

@@ -0,0 +1,56 @@
<template lang="pug">
v-container.fill-height.d-flex.justify-center.align-center
v-card.elevation-12.rounded-lg.pa-8(style="max-width: 400px; width: 100%;")
v-card-title.text-h4.text-center.font-weight-bold.mb-6 Login
v-card-text
v-form
v-text-field(
label="Username"
prepend-inner-icon="mdi-account"
v-model="username"
outlined
dense
required
)
v-text-field(
label="Password"
prepend-inner-icon="mdi-lock"
type="password"
v-model="password"
outlined
dense
required
)
v-checkbox(
label="Remember me"
class="my-4"
)
v-btn(
color="primary"
large
block
@click="loginHandler"
) Login
v-card-actions.justify-center
span.text-caption Don't have an account?
v-btn.text.small( color="primary") Sign Up
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
async function loginHandler() {
const success = await auth.login(username.value, password.value)
if (success) router.push('/')
else console.error(auth.error)
}
</script>

View File

@@ -8,6 +8,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { setupLayouts } from 'virtual:generated-layouts' import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes' import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '../stores/auth.ts'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -33,4 +34,19 @@ router.isReady().then(() => {
localStorage.removeItem('vuetify:dynamic-reload') localStorage.removeItem('vuetify:dynamic-reload')
}) })
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.user && !auth.loading) {
await auth.fetchUser()
}
if (to.path === '/login') return true
if (!auth.isAuthenticated) {
return { path: '/login' }
}
return true
})
export default router export default router

109
client/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface User {
id: number
username: string
email?: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchUser() {
try {
loading.value = true
const res = await fetch('/api/v1/user/info', {
method: 'GET',
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
user.value = data.user
error.value = null
} else if (res.status === 401) {
const refreshed = await refreshToken()
if (refreshed) return await fetchUser()
user.value = null
} else {
throw new Error('Failed to fetch user')
}
} catch (err: any) {
error.value = err.message
user.value = null
} finally {
loading.value = false
}
}
async function login(username: string, password: string) {
try {
loading.value = true
error.value = null
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!res.ok) throw new Error('Invalid credentials')
const data = await res.json()
if (data.ok && data.user) {
user.value = data.user
return true
}
throw new Error('Login failed')
} catch (err: any) {
error.value = err.message
return false
} finally {
loading.value = false
}
}
async function refreshToken() {
if (!document.cookie.includes('refresh_token')) return false
try {
const res = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
console.info('Token refreshed')
return true
}
console.warn('Token refresh failed')
return false
} catch {
return false
}
}
async function logout() {
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
})
} catch (err) {
console.warn('Logout error:', err)
} finally {
user.value = null
}
}
const isAuthenticated = computed(() => !!user.value)
return {
user,
loading,
error,
isAuthenticated,
login,
logout,
fetchUser,
refreshToken,
}
})

View File

@@ -21,6 +21,7 @@ declare module 'vue-router/auto-routes' {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>, '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/all': RouteRecordInfo<'/all', '/all', Record<never, never>, Record<never, never>>, '/all': RouteRecordInfo<'/all', '/all', Record<never, never>, Record<never, never>>,
'/kanji': RouteRecordInfo<'/kanji', '/kanji', Record<never, never>, Record<never, never>>, '/kanji': RouteRecordInfo<'/kanji', '/kanji', Record<never, never>, Record<never, never>>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
'/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>, '/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>,
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>, '/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
} }
@@ -48,6 +49,10 @@ declare module 'vue-router/auto-routes' {
routes: '/kanji' routes: '/kanji'
views: never views: never
} }
'src/pages/login.vue': {
routes: '/login'
views: never
}
'src/pages/vocab.vue': { 'src/pages/vocab.vue': {
routes: '/vocab' routes: '/vocab'
views: never views: never

View File

@@ -11,11 +11,13 @@ services:
- ./server:/app - ./server:/app
- /app/node_modules - /app/node_modules
environment: environment:
- NODE_ENV=dev - NODE_ENV=DEV
- PORT=3000 - PORT=3000
command: pnpm run dev command: pnpm run dev
networks: networks:
- srs-app-net - srs-app-net
env_file:
- .env
client: client:
build: build:
@@ -29,7 +31,7 @@ services:
- ./client:/app - ./client:/app
- /app/node_modules - /app/node_modules
environment: environment:
- NODE_ENV=dev - NODE_ENV=DEV
- VITE_APP_URL=http://srs-server:3000 - VITE_APP_URL=http://srs-server:3000
command: pnpm run dev command: pnpm run dev
depends_on: depends_on:

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"wanakana": "^5.3.1"
}
}

23
pnpm-lock.yaml generated
View File

@@ -1,23 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
wanakana:
specifier: ^5.3.1
version: 5.3.1
packages:
wanakana@5.3.1:
resolution: {integrity: sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==}
engines: {node: '>=12'}
snapshots:
wanakana@5.3.1: {}

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": { "dependencies": {
"@fastify/cors": "^11.1.0", "@fastify/cors": "^11.1.0",
"@fastify/static": "^8.2.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", "cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"fastify": "^5.6.1", "fastify": "^5.6.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"ldapts": "^8.0.9",
"mongodb": "^6.20.0", "mongodb": "^6.20.0",
"mongoose": "^8.19.1", "mongoose": "^8.19.1",
"node": "^24.10.0", "node": "^24.10.0",

223
server/pnpm-lock.yaml generated
View File

@@ -14,15 +14,39 @@ importers:
'@fastify/static': '@fastify/static':
specifier: ^8.2.0 specifier: ^8.2.0
version: 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: cors:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.5 version: 2.8.5
dotenv:
specifier: ^17.2.3
version: 17.2.3
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0 version: 5.1.0
express-rate-limit:
specifier: ^8.1.0
version: 8.1.0(express@5.1.0)
fastify: fastify:
specifier: ^5.6.1 specifier: ^5.6.1
version: 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: mongodb:
specifier: ^6.20.0 specifier: ^6.20.0
version: 6.20.0 version: 6.20.0
@@ -192,12 +216,20 @@ packages:
'@tsconfig/node16@1.0.4': '@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/asn1@0.2.4':
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 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': '@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
@@ -216,9 +248,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 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': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.7.2': '@types/node@24.7.2':
resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==} resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==}
@@ -304,6 +342,9 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 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: atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -333,6 +374,9 @@ packages:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'} engines: {node: '>=16.20.1'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -379,6 +423,13 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'} 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: cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'} engines: {node: '>=6.6.0'}
@@ -406,6 +457,15 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} 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: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -430,6 +490,10 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'} 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: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -437,6 +501,9 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -515,6 +582,12 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} 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: express@5.1.0:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -649,6 +722,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
http-errors@2.0.0: http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -679,6 +756,10 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 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: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -736,6 +817,16 @@ packages:
json-stable-stringify-without-jsonify@1.0.1: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} 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: kareem@2.6.3:
resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -743,6 +834,10 @@ packages:
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} 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: levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -754,9 +849,30 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} 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: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lru-cache@11.2.2: lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -1111,6 +1227,9 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1202,6 +1321,10 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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: v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -1397,6 +1520,10 @@ snapshots:
'@tsconfig/node16@1.0.4': {} '@tsconfig/node16@1.0.4': {}
'@types/asn1@0.2.4':
dependencies:
'@types/node': 24.7.2
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -1406,6 +1533,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.7.2 '@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': '@types/cors@2.8.19':
dependencies: dependencies:
'@types/node': 24.7.2 '@types/node': 24.7.2
@@ -1429,8 +1560,15 @@ snapshots:
'@types/json-schema@7.0.15': {} '@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/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/node@24.7.2': '@types/node@24.7.2':
dependencies: dependencies:
undici-types: 7.14.0 undici-types: 7.14.0
@@ -1514,6 +1652,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
avvio@9.1.0: avvio@9.1.0:
@@ -1550,6 +1692,8 @@ snapshots:
bson@6.10.4: {} bson@6.10.4: {}
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
@@ -1599,6 +1743,13 @@ snapshots:
content-type@1.0.5: {} 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-signature@1.2.2: {}
cookie@0.7.2: {} cookie@0.7.2: {}
@@ -1620,6 +1771,10 @@ snapshots:
data-uri-to-buffer@4.0.1: {} 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): debug@4.4.3(supports-color@5.5.0):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -1634,6 +1789,8 @@ snapshots:
diff@4.0.2: {} diff@4.0.2: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -1642,6 +1799,10 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -1731,6 +1892,11 @@ snapshots:
etag@1.8.1: {} 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: express@5.1.0:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@@ -1919,6 +2085,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
helmet@8.1.0: {}
http-errors@2.0.0: http-errors@2.0.0:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -1948,6 +2116,8 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
ip-address@10.0.1: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
ipaddr.js@2.2.0: {} ipaddr.js@2.2.0: {}
@@ -1990,12 +2160,47 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {} 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: {} kareem@2.6.3: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 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: levn@0.4.1:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -2011,8 +2216,22 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 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.merge@4.6.2: {}
lodash.once@4.1.1: {}
lru-cache@11.2.2: {} lru-cache@11.2.2: {}
make-error@1.3.6: {} make-error@1.3.6: {}
@@ -2345,6 +2564,8 @@ snapshots:
statuses@2.0.1: {} statuses@2.0.1: {}
strict-event-emitter-types@2.0.0: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@@ -2433,6 +2654,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
uuid@11.1.0: {}
v8-compile-cache-lib@3.0.1: {} v8-compile-cache-lib@3.0.1: {}
vary@1.1.2: {} 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 { ApiKeyModel } from '../../../models/apikey.model.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router() const router = express.Router()
router.use(verifyAccessToken)
router.get('/', async (_req: Request, res: Response) => { router.get('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try { try {
const doc = await ApiKeyModel.findOne({}) const userId = req.userId
const doc = await ApiKeyModel.findOne({ userId: userId })
const apiKey = doc?.apiKey || '' 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 { try {
const { apiKey } = req.body as { apiKey?: string } const { apiKey } = req.body
if (!apiKey || !apiKey.trim()) { if (!apiKey || !apiKey.trim()) {
return res.status(400).json({ error: 'Invalid API key' }) return res.status(400).json({ error: 'Invalid API key' })
} }
console.log(req.body)
const userId = req.userId
await ApiKeyModel.updateOne( await ApiKeyModel.updateOne(
{}, {},
{ $set: { apiKey: apiKey } }, { $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
{ upsert: true } { upsert: true },
) )
res.json({ success: 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 { try {
const result = await ApiKeyModel.deleteOne({}) const userId = req.userId
const result = await ApiKeyModel.deleteOne({ userId: userId })
if (result.deletedCount === 0) { if (result.deletedCount === 0) {
console.log('No API key found to delete.') 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) => { router.get('/', async (_req: Request, res: Response) => {
try { try {
const doc = await KanjiModel.find() const doc = await KanjiModel.find()
console.log(doc)
res.json(doc) res.json(doc)
} catch (error) { } catch (error) {
console.error('Error fetching Kanji Subjects', 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 { ApiKeyModel } from '../../../models/apikey.model.ts'
import { syncWanikaniData } from '../../../services/wanikaniService.ts' import { syncWanikaniData } from '../../../services/wanikaniService.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router() const router = express.Router()
interface ApiKeyDocument { router.get('/sync', verifyAccessToken, async (req: AuthRequest, res: Response) => {
apiKey?: string; if (!req.userId) return res.status(401).json({ error: 'Unauthorized' })
}
router.get('/sync', async (req, res) => {
try { try {
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null const apiKeyDoc = await ApiKeyModel.findOne({ userId: req.userId })
const apiKey = apiKeyDoc?.apiKey const apiKey = apiKeyDoc?.apiKey
console.log(apiKey, apiKeyDoc)
if (!apiKey || apiKey.trim() === '') { if (!apiKey || apiKey.trim() === '') {
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' }) 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 }) res.json({ success: true })
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@@ -1,7 +1,7 @@
import mongoose from 'mongoose' import mongoose from 'mongoose'
export async function connectMongo() { 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 if (mongoose.connection.readyState === 1) return
await mongoose.connect(url) await mongoose.connect(url)
console.log('✅ Connected to MongoDB at', 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 express from 'express'
import { connectMongo } from './db/connect.ts'
import cors from 'cors' 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 keyRouter from './api/v1/key/index.ts'
import syncRouter from './api/v1/wanikani/sync.ts' import syncRouter from './api/v1/wanikani/sync.ts'
import kanjiRouter from './api/v1/subject/kanji.ts' import kanjiRouter from './api/v1/subject/kanji.ts'
import vocabRouter from './api/v1/subject/vocab.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() 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(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/key', verifyAccessToken, keyRouter)
app.use('/api/v1/wanikani', syncRouter) app.use('/api/v1/subject/kanji', verifyAccessToken, kanjiRouter)
app.use('/api/v1/subject/kanji', kanjiRouter) app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
app.use('/api/v1/subject/vocab', 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 const PORT = process.env.PORT || 3000
connectMongo().then(() => { 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' import mongoose from 'mongoose'
const ApiKeySchema = new mongoose.Schema({ const ApiKeySchema = new mongoose.Schema({
userId: { type: String, required: true },
apiKey: { type: String, required: true }, apiKey: { type: String, required: true },
}) })

View File

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

View File

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

View File

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

View File

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