init: HejYou API Server (Hono + Node.js + TypeScript)

Thin proxy between React app and Directus.
Admin token stays server-side; clients get own 30-day JWTs.
Endpoints: /auth/* register/login/profile/me, /words, /questions,
/qa-pairs, /pair, /progress, /assets/:fileId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:35:19 +02:00
commit 311d35fac0
12 changed files with 1370 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
DIRECTUS_URL=https://db.hejyou.com
DIRECTUS_ADMIN_TOKEN=your-admin-token-here
JWT_SECRET=generate-a-long-random-string-here
PORT=3000
CORS_ORIGIN=https://app.hejyou.com

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.DS_Store

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Stage 1: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

604
package-lock.json generated Normal file
View File

@@ -0,0 +1,604 @@
{
"name": "hejyou-api",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hejyou-api",
"version": "1.0.0",
"dependencies": {
"@hono/node-server": "^1.13.7",
"dotenv": "^16.4.7",
"hono": "^4.7.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"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/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/tsx": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "hejyou-api",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"hono": "^4.7.0",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@types/node": "^22.10.7",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

28
src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import 'dotenv/config'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import authRoutes from './routes/auth'
import contentRoutes from './routes/content'
import progressRoutes from './routes/progress'
const app = new Hono()
app.use('*', logger())
app.use('*', cors({
origin: process.env.CORS_ORIGIN || '*',
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
}))
app.get('/health', (c) => c.json({ ok: true, service: 'hejyou-api' }))
app.route('/languparent/auth', authRoutes)
app.route('/languparent', contentRoutes)
app.route('/languparent', progressRoutes)
const port = parseInt(process.env.PORT || '3000')
console.log(`HejYou API running on port ${port}`)
serve({ fetch: app.fetch, port })

307
src/lib/directus.ts Normal file
View File

@@ -0,0 +1,307 @@
import 'dotenv/config'
const BASE = process.env.DIRECTUS_URL!
const ADMIN_TOKEN = process.env.DIRECTUS_ADMIN_TOKEN!
function adminHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ADMIN_TOKEN}`,
}
}
// ── Auth ──────────────────────────────────────────────────────────────────────
export async function dLogin(email: string, password: string): Promise<{ access_token: string }> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error((err as any)?.errors?.[0]?.message || 'Login fehlgeschlagen.')
}
const data = await res.json() as { data: { access_token: string } }
return { access_token: data.data.access_token }
}
export async function dRegister(email: string, password: string): Promise<void> {
const res = await fetch(`${BASE}/users/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.status === 204) return
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error((err as any)?.errors?.[0]?.message || 'Registrierung fehlgeschlagen.')
}
}
export async function dGetUserByToken(accessToken: string): Promise<{ id: string; username: string | null }> {
const res = await fetch(`${BASE}/users/me?fields=id,username`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error('Ungültiges Token')
const data = await res.json() as { data: { id: string; username: string | null } }
return { id: data.data.id, username: data.data.username }
}
// ── Profile ───────────────────────────────────────────────────────────────────
export async function dCheckUsername(username: string): Promise<boolean> {
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
const res = await fetch(
`${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Username-Check fehlgeschlagen')
const data = await res.json() as { data: any[] }
return data.data.length === 0
}
export async function dCreateProfile(opts: {
userId: string
username: string
nativeLang: string
targetLang: string
}): Promise<string> {
const { userId, username, nativeLang, targetLang } = opts
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
const h = adminHeaders()
// 1. Create users_language record
const profileRes = await fetch(`${BASE}/items/users_language`, {
method: 'POST',
headers: h,
body: JSON.stringify({ username_public: username, username_lowercases: clean }),
})
if (!profileRes.ok) {
const err = await profileRes.json().catch(() => ({}))
throw new Error((err as any)?.errors?.[0]?.message || 'Profilerstellung fehlgeschlagen')
}
const profileData = await profileRes.json() as { data: { id: string } }
const profileId = profileData.data.id
// 2. Patch Directus user: set username (= profileId) + languages
await fetch(`${BASE}/users/${userId}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
})
// 3. Link user UUID back to profile
await fetch(`${BASE}/items/users_language/${profileId}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ user: userId }),
})
// 4. Create initial learning pair
await fetch(`${BASE}/items/learning_pairs`, {
method: 'POST',
headers: h,
body: JSON.stringify({
user: profileId,
language_from: nativeLang,
language_to: targetLang,
active: true,
current_level: 1,
points: 0,
}),
})
return profileId
}
// ── Languages ─────────────────────────────────────────────────────────────────
export async function dGetLanguages(): Promise<any[]> {
const res = await fetch(
`${BASE}/items/language_options?filter[status][_eq]=published&fields=id,title_de,short&sort=title_en`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden')
const data = await res.json() as { data: any[] }
return data.data || []
}
// ── Learning Pair ─────────────────────────────────────────────────────────────
export async function dGetActivePair(profileId: string): Promise<any> {
const res = await fetch(
`${BASE}/items/learning_pairs?filter[user][_eq]=${profileId}&filter[active][_eq]=true` +
`&fields=id,language_from,language_to,current_level,points&limit=1`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Sprachpaar konnte nicht geladen werden')
const data = await res.json() as { data: any[] }
return data.data[0] || null
}
export async function dUpdatePairPoints(pairId: string, points: number): Promise<boolean> {
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, {
method: 'PATCH',
headers: adminHeaders(),
body: JSON.stringify({ points }),
})
return res.ok
}
// ── Words ─────────────────────────────────────────────────────────────────────
export async function dGetWords(limit = 100): Promise<any[]> {
const res = await fetch(
`${BASE}/items/words?filter[status][_eq]=published` +
`&fields=id,title_de,title_en,title_se,level&limit=${limit}`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Wörter konnten nicht geladen werden')
const data = await res.json() as { data: any[] }
return data.data || []
}
// ── Questions ─────────────────────────────────────────────────────────────────
export async function dGetQuestions(limit = 100): Promise<any[]> {
const res = await fetch(
`${BASE}/items/questions?filter[status][_eq]=published` +
`&fields=id,question_de,question_en,question_se,answer_de,answer_en,answer_se,level,related_words.words_id` +
`&limit=${limit}`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Fragen konnten nicht geladen werden')
const data = await res.json() as { data: any[] }
return data.data || []
}
// ── Progress ──────────────────────────────────────────────────────────────────
export async function dGetProgress(profileId: string, toLangId?: string): Promise<any[]> {
let url =
`${BASE}/items/user_progress?filter[user][_eq]=${profileId}` +
`&fields=id,word,question,card_type,result,language_from,language_to&limit=-1`
if (toLangId) url += `&filter[language_to][_eq]=${toLangId}`
const res = await fetch(url, { headers: adminHeaders() })
if (!res.ok) throw new Error('Fortschritt konnte nicht geladen werden')
const data = await res.json() as { data: any[] }
return data.data || []
}
export async function dSaveProgress(payload: {
user: string
word?: string | null
question?: string | null
card_type: string
result: string
points_earned: number
language_from: string
language_to: string
}): Promise<any> {
const res = await fetch(`${BASE}/items/user_progress`, {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify(payload),
})
if (!res.ok) return null
const data = await res.json() as { data: any }
return data.data
}
// ── QA Pairs ──────────────────────────────────────────────────────────────────
export async function dGetQAPairsAtLevel(level: number, langSuffix = 'de'): Promise<any[]> {
const fields = [
'id',
'picture.picture',
'selections',
'word_main.db_words_id.id',
'word_main.db_words_id.titel_de',
'word_main.db_words_id.titel_en',
'word_main.db_words_id.titel_se',
'pairs.db_pairs_id.id',
'pairs.db_pairs_id.level',
'pairs.db_pairs_id.status',
'pairs.db_pairs_id.statement.db_statement_id.statement_de',
'pairs.db_pairs_id.statement.db_statement_id.statement_en',
'pairs.db_pairs_id.statement.db_statement_id.statement_se',
].join(',')
const filter = encodeURIComponent(
JSON.stringify({ pairs: { db_pairs_id: { level: { _eq: level } } } }),
)
const res = await fetch(
`${BASE}/items/db_objects?fields=${fields}&filter=${filter}&limit=-1`,
{ headers: adminHeaders() },
)
if (!res.ok) return []
const data = await res.json() as { data: any[] }
const statementKey = `statement_${langSuffix}`
const titelKey = `titel_${langSuffix}`
const result: any[] = []
for (const obj of data.data || []) {
// Build word lookup { id -> text }
const wordsById: Record<string, string> = {}
let primaryWord = ''
for (const wm of obj.word_main || []) {
const word = wm?.db_words_id
if (!word) continue
const text: string = word[titelKey] || word.titel_de || ''
if (text) wordsById[word.id] = text
if (!primaryWord && text) primaryWord = text
}
for (const p of obj.pairs || []) {
const pair = p?.db_pairs_id
if (!pair) continue
if (pair.level !== level) continue
if (pair.status === 'archived') continue
const stmtRow = pair.statement?.[0]?.db_statement_id
if (!stmtRow) continue
const raw: string = stmtRow[statementKey] || stmtRow.statement_de || ''
if (!raw) continue
// Replace {prefix.wordId} → actual word text
const statement = raw.replace(/\{([^}]+)\}/g, (_m: string, ref: string) => {
const parts = ref.split('.')
const wid = parts[parts.length - 1]
return wordsById[wid] || primaryWord
})
result.push({
pairId: pair.id,
level: pair.level,
statement,
pictureFileId: obj.picture?.picture || null,
objectId: obj.id,
primaryWord,
selections: obj.selections || null,
})
}
}
return result
}
// ── Assets ────────────────────────────────────────────────────────────────────
export function dAssetUrl(fileId: string): string {
return `${BASE}/assets/${fileId}?access_token=${encodeURIComponent(ADMIN_TOKEN)}`
}
// ── Profile Data ──────────────────────────────────────────────────────────────
export async function dGetProfileData(userId: string): Promise<any> {
const res = await fetch(
`${BASE}/users/${userId}?fields=id,username,language_native,language_target,points_total,streak_days`,
{ headers: adminHeaders() },
)
if (!res.ok) throw new Error('Profil konnte nicht geladen werden')
const data = await res.json() as { data: any }
return data.data
}

27
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Context, Next } from 'hono'
import { verify } from 'hono/jwt'
export interface JwtPayload {
sub: string // Directus user UUID
username: string // profile ID (users_language.id)
exp: number
[key: string]: unknown // index signature required by hono/jwt
}
export async function requireAuth(c: Context<any>, next: Next): Promise<Response | void> {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
const token = authHeader.slice(7)
const secret = process.env.JWT_SECRET!
try {
const payload = await verify(token, secret, 'HS256') as unknown as JwtPayload
c.set('jwtPayload', payload)
await next()
} catch {
return c.json({ error: 'Invalid or expired token' }, 401)
}
}

135
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
import {
dLogin,
dRegister,
dGetUserByToken,
dCheckUsername,
dCreateProfile,
dGetProfileData,
} from '../lib/directus'
import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth'
const auth = new Hono()
const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 30 // 30 days
async function issueJwt(sub: string, username: string): Promise<{ token: string; expiresIn: number }> {
const secret = process.env.JWT_SECRET!
const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS
const payload: JwtPayload = { sub, username, exp }
const token = await sign(payload, secret)
return { token, expiresIn: JWT_EXPIRY_SECONDS }
}
// POST /register
auth.post('/register', async (c) => {
let body: { email?: string; password?: string }
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { email, password } = body
if (!email || !password) {
return c.json({ error: 'email and password are required' }, 400)
}
try {
await dRegister(email, password)
const { access_token } = await dLogin(email, password)
const user = await dGetUserByToken(access_token)
return c.json({ userId: user.id }, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Registration failed' }, 400)
}
})
// POST /profile
auth.post('/profile', async (c) => {
let body: { userId?: string; username?: string; nativeLang?: string; targetLang?: string }
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { userId, username, nativeLang, targetLang } = body
if (!userId || !username || !nativeLang || !targetLang) {
return c.json({ error: 'userId, username, nativeLang, and targetLang are required' }, 400)
}
try {
const profileId = await dCreateProfile({ userId, username, nativeLang, targetLang })
const { token, expiresIn } = await issueJwt(userId, profileId)
return c.json({ token, expiresIn }, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Profile creation failed' }, 400)
}
})
// POST /login
auth.post('/login', async (c) => {
let body: { email?: string; password?: string }
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { email, password } = body
if (!email || !password) {
return c.json({ error: 'email and password are required' }, 400)
}
try {
const { access_token } = await dLogin(email, password)
const user = await dGetUserByToken(access_token)
if (user.username === null) {
return c.json(
{ error: 'Profile not set up', userId: user.id, needsProfile: true },
403,
)
}
// user.username holds the profileId stored on the Directus user record
const { token, expiresIn } = await issueJwt(user.id, user.username)
return c.json({ token, expiresIn })
} catch (err: any) {
return c.json({ error: err.message || 'Login failed' }, 401)
}
})
// GET /me [requireAuth]
auth.get('/me', requireAuth, async (c) => {
const payload = c.get('jwtPayload') as JwtPayload
try {
const profile = await dGetProfileData(payload.sub)
if (!profile) {
return c.json({ error: 'Profile not found' }, 404)
}
return c.json(profile)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch profile' }, 500)
}
})
// GET /check-username?username=xxx [public]
auth.get('/check-username', async (c) => {
const username = c.req.query('username')
if (!username) {
return c.json({ error: 'username query parameter is required' }, 400)
}
try {
const available = await dCheckUsername(username)
return c.json({ available })
} catch (err: any) {
return c.json({ error: err.message || 'Failed to check username' }, 500)
}
})
export default auth

108
src/routes/content.ts Normal file
View File

@@ -0,0 +1,108 @@
import { Hono } from 'hono'
import {
dGetLanguages,
dGetActivePair,
dGetWords,
dGetQuestions,
dGetQAPairsAtLevel,
dAssetUrl,
} from '../lib/directus'
import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth'
const content = new Hono()
// GET /languages (public)
content.get('/languages', async (c) => {
try {
const languages = await dGetLanguages()
return c.json(languages)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch languages' }, 500)
}
})
// GET /pair [auth]
content.get('/pair', requireAuth, async (c) => {
const payload = c.get('jwtPayload') as JwtPayload
try {
const pair = await dGetActivePair(payload.username)
if (!pair) {
return c.json({ error: 'No active learning pair found' }, 404)
}
return c.json(pair)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch active pair' }, 500)
}
})
// GET /words [auth]
content.get('/words', requireAuth, async (c) => {
try {
const words = await dGetWords()
return c.json(words)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch words' }, 500)
}
})
// GET /questions [auth]
content.get('/questions', requireAuth, async (c) => {
try {
const questions = await dGetQuestions()
return c.json(questions)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch questions' }, 500)
}
})
// GET /qa-pairs [auth] ?level=1&lang=de
content.get('/qa-pairs', requireAuth, async (c) => {
const levelParam = c.req.query('level')
const lang = c.req.query('lang') || 'de'
if (!levelParam) {
return c.json({ error: 'level query parameter is required' }, 400)
}
const level = parseInt(levelParam, 10)
if (isNaN(level)) {
return c.json({ error: 'level must be a number' }, 400)
}
try {
const pairs = await dGetQAPairsAtLevel(level, lang)
return c.json(pairs)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch QA pairs' }, 500)
}
})
// GET /assets/:fileId [auth]
content.get('/assets/:fileId', requireAuth, async (c) => {
const fileId = c.req.param('fileId')
if (!fileId) return c.json({ error: 'fileId required' }, 400)
const assetUrl = dAssetUrl(fileId)
try {
const upstream = await fetch(assetUrl)
if (!upstream.ok) {
return c.json({ error: 'Asset not found' }, upstream.status as any)
}
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream'
const body = upstream.body
return new Response(body, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch asset' }, 500)
}
})
export default content

95
src/routes/progress.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Hono } from 'hono'
import { dGetProgress, dSaveProgress, dUpdatePairPoints } from '../lib/directus'
import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth'
const progress = new Hono()
// All routes require auth
progress.use('*', requireAuth)
// GET /progress ?lang=de
progress.get('/progress', async (c) => {
const payload = c.get('jwtPayload') as JwtPayload
const lang = c.req.query('lang')
try {
const data = await dGetProgress(payload.username, lang)
return c.json(data)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch progress' }, 500)
}
})
// POST /progress
progress.post('/progress', async (c) => {
const payload = c.get('jwtPayload') as JwtPayload
let body: {
word?: string | null
question?: string | null
card_type?: string
result?: string
points_earned?: number
language_from?: string
language_to?: string
}
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { word, question, card_type, result, points_earned, language_from, language_to } = body
if (!card_type || !result || points_earned === undefined || !language_from || !language_to) {
return c.json(
{ error: 'card_type, result, points_earned, language_from, and language_to are required' },
400,
)
}
try {
const saved = await dSaveProgress({
user: payload.username,
word: word ?? null,
question: question ?? null,
card_type,
result,
points_earned,
language_from,
language_to,
})
return c.json(saved, 201)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to save progress' }, 500)
}
})
// PATCH /pair/:pairId/points { points }
progress.patch('/pair/:pairId/points', async (c) => {
const pairId = c.req.param('pairId')
let body: { points?: number }
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
if (body.points === undefined || typeof body.points !== 'number') {
return c.json({ error: 'points (number) is required' }, 400)
}
try {
const ok = await dUpdatePairPoints(pairId, body.points)
if (!ok) {
return c.json({ error: 'Failed to update pair points' }, 500)
}
return c.json({ ok: true })
} catch (err: any) {
return c.json({ error: err.message || 'Failed to update pair points' }, 500)
}
})
export default progress

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}