commit 311d35fac043fa6724a63744f8e7421ae56e435a Author: admin Date: Thu May 14 22:35:19 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b181ee2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9145c03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11724d3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..af32c64 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddd29d8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2e7330f --- /dev/null +++ b/src/index.ts @@ -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 }) diff --git a/src/lib/directus.ts b/src/lib/directus.ts new file mode 100644 index 0000000..35a2152 --- /dev/null +++ b/src/lib/directus.ts @@ -0,0 +1,307 @@ +import 'dotenv/config' + +const BASE = process.env.DIRECTUS_URL! +const ADMIN_TOKEN = process.env.DIRECTUS_ADMIN_TOKEN! + +function adminHeaders(): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {} + 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 { + 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 +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..0c4556a --- /dev/null +++ b/src/middleware/auth.ts @@ -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, next: Next): Promise { + 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) + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..208d1a7 --- /dev/null +++ b/src/routes/auth.ts @@ -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 diff --git a/src/routes/content.ts b/src/routes/content.ts new file mode 100644 index 0000000..6e03013 --- /dev/null +++ b/src/routes/content.ts @@ -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 diff --git a/src/routes/progress.ts b/src/routes/progress.ts new file mode 100644 index 0000000..ff933c5 --- /dev/null +++ b/src/routes/progress.ts @@ -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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..25d654e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src"] +}