Compare commits

...

37 Commits

Author SHA1 Message Date
521f8df465 feat(client): bio editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
bbe03b36e0 WIP
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
4e45a9b6d0 feat(client): update userinfo
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
27ac4d9b4a feat: sync api changes and fix auth-related bugs
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
a60a796345 refactor: use SetError in exception.Builder where errors are available
Update multiple services and middlewares to pass the original error to exception.Builder before building the error code.

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:42:52 +08:00
14f50ecdb2 refactor: update exception constants to follow new naming convention
- Update old ErrorStatus, ErrorType, and Service/Endpoint constants to new naming convention
- Fix incorrect TypeSpecific usage in JWT middleware
- Add missing event specific error definitions to specific.yaml
- Regenerate exception constants

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:34:09 +08:00
b1c78dce28 Add Build error hook (print exception)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:28:23 +08:00
585ec46282 Fix some type change bugs (error)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:16:47 +08:00
8f69b61799 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:59:03 +08:00
64bab332c9 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:58:42 +08:00
38401a5f69 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:57:06 +08:00
f03d472c30 Ignore generated go files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:55:05 +08:00
2d6f6700f0 Move definitions to gen_exception
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:53:28 +08:00
2e11fc5d9c Fix go mod
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:51:37 +08:00
ac428946e7 Use generator to generate exceptions from yaml
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:48:37 +08:00
e4329dfc2b refactor: standardize error handling with exception.Builder
- Replace hardcoded error messages with structured error codes using exception.Builder.
- Introduce new common error constants in exception/common.go (CommonErrorInvalidInput, CommonErrorUserNotFound, etc.).
- Update exception/specific.go with domain-specific errors and remove redundant ones.
- Apply consistent error handling across auth, event, user services and middleware.

Co-authored-by: Gemini <gemini@google.com>
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 12:47:49 +08:00
5dbbdc62e6 Add exception error manager
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 12:04:17 +08:00
200614a5c9 Add error retern for database
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:03:56 +08:00
4ac5b1c101 Fix error reponses
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:01:13 +08:00
b7e6009706 Change logrus to slog
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:52:54 +08:00
fd262239e4 Remove file logger from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:32:13 +08:00
cf761d218d Fix gin debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:31:24 +08:00
110627f27e Fix gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:29:01 +08:00
64392c32c6 Restruct logger order
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:25:10 +08:00
3f8f2547be Split and optimize gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:13:13 +08:00
632fa6cf8e Fix config types
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:03:12 +08:00
d04f8cdc44 Move email send from to send func
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:02:29 +08:00
97f5677a97 Remove oauth login email
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 08:58:14 +08:00
2ed4a4da02 User update check one by one
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 23:33:50 +08:00
100fe32f8e Disable email changes, lazy~~~~~
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 20:02:54 +08:00
231f591767 Fix bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:48:59 +08:00
0e7aaed154 Fix typo
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:10:00 +08:00
89c2d11f19 Fix exchange bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:04:26 +08:00
cd93491d98 Add exchange api endpoint, fix jwt authtoken var type error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 18:51:15 +08:00
9b83ab565a Fix response structure error and router error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 17:48:52 +08:00
5e17bbd965 Fix Containerfile using just build
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:20:44 +08:00
de0d05df0a Add charts empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:14:26 +08:00
65 changed files with 2040 additions and 767 deletions

View File

@@ -1 +1,2 @@
TZ=Asia/Shanghai TZ=Asia/Shanghai
LOG_LEVEL=debug

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ go.work.sum
.DS_Store .DS_Store
__MACOSX __MACOSX
._* ._*
# go gen
*_gen.go

View File

@@ -1,16 +1,15 @@
FROM docker.io/node:22-alpine AS client-build FROM docker.io/node:22-alpine AS client-cms-build
RUN apk add just -y
RUN npm install -g corepack && \ RUN npm install -g corepack && \
corepack enable corepack enable
WORKDIR /app WORKDIR /app
ENV VITE_APP_BASE_URL=$CLIENT_BASE_URL ENV VITE_APP_BASE_URL=$CLIENT_BASE_URL
COPY . . COPY . .
RUN cd /app/client && \ RUN just build-client-cms
pnpm install -r --frozen-lockfile && \
pnpm run build
FROM docker.io/busybox:1.37 AS client FROM docker.io/busybox:1.37 AS client-cms
WORKDIR /app WORKDIR /app
COPY --from=client-build /app/client/dist . COPY --from=client-build /app/.outputs/client/cms/dist .
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["httpd", "-f", "-p", "3000", "-h", "/app", "-v"] ENTRYPOINT ["httpd", "-f", "-p", "3000", "-h", "/app", "-v"]

0
charts/.gitkeep Normal file
View File

View File

@@ -3,7 +3,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({ export default antfu({
gitignore: true, gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**'], ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*'],
react: true, react: true,
stylistic: { stylistic: {
semi: true, semi: true,

View File

@@ -37,6 +37,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4", "@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1", "@tanstack/zod-form-adapter": "^0.42.1",
"@uiw/react-md-editor": "^4.0.11",
"axios": "^1.13.2", "axios": "^1.13.2",
"base-64": "^1.0.0", "base-64": "^1.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@@ -44,6 +45,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"culori": "^4.0.2", "culori": "^4.0.2",
"immer": "^11.1.0", "immer": "^11.1.0",
"lodash-es": "^4.17.22",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -69,6 +71,7 @@
"@tanstack/router-plugin": "^1.141.7", "@tanstack/router-plugin": "^1.141.7",
"@types/base-64": "^1.0.2", "@types/base-64": "^1.0.2",
"@types/culori": "^4.0.1", "@types/culori": "^4.0.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
@@ -82,6 +85,7 @@
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"type-fest": "^5.4.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",

View File

@@ -89,6 +89,9 @@ importers:
'@tanstack/zod-form-adapter': '@tanstack/zod-form-adapter':
specifier: ^0.42.1 specifier: ^0.42.1
version: 0.42.1(zod@4.3.5) version: 0.42.1(zod@4.3.5)
'@uiw/react-md-editor':
specifier: ^4.0.11
version: 4.0.11(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
axios: axios:
specifier: ^1.13.2 specifier: ^1.13.2
version: 1.13.2 version: 1.13.2
@@ -110,6 +113,9 @@ importers:
immer: immer:
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.3 version: 11.1.3
lodash-es:
specifier: ^4.17.22
version: 4.17.22
lucide-react: lucide-react:
specifier: ^0.562.0 specifier: ^0.562.0
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
@@ -180,6 +186,9 @@ importers:
'@types/culori': '@types/culori':
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/node': '@types/node':
specifier: ^25.0.3 specifier: ^25.0.3
version: 25.0.9 version: 25.0.9
@@ -219,6 +228,9 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
type-fest:
specifier: ^5.4.1
version: 5.4.1
typescript: typescript:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
@@ -1254,66 +1266,79 @@ packages:
resolution: {integrity: sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==} resolution: {integrity: sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.2': '@rollup/rollup-linux-arm-musleabihf@4.55.2':
resolution: {integrity: sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==} resolution: {integrity: sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.2': '@rollup/rollup-linux-arm64-gnu@4.55.2':
resolution: {integrity: sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==} resolution: {integrity: sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.2': '@rollup/rollup-linux-arm64-musl@4.55.2':
resolution: {integrity: sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==} resolution: {integrity: sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.2': '@rollup/rollup-linux-loong64-gnu@4.55.2':
resolution: {integrity: sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==} resolution: {integrity: sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.2': '@rollup/rollup-linux-loong64-musl@4.55.2':
resolution: {integrity: sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==} resolution: {integrity: sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.2': '@rollup/rollup-linux-ppc64-gnu@4.55.2':
resolution: {integrity: sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==} resolution: {integrity: sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.2': '@rollup/rollup-linux-ppc64-musl@4.55.2':
resolution: {integrity: sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==} resolution: {integrity: sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.2': '@rollup/rollup-linux-riscv64-gnu@4.55.2':
resolution: {integrity: sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==} resolution: {integrity: sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.2': '@rollup/rollup-linux-riscv64-musl@4.55.2':
resolution: {integrity: sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==} resolution: {integrity: sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.2': '@rollup/rollup-linux-s390x-gnu@4.55.2':
resolution: {integrity: sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==} resolution: {integrity: sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.2': '@rollup/rollup-linux-x64-gnu@4.55.2':
resolution: {integrity: sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==} resolution: {integrity: sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.2': '@rollup/rollup-linux-x64-musl@4.55.2':
resolution: {integrity: sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==} resolution: {integrity: sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.2': '@rollup/rollup-openbsd-x64@4.55.2':
resolution: {integrity: sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==} resolution: {integrity: sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==}
@@ -1472,24 +1497,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1724,12 +1753,21 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/hast@2.3.10':
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@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/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.23':
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -1739,6 +1777,9 @@ packages:
'@types/node@25.0.9': '@types/node@25.0.9':
resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -1818,6 +1859,21 @@ packages:
resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@uiw/copy-to-clipboard@1.0.19':
resolution: {integrity: sha512-AYxzFUBkZrhtExb2QC0C4lFH2+BSx6JVId9iqeGHakBuosqiQHUQaNZCvIBeM97Ucp+nJ22flOh8FBT2pKRRAA==}
'@uiw/react-markdown-preview@5.1.5':
resolution: {integrity: sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@uiw/react-md-editor@4.0.11':
resolution: {integrity: sha512-F0OR5O1v54EkZYvJj3ew0I7UqLiPeU34hMAY4MdXS3hI86rruYi5DHVkG/VuvLkUZW7wIETM2QFtZ459gKIjQA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -1936,6 +1992,9 @@ packages:
resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==} resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==}
hasBin: true hasBin: true
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
binary-extensions@2.3.0: binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2106,6 +2165,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-selector-parser@3.3.0:
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2213,6 +2275,10 @@ packages:
dijkstrajs@1.0.3: dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
direction@2.0.1:
resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
hasBin: true
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -2244,6 +2310,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.0: entities@7.0.0:
resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@@ -2724,12 +2794,54 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-has-property@3.0.0:
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@3.1.1:
resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-select@6.0.4:
resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6: hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@7.2.0:
resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
hermes-estree@0.25.1: hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -2742,6 +2854,9 @@ packages:
html-url-attributes@3.0.1: html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -2922,24 +3037,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2981,6 +3100,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3274,9 +3396,15 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
parse-numeric-range@1.3.0:
resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==}
parse-statements@1.0.11: parse-statements@1.0.11:
resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3349,6 +3477,9 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -3390,6 +3521,12 @@ packages:
'@types/react': '>=18' '@types/react': '>=18'
react: '>=18' react: '>=18'
react-markdown@9.0.3:
resolution: {integrity: sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.18.0: react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3462,6 +3599,9 @@ packages:
resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
refractor@4.9.0:
resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==}
regexp-ast-analysis@0.7.1: regexp-ast-analysis@0.7.1:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -3474,12 +3614,58 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true hasBin: true
rehype-attr@3.0.3:
resolution: {integrity: sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw==}
engines: {node: '>=16'}
rehype-autolink-headings@7.1.0:
resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
rehype-ignore@2.0.3:
resolution: {integrity: sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==}
engines: {node: '>=16'}
rehype-parse@9.0.1:
resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
rehype-prism-plus@2.0.0:
resolution: {integrity: sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ==}
rehype-prism-plus@2.0.1:
resolution: {integrity: sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
rehype-rewrite@4.0.4:
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
engines: {node: '>=16.0.0'}
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
rehype-stringify@10.0.1:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
rehype@13.0.2:
resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-github-blockquote-alert@1.3.1:
resolution: {integrity: sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg==}
engines: {node: '>=16'}
remark-parse@11.0.0: remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2: remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-directory@2.1.1: require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3650,6 +3836,10 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-merge@3.4.0: tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -3721,6 +3911,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
type-fest@5.4.1:
resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==}
engines: {node: '>=20'}
typescript-eslint@8.53.1: typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==} resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3742,6 +3936,9 @@ packages:
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-filter@5.0.1:
resolution: {integrity: sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==}
unist-util-is@6.0.1: unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -3807,6 +4004,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3: vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -3867,6 +4067,9 @@ packages:
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -5386,12 +5589,22 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/hast@2.3.10':
dependencies:
'@types/unist': 2.0.11
'@types/hast@3.0.4': '@types/hast@3.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.23
'@types/lodash@4.17.23': {}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -5402,6 +5615,8 @@ snapshots:
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
dependencies: dependencies:
'@types/node': 25.0.9 '@types/node': 25.0.9
@@ -5511,6 +5726,41 @@ snapshots:
'@typescript-eslint/types': 8.53.1 '@typescript-eslint/types': 8.53.1
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@uiw/copy-to-clipboard@1.0.19': {}
'@uiw/react-markdown-preview@5.1.5(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@uiw/copy-to-clipboard': 1.0.19
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-markdown: 9.0.3(@types/react@19.2.8)(react@19.2.3)
rehype-attr: 3.0.3
rehype-autolink-headings: 7.1.0
rehype-ignore: 2.0.3
rehype-prism-plus: 2.0.0
rehype-raw: 7.0.0
rehype-rewrite: 4.0.4
rehype-slug: 6.0.0
remark-gfm: 4.0.1
remark-github-blockquote-alert: 1.3.1
unist-util-visit: 5.0.0
transitivePeerDependencies:
- '@types/react'
- supports-color
'@uiw/react-md-editor@4.0.11(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@uiw/react-markdown-preview': 5.1.5(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
rehype: 13.0.2
rehype-prism-plus: 2.0.1
transitivePeerDependencies:
- '@types/react'
- supports-color
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
@@ -5642,6 +5892,8 @@ snapshots:
baseline-browser-mapping@2.9.15: {} baseline-browser-mapping@2.9.15: {}
bcp-47-match@2.0.3: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
birecord@0.1.1: {} birecord@0.1.1: {}
@@ -5798,6 +6050,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-selector-parser@3.3.0: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -5874,6 +6128,8 @@ snapshots:
dijkstrajs@1.0.3: {} dijkstrajs@1.0.3: {}
direction@2.0.1: {}
dom-helpers@5.2.1: dom-helpers@5.2.1:
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -5905,6 +6161,8 @@ snapshots:
entities@4.5.0: {} entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.0: {} entities@7.0.0: {}
environment@1.1.0: {} environment@1.1.0: {}
@@ -6503,6 +6761,94 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.3
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-has-property@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-heading-rank@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@3.1.1:
dependencies:
'@types/hast': 2.3.10
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-select@6.0.4:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
bcp-47-match: 2.0.3
comma-separated-tokens: 2.0.3
css-selector-parser: 3.3.0
devlop: 1.1.0
direction: 2.0.1
hast-util-has-property: 3.0.0
hast-util-to-string: 3.0.1
hast-util-whitespace: 3.0.0
nth-check: 2.1.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
unist-util-visit: 5.0.0
zwitch: 2.0.4
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6: hast-util-to-jsx-runtime@2.3.6:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -6523,10 +6869,40 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-string@3.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
hastscript@7.2.0:
dependencies:
'@types/hast': 2.3.10
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 3.1.1
property-information: 6.5.0
space-separated-tokens: 2.0.2
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
hermes-estree@0.25.1: {} hermes-estree@0.25.1: {}
hermes-parser@0.25.1: hermes-parser@0.25.1:
@@ -6537,6 +6913,8 @@ snapshots:
html-url-attributes@3.0.1: {} html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -6733,6 +7111,8 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash-es@4.17.22: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.21: {} lodash@4.17.21: {}
@@ -7253,8 +7633,14 @@ snapshots:
json-parse-even-better-errors: 2.3.1 json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4 lines-and-columns: 1.2.4
parse-numeric-range@1.3.0: {}
parse-statements@1.0.11: {} parse-statements@1.0.11: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@@ -7317,6 +7703,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
property-information@6.5.0: {}
property-information@7.1.0: {} property-information@7.1.0: {}
proxy-from-env@1.1.0: {} proxy-from-env@1.1.0: {}
@@ -7362,6 +7750,23 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
react-markdown@9.0.3(@types/react@19.2.8)(react@19.2.3):
dependencies:
'@types/hast': 3.0.4
'@types/react': 19.2.8
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.3
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.0.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.18.0: {} react-refresh@0.18.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3): react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3):
@@ -7443,6 +7848,13 @@ snapshots:
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
refractor@4.9.0:
dependencies:
'@types/hast': 2.3.10
'@types/prismjs': 1.26.5
hastscript: 7.2.0
parse-entities: 4.0.2
regexp-ast-analysis@0.7.1: regexp-ast-analysis@0.7.1:
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -7454,6 +7866,98 @@ snapshots:
dependencies: dependencies:
jsesc: 3.1.0 jsesc: 3.1.0
rehype-attr@3.0.3:
dependencies:
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-autolink-headings@7.1.0:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
hast-util-heading-rank: 3.0.0
hast-util-is-element: 3.0.0
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-ignore@2.0.3:
dependencies:
hast-util-select: 6.0.4
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-parse@9.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-from-html: 2.0.3
unified: 11.0.5
rehype-prism-plus@2.0.0:
dependencies:
hast-util-to-string: 3.0.1
parse-numeric-range: 1.3.0
refractor: 4.9.0
rehype-parse: 9.0.1
unist-util-filter: 5.0.1
unist-util-visit: 5.0.0
rehype-prism-plus@2.0.1:
dependencies:
hast-util-to-string: 3.0.1
parse-numeric-range: 1.3.0
refractor: 4.9.0
rehype-parse: 9.0.1
unist-util-filter: 5.0.1
unist-util-visit: 5.0.0
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-rewrite@4.0.4:
dependencies:
hast-util-select: 6.0.4
unified: 11.0.5
unist-util-visit: 5.0.0
rehype-slug@6.0.0:
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.0.0
rehype-stringify@10.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
unified: 11.0.5
rehype@13.0.2:
dependencies:
'@types/hast': 3.0.4
rehype-parse: 9.0.1
rehype-stringify: 10.0.1
unified: 11.0.5
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-github-blockquote-alert@1.3.1:
dependencies:
unist-util-visit: 5.0.0
remark-parse@11.0.0: remark-parse@11.0.0:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -7471,6 +7975,12 @@ snapshots:
unified: 11.0.5 unified: 11.0.5
vfile: 6.0.3 vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-main-filename@2.0.0: {} require-main-filename@2.0.0: {}
@@ -7639,6 +8149,8 @@ snapshots:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
tagged-tag@1.0.0: {}
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {} tailwindcss@4.1.18: {}
@@ -7699,6 +8211,10 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@5.4.1:
dependencies:
tagged-tag: 1.0.0
typescript-eslint@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): typescript-eslint@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -7726,6 +8242,12 @@ snapshots:
trough: 2.2.0 trough: 2.2.0
vfile: 6.0.3 vfile: 6.0.3
unist-util-filter@5.0.1:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unist-util-is@6.0.1: unist-util-is@6.0.1:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -7798,6 +8320,11 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3: vfile-message@4.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -7864,6 +8391,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
web-namespaces@2.0.1: {}
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}
which-module@2.0.1: {} which-module@2.0.1: {}

View File

@@ -19,18 +19,25 @@ import {
import { import {
Input, Input,
} from '@/components/ui/input'; } from '@/components/ui/input';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo';
const formSchema = z.object({ const formSchema = z.object({
email: z.string(), username: z.string().min(5),
nickname: z.string().min(1), nickname: z.string().min(1),
subtitle: z.string().min(1), subtitle: z.string().min(1),
avatar: z.url().min(1),
}); });
export function EditProfileDialog() { export function EditProfileDialog() {
const { data: user } = useUserInfo();
const { mutateAsync } = useUpdateUser();
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
email: '', avatar: user.avatar,
nickname: '', username: user.username,
subtitle: '', nickname: user.nickname,
subtitle: user.subtitle,
}, },
validators: { validators: {
onBlur: formSchema, onBlur: formSchema,
@@ -39,13 +46,12 @@ export function EditProfileDialog() {
value, value,
}) => { }) => {
try { try {
toast( await mutateAsync(value);
<code className="text-white">{JSON.stringify(value, null, 2)}</code>, toast.success('个人资料更新成功');
);
} }
catch (error) { catch (error) {
console.error('Form submission error', error); console.error('Form submission error', error);
toast.error('Failed to submit the form. Please try again.'); toast.error('更新个人资料失败,请重试');
} }
}, },
}); });
@@ -53,60 +59,94 @@ export function EditProfileDialog() {
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full mt-4" size="lg"></Button> <Button variant="outline" className="w-full" size="lg"></Button>
</DialogTrigger> </DialogTrigger>
<form <DialogContent className="sm:max-w-[425px]">
onSubmit={(e) => { <form
e.preventDefault(); onSubmit={(e) => {
e.stopPropagation(); e.preventDefault();
void form.handleSubmit(); e.stopPropagation();
}} void form.handleSubmit();
> }}
<DialogContent className="sm:max-w-[425px]"> className="grid gap-4"
>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<Field> <form.Field name="username">
<FieldLabel htmlFor="email">Email</FieldLabel> {field => (
<Input <Field>
id="email" <FieldLabel htmlFor="username"></FieldLabel>
name="email" <Input
placeholder="noa@requiem.garden" id="username"
value={form.getFieldValue('email')} name="username"
onChange={e => form.setFieldValue('email', e.target.value)} placeholder={user.username}
/> value={field.state.value}
<FieldError /> onBlur={field.handleBlur}
</Field> onChange={e => field.handleChange(e.target.value)}
<Field> />
<FieldLabel htmlFor="nickname"></FieldLabel> <FieldError errors={field.state.meta.errors} />
<Input </Field>
id="nickname" )}
name="nickname" </form.Field>
placeholder="Noa Virellia" <form.Field name="nickname">
value={form.getFieldValue('nickname')} {field => (
onChange={e => form.setFieldValue('nickname', e.target.value)} <Field>
/> <FieldLabel htmlFor="nickname"></FieldLabel>
<FieldError /> <Input
</Field> id="nickname"
<Field> name="nickname"
<FieldLabel htmlFor="subtitle"></FieldLabel> placeholder={user.nickname}
<Input value={field.state.value}
id="subtitle" onBlur={field.handleBlur}
name="subtitle" onChange={e => field.handleChange(e.target.value)}
placeholder="天生骄傲" />
value={form.getFieldValue('subtitle')} <FieldError errors={field.state.meta.errors} />
onChange={e => form.setFieldValue('subtitle', e.target.value)} </Field>
/> )}
<FieldError /> </form.Field>
</Field> <form.Field name="subtitle">
{field => (
<Field>
<FieldLabel htmlFor="subtitle"></FieldLabel>
<Input
id="subtitle"
name="subtitle"
placeholder={user.subtitle}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="avatar">
{field => (
<Field>
<FieldLabel htmlFor="avatar"></FieldLabel>
<Input
id="avatar"
name="avatar"
placeholder={user.avatar}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline"></Button> <Button variant="outline"></Button>
</DialogClose> </DialogClose>
<Button type="submit"></Button> <DialogClose asChild>
<Button type="submit"></Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </form>
</form> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,102 +0,0 @@
import {
useForm,
} from '@tanstack/react-form';
import {
toast,
} from 'sonner';
import {
z,
} from 'zod';
import {
Button,
} from '@/components/ui/button';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import {
Input,
} from '@/components/ui/input';
const formSchema = z.object({
email: z.string(),
nickname: z.string().min(1),
subtitle: z.string().min(1),
});
export default function EditProfileForm() {
const form = useForm({
defaultValues: {
email: '',
nickname: '',
subtitle: '',
},
validators: {
onBlur: formSchema,
},
onSubmit: async ({
value,
}) => {
try {
toast(
<code className="text-white">{JSON.stringify(value, null, 2)}</code>,
);
}
catch (error) {
console.error('Form submission error', error);
toast.error('Failed to submit the form. Please try again.');
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
placeholder="noa@requiem.garden"
value={form.getFieldValue('email')}
onChange={e => form.setFieldValue('email', e.target.value)}
/>
<FieldError />
</Field>
<Field>
<FieldLabel htmlFor="nickname"></FieldLabel>
<Input
id="nickname"
name="nickname"
placeholder="Noa Virellia"
value={form.getFieldValue('nickname')}
onChange={e => form.setFieldValue('nickname', e.target.value)}
/>
<FieldError />
</Field>
<Field>
<FieldLabel htmlFor="subtitle"></FieldLabel>
<Input
id="subtitle"
name="subtitle"
placeholder="天生骄傲"
value={form.getFieldValue('subtitle')}
onChange={e => form.setFieldValue('subtitle', e.target.value)}
/>
<FieldError />
</Field>
<Button type="submit"></Button>
</form>
);
}

View File

@@ -1,34 +1,81 @@
import { Mail } from 'lucide-react'; import MDEditor from '@uiw/react-md-editor';
import { isNil } from 'lodash-es';
import { Mail, Pencil } from 'lucide-react';
import { useState } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { toast } from 'sonner';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo'; import { useUserInfo } from '@/hooks/data/useUserInfo';
import { base64ToUtf8 } from '@/lib/utils'; import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
import { Button } from '../ui/button';
import { EditProfileDialog } from './edit-profile-dialog'; import { EditProfileDialog } from './edit-profile-dialog';
export function MainProfile() { export function MainProfile() {
const { data: user } = useUserInfo(); const { data: user } = useUserInfo();
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio));
const [enableBioEdit, setEnableBioEdit] = useState(false);
const { mutateAsync } = useUpdateUser();
return ( return (
<div className="flex flex-col w-full"> <div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
<div className="flex w-full flex-row gap-4 mt-2"> <div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
<Avatar className="size-16 rounded-full border-2 border-muted"> <div className="flex flex-col w-full gap-3">
<AvatarImage src={user.avatar} alt={user.nickname} /> <div className="flex flex-col w-full gap-3">
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <div className="flex flex-row gap-3 w-full lg:flex-col">
</Avatar> <Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
<div className="flex flex-1 flex-col justify-center"> <AvatarImage src={user.avatar} alt={user.nickname} />
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span> <AvatarFallback className="rounded-lg">CN</AvatarFallback>
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span> </Avatar>
<div className="flex flex-1 flex-col justify-center lg:mt-3">
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
</div>
</div>
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
<Mail className="h-4 w-4 stroke-muted-foreground" />
{user.email}
</div>
</div>
<EditProfileDialog />
</div> </div>
</div> </div>
<EditProfileDialog /> <section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
<section className="px-2 mt-4">
<div className="flex flex-row gap-2 items-center text-sm">
<Mail className="h-4 w-4 stroke-muted-foreground" />
{user.email}
</div>
</section>
<section className="rounded-md border border-muted w-full min-h-72 mt-4 p-6 prose dark:prose-invert max-w-[1012px] self-center">
{/* Bio */} {/* Bio */}
<Markdown>{base64ToUtf8(user.bio)}</Markdown> {enableBioEdit
? (
<MDEditor
value={bio}
onChange={setBio}
height="100%"
/>
)
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
<Button
className="absolute bottom-4 right-4"
// eslint-disable-next-line ts/no-misused-promises
onClick={async () => {
if (!enableBioEdit) {
setEnableBioEdit(true);
}
else {
if (!isNil(bio)) {
try {
await mutateAsync({ bio: utf8ToBase64(bio) });
setEnableBioEdit(false);
}
catch (error) {
console.error(error);
toast.error('个人简介更新失败');
}
}
}
}}
size="icon-sm"
variant={enableBioEdit ? 'default' : 'outline'}
>
<Pencil />
</Button>
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface UpdateUserPayload {
avatar?: string;
bio?: string;
nickname?: string;
subtitle?: string;
username?: string;
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: UpdateUserPayload) => {
return axiosClient.patch<{ status: string }>('/user/update', payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['userInfo'] });
},
});
}

View File

@@ -6,6 +6,7 @@ export function useUserInfo() {
queryKey: ['userInfo'], queryKey: ['userInfo'],
queryFn: async () => { queryFn: async () => {
const response = await axiosClient.get<{ const response = await axiosClient.get<{
username: string;
user_id: string; user_id: string;
email: string; email: string;
type: string; type: string;

View File

@@ -1,10 +1,17 @@
import type { AxiosRequestConfig } from 'axios'; import type { AxiosError, AxiosRequestConfig } from 'axios';
import axios, { AxiosError } from 'axios'; import type { JsonValue } from 'type-fest';
import axios from 'axios';
import { isNil } from 'lodash-es';
import { router } from '@/lib/router'; import { router } from '@/lib/router';
import { doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token'; import { clearTokens, doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
export const HEADER_API_VERSION = {
'X-Api-Version': 'latest',
};
export const axiosClient = axios.create({ export const axiosClient = axios.create({
baseURL: '/api/v1/', baseURL: '/api/v1/',
headers: HEADER_API_VERSION,
}); });
axiosClient.interceptors.request.use((config) => { axiosClient.interceptors.request.use((config) => {
@@ -18,27 +25,43 @@ axiosClient.interceptors.request.use((config) => {
type RetryConfig = AxiosRequestConfig & { _retry?: boolean }; type RetryConfig = AxiosRequestConfig & { _retry?: boolean };
axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => { interface ResponseData {
code: number;
error_id: string;
status: string;
data: JsonValue;
}
axiosClient.interceptors.response.use(async (response) => {
const data = response.data as ResponseData;
if (data.code !== 200) {
return Promise.reject(data);
}
response.data = data.data;
return response;
}, async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig | undefined; const originalRequest = error.config as RetryConfig | undefined;
if (!error.response || error.response.status !== 401 || !originalRequest) { if (!error.response || error.response.status !== 401 || !originalRequest) {
return Promise.reject(error); return Promise.reject(error);
} }
if (error.response.status === 401 && originalRequest.url !== '/auth/refresh' && !isNil(getRefreshToken())) {
if (error.response.status === 401 && getRefreshToken() !== null) {
try { try {
const maybeRefreshTokenValue = await doRefreshToken(); const maybeRefreshTokenResponse = await doRefreshToken();
const { access_token, refresh_token } = maybeRefreshTokenValue.data; if (maybeRefreshTokenResponse.status !== 200) {
throw new Error('Failed to refresh token');
}
const { access_token, refresh_token } = maybeRefreshTokenResponse.data;
originalRequest.headers = originalRequest.headers ?? {}; originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = `Bearer ${access_token}`; originalRequest.headers.Authorization = `Bearer ${access_token}`;
setToken(access_token); setToken(access_token);
setRefreshToken(refresh_token); setRefreshToken(refresh_token);
return await axiosClient(originalRequest); return await axiosClient(originalRequest);
} }
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) { catch (e) {
if (e instanceof AxiosError && e.status === 401) { // Should remove token (tokens are out of date)
await router.navigate({ to: '/authorize' }); clearTokens();
return Promise.reject(error); await router.navigate({ to: '/authorize' });
}
} }
} }
}); });

View File

@@ -1,4 +1,4 @@
import axios from 'axios'; import { axiosClient, HEADER_API_VERSION } from './axios';
export function setToken(token: string) { export function setToken(token: string) {
localStorage.setItem('token', token); localStorage.setItem('token', token);
@@ -30,11 +30,11 @@ export function clearTokens() {
} }
export async function doSetTokenByCode(code: string) { export async function doSetTokenByCode(code: string) {
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code }); const { data } = await axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/token', { code }, { headers: HEADER_API_VERSION });
setToken(data.access_token); setToken(data.access_token);
setRefreshToken(data.refresh_token); setRefreshToken(data.refresh_token);
} }
export async function doRefreshToken() { export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() }); return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
} }

View File

@@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/profile')({
function RouteComponent() { function RouteComponent() {
return ( return (
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6"> <div className="flex h-full flex-col gap-6 px-4 py-6">
<MainProfile /> <MainProfile />
</div> </div>
); );

View File

@@ -1,7 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import { isNil } from 'lodash-es';
import z from 'zod'; import z from 'zod';
import { LoginForm } from '@/components/login-form'; import { LoginForm } from '@/components/login-form';
import { axiosClient } from '@/lib/axios';
import { generateOAuthState } from '@/lib/random'; import { generateOAuthState } from '@/lib/random';
import { getToken } from '@/lib/token'; import { getToken } from '@/lib/token';
@@ -22,15 +24,21 @@ export const Route = createFileRoute('/authorize')({
function RouteComponent() { function RouteComponent() {
const token = getToken(); const token = getToken();
const oauthParams = Route.useSearch(); const oauthParams = Route.useSearch();
if (token !== null) { /**
const base = new URL(window.location.origin); * Auth by Token Flow
const url = new URL('/api/v1/auth/redirect', base); */
url.searchParams.set('client_id', oauthParams.client_id); if (!isNil(token)) {
url.searchParams.set('response_type', oauthParams.response_type); axiosClient.post<{ redirect_uri: string }>('/auth/exchange', {
url.searchParams.set('redirect_uri', oauthParams.redirect_uri); client_id: oauthParams.client_id,
url.searchParams.set('state', oauthParams.state); redirect_uri: oauthParams.redirect_uri,
window.location.href = url.toString(); state: oauthParams.state,
return null; }).then((res) => {
window.location.href = res.data.redirect_uri;
}).catch((e) => {
console.error(e);
return 'Token exchange failed';
});
return 'Redirecting';
} }
return ( return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">

View File

@@ -1,15 +1,15 @@
import path from "node:path"; import path from 'node:path';
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react';
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
import svgr from "vite-plugin-svgr"; import svgr from 'vite-plugin-svgr';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tanstackRouter({ tanstackRouter({
target: "react", target: 'react',
autoCodeSplitting: true, autoCodeSplitting: true,
}), }),
react(), react(),
@@ -18,15 +18,15 @@ export default defineConfig({
], ],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: { server: {
proxy: { proxy: {
"/api": "http://10.0.0.250:8000", '/api': 'http://10.0.0.10:8000',
}, },
host: "0.0.0.0", host: '0.0.0.0',
port: 5173, port: 5173,
allowedHosts: ["nix.org.cn", "nixos.party"], allowedHosts: ['nix.org.cn', 'nixos.party'],
}, },
}); });

View File

@@ -0,0 +1,10 @@
common:
error:
invalid_input: "00001"
unauthorized: "00002"
internal: "00003"
permission_denied: "00004"
uuid_parse_failed: "00005"
database: "00006"
missing_user_id: "00007"
user_not_found: "00008"

View File

@@ -0,0 +1,23 @@
endpoint:
auth:
service:
redirect: "01"
magic: "02"
token: "03"
refresh: "04"
exchange: "05"
event:
service:
info: "01"
checkin: "02"
checkin_query: "03"
checkin_submit: "04"
user:
service:
info: "01"
update: "02"
list: "03"
full: "04"
create: "05"
middleware:
service: "01"

View File

@@ -0,0 +1,6 @@
middleware:
service:
gin_logger: "901"
jwt: "902"
permission: "903"
api_version: "904"

View File

@@ -0,0 +1,4 @@
service:
auth: "001"
user: "002"
event: "003"

View File

@@ -0,0 +1,34 @@
api:
version:
not_found: "00001"
auth:
redirect:
token_invalid: "00001"
client_not_found: "00002"
uri_mismatch: "00003"
invalid_uri: "00004"
magic:
turnstile_failed: "00001"
code_gen_failed: "00002"
invalid_external_url: "00003"
invalid_email_config: "00004"
token:
invalid_token: "00001"
gen_failed: "00002"
refresh:
invalid_token: "00001"
renew_failed: "00002"
exchange:
get_user_id_failed: "00001"
code_gen_failed: "00002"
invalid_redirect_uri: "00003"
user:
list:
meilisearch_failed: "00001"
event:
info:
not_found: "00001"
checkin:
gen_code_failed: "00001"
checkin_query:
record_not_found: "00001"

View File

@@ -0,0 +1,5 @@
status:
success: "2"
user: "4"
server: "5"
client: "6"

View File

@@ -0,0 +1,3 @@
type:
common: "1"
specific: "0"

View File

@@ -0,0 +1,8 @@
// Code generated by gen-exception; DO NOT EDIT.
package exception
const (
{{- range .Items }}
{{ .Name }} = "{{ .Value }}"
{{- end }}
)

95
cmd/gen_exception/main.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gopkg.in/yaml.v3"
)
type ErrorItem struct {
Name string
Value string
}
type TplData struct {
Items []ErrorItem
}
func toCamel(s string) string {
caser := cases.Title(language.English)
s = strings.ReplaceAll(s, "-", "_")
parts := strings.Split(s, "_")
for i := range parts {
parts[i] = caser.String(parts[i])
}
return strings.Join(parts, "")
}
func recursiveParse(prefix string, raw any, items *[]ErrorItem) {
switch v := raw.(type) {
case map[string]any:
for key, val := range v {
recursiveParse(prefix+toCamel(key), val, items)
}
case string:
*items = append(*items, ErrorItem{
Name: prefix,
Value: v,
})
case int, int64:
*items = append(*items, ErrorItem{
Name: prefix,
Value: fmt.Sprintf("%v", v),
})
}
}
func main() {
yamlDir := "cmd/gen_exception/definitions"
outputDir := "internal/exception"
tplPath := "cmd/gen_exception/exception.tmpl"
if _, err := os.Stat(tplPath); os.IsNotExist(err) {
log.Fatalf("Cannot found tmpl %s", tplPath)
}
funcMap := template.FuncMap{"ToCamel": toCamel}
tmpl := template.Must(template.New("exception.tmpl").Funcs(funcMap).ParseFiles(tplPath))
os.MkdirAll(outputDir, 0755)
files, _ := filepath.Glob(filepath.Join(yamlDir, "*.yaml"))
for _, yamlFile := range files {
content, err := os.ReadFile(yamlFile)
if err != nil {
log.Printf("Read file error: %v", err)
continue
}
var rawData any
if err := yaml.Unmarshal(content, &rawData); err != nil {
log.Printf("Unmarshal error in %s: %v", yamlFile, err)
continue
}
var items []ErrorItem
recursiveParse("", rawData, &items)
baseName := strings.TrimSuffix(filepath.Base(yamlFile), filepath.Ext(yamlFile))
outputFileName := baseName + "_gen.go"
outputPath := filepath.Join(outputDir, outputFileName)
f, _ := os.Create(outputPath)
tmpl.Execute(f, TplData{Items: items})
f.Close()
fmt.Printf("Generated: %s (%d constants)\n", outputPath, len(items))
}
}

View File

@@ -3,7 +3,6 @@ server:
address: :8000 address: :8000
external_url: https://example.com external_url: https://example.com
debug_mode: false debug_mode: false
file_logger: false
database: database:
type: postgres type: postgres
host: 127.0.0.1 host: 127.0.0.1
@@ -26,13 +25,6 @@ email:
password: password:
security: security:
insecure_skip_verify: insecure_skip_verify:
from:
auth:
oauth2:
tenant_id:
client_id:
client_secret:
scope:
secrets: secrets:
turnstile_secret: example turnstile_secret: example
client_secret_key: aes_32_byte_string client_secret_key: aes_32_byte_string

View File

@@ -1,7 +1,7 @@
package config package config
import ( import (
"log" "log/slog"
"os" "os"
"strings" "strings"
@@ -29,9 +29,11 @@ func Init() {
conf := &config{} conf := &config{}
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
// Dont generate config when using dev mode // Dont generate config when using dev mode
log.Fatalln("Can't read config!") slog.Error("[Config] Can't read config!", "err", err)
os.Exit(1)
} }
if err := viper.Unmarshal(conf); err != nil { if err := viper.Unmarshal(conf); err != nil {
log.Fatal(err) slog.Error("[Condig] Can't unmarshal config!", "err", err)
os.Exit(1)
} }
} }

View File

@@ -16,7 +16,6 @@ type server struct {
Address string `yaml:"address"` Address string `yaml:"address"`
ExternalUrl string `yaml:"external_url"` ExternalUrl string `yaml:"external_url"`
DebugMode string `yaml:"debug_mode"` DebugMode string `yaml:"debug_mode"`
FileLogger string `yaml:"file_logger"`
} }
type database struct { type database struct {
@@ -40,23 +39,13 @@ type search struct {
ApiKey string `yaml:"api_key"` ApiKey string `yaml:"api_key"`
} }
type _email_oauth2 struct {
Tenantid string `yaml:"tenant_id"`
ClientId string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
Scope string `yaml:"scope"`
}
type email struct { type email struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port string `yaml:"port"` Port string `yaml:"port"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
Security string `yaml:"security"` Security string `yaml:"security"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"` InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
From string `yaml:"from"`
Auth string `yaml:"auth"`
Oauth2 _email_oauth2 `yaml:"oauth2"`
} }
type secrets struct { type secrets struct {

View File

@@ -228,13 +228,13 @@ func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result() val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
if err != nil { if err != nil {
return errors.New("invalid or expired checkin code") return errors.New("[Attendance Data] invalid or expired checkin code")
} }
// Expected format: user_id:<uuid>:event_id:<uuid> // Expected format: user_id:<uuid>:event_id:<uuid>
parts := strings.Split(val, ":") parts := strings.Split(val, ":")
if len(parts) != 4 { if len(parts) != 4 {
return errors.New("invalid checkin code format") return errors.New("[Attendance Data] invalid checkin code format")
} }
userIdStr := parts[1] userIdStr := parts[1]

View File

@@ -32,10 +32,10 @@ func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
return &client, nil return &client, nil
} }
func (self *Client) GetDecryptedSecret() (string, error) { func (self *Client) GetDecryptedSecret() ([]byte, error) {
secretKey := viper.GetString("secrets.client_secret_key") secretKey := viper.GetString("secrets.client_secret_key")
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey)) secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
return string(secret), err return secret, err
} }
type ClientParams struct { type ClientParams struct {
@@ -87,5 +87,5 @@ func (self *Client) ValidateRedirectURI(redirectURI string) error {
return nil return nil
} }
} }
return errors.New("redirect uri not match") return errors.New("[Client Data] redirect uri not match")
} }

View File

@@ -2,10 +2,12 @@ package data
import ( import (
"nixcn-cms/data/drivers" "nixcn-cms/data/drivers"
"os"
"log/slog"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper" "github.com/spf13/viper"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -25,19 +27,22 @@ func Init() {
} }
if dbType != "postgres" { if dbType != "postgres" {
log.Fatal("[Database] Only support postgras db!") slog.Error("[Database] Only support postgras db!")
os.Exit(1)
} }
// Conect to db // Conect to db
db, err := drivers.Postgres(exDSN) db, err := drivers.Postgres(exDSN)
if err != nil { if err != nil {
log.Fatal("[Database] Error connecting to db!") slog.Error("[Database] Error connecting to db!", "err", err)
os.Exit(1)
} }
// Auto migrate // Auto migrate
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{}) err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
if err != nil { if err != nil {
log.Error("[Database] Error migrating database: ", err) slog.Error("[Database] Error migrating database!", "err", err)
os.Exit(1)
} }
Database = db Database = db
@@ -52,7 +57,8 @@ func Init() {
} }
rdb, err := drivers.Redis(rDSN) rdb, err := drivers.Redis(rDSN)
if err != nil { if err != nil {
log.Fatal("[Redis] Error connecting to Redis: ", err) slog.Error("[Redis] Error connecting to Redis!", "err", err)
os.Exit(1)
} }
Redis = rdb Redis = rdb

3
generate.go Normal file
View File

@@ -0,0 +1,3 @@
package main
//go:generate go run ./cmd/gen_exception/main.go

7
go.mod
View File

@@ -10,15 +10,16 @@ require (
github.com/aliyun/credentials-go v1.4.5 github.com/aliyun/credentials-go v1.4.5
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-viper/mapstructure/v2 v2.4.0 github.com/go-viper/mapstructure/v2 v2.4.0
github.com/goccy/go-json v0.10.5
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/meilisearch/meilisearch-go v0.35.0 github.com/meilisearch/meilisearch-go v0.35.0
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0 golang.org/x/text v0.33.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@@ -44,7 +45,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.29.0 // indirect github.com/go-playground/validator/v10 v10.29.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect github.com/goccy/go-yaml v1.19.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -77,7 +77,6 @@ require (
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

9
go.sum
View File

@@ -193,8 +193,6 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -292,8 +290,6 @@ golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -315,7 +311,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -351,8 +346,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -21,7 +21,7 @@ func normalizeKey(key []byte) ([]byte, error) {
case 16, 24, 32: case 16, 24, 32:
return key, nil return key, nil
default: default:
return nil, errors.New("AES key length must be 16, 24, or 32 bytes") return nil, errors.New("[Cryptography AES] AES key length must be 16, 24, or 32 bytes")
} }
} }
@@ -74,7 +74,7 @@ func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
} }
if len(data) < gcm.NonceSize() { if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short") return nil, errors.New("[Cryptography AES] ciphertext too short")
} }
nonce := data[:gcm.NonceSize()] nonce := data[:gcm.NonceSize()]
@@ -92,11 +92,11 @@ func pkcs7Pad(data []byte, blockSize int) []byte {
func pkcs7Unpad(data []byte) ([]byte, error) { func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data) length := len(data)
if length == 0 { if length == 0 {
return nil, errors.New("invalid padding") return nil, errors.New("[Cryptography AES] invalid padding")
} }
padding := int(data[length-1]) padding := int(data[length-1])
if padding == 0 || padding > length { if padding == 0 || padding > length {
return nil, errors.New("invalid padding") return nil, errors.New("[Cryptography AES] invalid padding")
} }
return data[:length-padding], nil return data[:length-padding], nil
} }
@@ -143,7 +143,7 @@ func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
} }
if len(data) < block.BlockSize() { if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short") return nil, errors.New("[Cryptography AES] ciphertext too short")
} }
iv := data[:block.BlockSize()] iv := data[:block.BlockSize()]
@@ -195,7 +195,7 @@ func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
} }
if len(data) < block.BlockSize() { if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short") return nil, errors.New("[Cryptography AES] ciphertext too short")
} }
iv := data[:block.BlockSize()] iv := data[:block.BlockSize()]

View File

@@ -0,0 +1,66 @@
package exception
import (
"fmt"
"log/slog"
)
// 12 chars len
// :1=status
// :3=service
// :2=endpoint
// :1=common/specific
// :5=original
type Builder struct {
Status string
Service string
Endpoint string
Type string
Original string
Error error
}
func (self *Builder) SetStatus(s string) *Builder {
self.Status = s
return self
}
func (self *Builder) SetService(s string) *Builder {
self.Service = s
return self
}
func (self *Builder) SetEndpoint(s string) *Builder {
self.Endpoint = s
return self
}
func (self *Builder) SetType(s string) *Builder {
self.Type = s
return self
}
func (self *Builder) SetOriginal(s string) *Builder {
self.Original = s
return self
}
func (self *Builder) SetError(e error) *Builder {
self.Error = e
return self
}
func (self *Builder) Build() string {
errorCode := fmt.Sprintf("%s%s%s%s%s",
self.Status,
self.Service,
self.Endpoint,
self.Type,
self.Original,
)
if self.Error != nil {
slog.Warn("Service exception", "id", errorCode, "err", self.Error)
}
return errorCode
}

View File

@@ -9,7 +9,9 @@ client_cms_dir := join(client_dir, "cms")
server_exec_path := join(output_dir, project_name) server_exec_path := join(output_dir, project_name)
server_entry := "main.go" server_entry := "main.go"
install: install-cms install: install-cms install-back
generate: gen-back
install-cms: install-cms:
cd {{ client_cms_dir }} && {{ pnpm_cmd }} install cd {{ client_cms_dir }} && {{ pnpm_cmd }} install
@@ -24,20 +26,26 @@ build-client-cms:
build-back: build-back:
{{ go_cmd }} build -o {{ server_exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} {{ server_entry }} {{ go_cmd }} build -o {{ server_exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} {{ server_entry }}
install-back:
cd {{ project_dir }} && go mod tidy
run-back: run-back:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }}
test-back: test-back:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./... cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./...
gen-back:
cd {{ project_dir }} && go generate .
watch-back: watch-back:
watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ server_exec_path }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}' watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ server_exec_path }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ server_exec_path }}'
dev: clean install dev: clean install generate
devenv up --verbose devenv up --verbose
dev-client-cms: install-cms dev-client-cms: install-cms
devenv up client-cms --verbose devenv up client-cms --verbose
dev-back: clean dev-back: clean install-back gen-back
devenv up backend postgres redis meilisearch --verbose devenv up backend postgres redis meilisearch --verbose

View File

@@ -1,60 +0,0 @@
package logger
import (
"os"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
func Init() {
FileLogger := viper.GetBool("server.file_logger")
DebugMode := viper.GetBool("server.debug_mode")
if FileLogger == true {
gin.DisableConsoleColor()
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
log.Panicln("Error opening log file: ", err)
}
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(file)
log.SetLevel(log.DebugLevel)
} else {
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(os.Stdout)
log.SetLevel(log.WarnLevel)
}
if DebugMode == true {
gin.SetMode(gin.DebugMode)
log.SetLevel(log.DebugLevel)
} else {
gin.SetMode(gin.ReleaseMode)
}
}
func Gin() gin.HandlerFunc {
return func(ctx *gin.Context) {
startTime := time.Now()
ctx.Next()
endTime := time.Now()
latencyTime := endTime.Sub(startTime)
reqMethod := ctx.Request.Method
reqUri := ctx.Request.RequestURI
statusCode := ctx.Writer.Status()
clientIP := ctx.ClientIP()
log.WithFields(log.Fields{
"METHOD": reqMethod,
"URI": reqUri,
"STATUS": statusCode,
"LATENCY": latencyTime,
"CLIENT_IP": clientIP,
}).Info("HTTP REQUEST")
ctx.Next()
}
}

47
logger/slog.go Normal file
View File

@@ -0,0 +1,47 @@
package logger
import (
"io"
"log/slog"
"os"
"strings"
)
func Init() {
levelStr := strings.ToLower(os.Getenv("LOG_LEVEL"))
var level slog.Level
switch levelStr {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_RDWR, 0666)
var writer io.Writer = os.Stdout
if err != nil {
slog.Error("Error to create log file", "err", err)
} else {
writer = io.MultiWriter(os.Stdout, file)
}
opts := &slog.HandlerOptions{
Level: level,
AddSource: true,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.String(a.Key, a.Value.Time().Format("2006-01-02 15:04:05"))
}
return a
},
}
handler := slog.NewJSONHandler(writer, opts)
logger := slog.New(handler)
slog.SetDefault(logger)
}

View File

@@ -8,8 +8,8 @@ import (
) )
func main() { func main() {
config.Init()
logger.Init() logger.Init()
config.Init()
data.Init() data.Init()
server.Start() server.Start()
} }

View File

@@ -1,12 +1,24 @@
package middleware package middleware
import "github.com/gin-gonic/gin" import (
"nixcn-cms/internal/exception"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
func ApiVersionCheck() gin.HandlerFunc { func ApiVersionCheck() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
apiVersion := c.GetHeader("X-Api-Version") apiVersion := c.GetHeader("X-Api-Version")
if apiVersion == "" { if apiVersion == "" {
c.Abort() errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.MiddlewareServiceApiVersion).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeSpecific).
SetOriginal(exception.ApiVersionNotFound).
Build()
utils.HttpAbort(c, 400, errorCode)
return return
} }
c.Next() c.Next()

53
middleware/gin_logger.go Normal file
View File

@@ -0,0 +1,53 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
var body []byte
if c.Request.Body != nil {
body, _ = io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
}
headerJSON, _ := json.Marshal(c.Request.Header)
startTime := time.Now()
c.Next()
var errorMessage string
if len(c.Errors) > 0 {
errorMessage = c.Errors.String()
}
fields := []any{
"status", c.Writer.Status(),
"method", c.Request.Method,
"uri", c.Request.RequestURI,
"ip", c.ClientIP(),
"latency", time.Since(startTime).String(),
"user_agent", c.Request.UserAgent(),
"headers", string(headerJSON),
"request_body", string(body),
"errors", errorMessage,
}
status := c.Writer.Status()
if len(c.Errors) > 0 || status >= 500 {
slog.Error("HTTP_ERROR", fields...)
} else if status >= 400 {
slog.Warn("HTTP_CLIENT_ERROR", fields...)
} else {
slog.Info("HTTP_SUCCESS", fields...)
}
}
}

View File

@@ -1,13 +1,14 @@
package middleware package middleware
import ( import (
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authtoken" "nixcn-cms/pkgs/authtoken"
"nixcn-cms/utils" "nixcn-cms/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func JWTAuth(required bool) gin.HandlerFunc { func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
@@ -15,12 +16,16 @@ func JWTAuth(required bool) gin.HandlerFunc {
authtoken := new(authtoken.Token) authtoken := new(authtoken.Token)
uid, err := authtoken.HeaderVerify(auth) uid, err := authtoken.HeaderVerify(auth)
if err != nil { if err != nil {
utils.HttpAbort(c, 401, "", "unauthorized") errorCode := new(exception.Builder).
return SetStatus(exception.StatusServer).
} SetService(exception.MiddlewareServiceJwt).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUnauthorized).
SetError(err).
Build()
if required == true && uid == "" { utils.HttpAbort(c, 401, errorCode)
utils.HttpAbort(c, 401, "", "unauthorized")
return return
} }

View File

@@ -2,6 +2,7 @@ package middleware
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,19 +16,42 @@ func Permission(requiredLevel uint) gin.HandlerFunc {
if !ok { if !ok {
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok || userIdOrig.(string) == "" { if !ok || userIdOrig.(string) == "" {
utils.HttpAbort(c, 401, "", "missing user id") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.MiddlewareServicePermission).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpAbort(c, 401, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpAbort(c, 500, "", "error parsing user id") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.MiddlewareServicePermission).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpAbort(c, 500, errorCode)
return return
} }
userData, err := new(data.User).GetByUserId(userId) userData, err := new(data.User).GetByUserId(userId)
if err != nil { if err != nil {
utils.HttpAbort(c, 404, "", "user not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.MiddlewareServicePermission).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUserNotFound).
SetError(err).
Build()
utils.HttpAbort(c, 404, errorCode)
return return
} }
@@ -38,7 +62,14 @@ func Permission(requiredLevel uint) gin.HandlerFunc {
} }
if permissionLevel < requiredLevel { if permissionLevel < requiredLevel {
utils.HttpAbort(c, 403, "", "permission denied") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.MiddlewareServicePermission).
SetEndpoint(exception.EndpointMiddlewareService).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorPermissionDenied).
Build()
utils.HttpAbort(c, 403, errorCode)
return return
} }

View File

@@ -129,14 +129,14 @@ func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
// read refresh token bind data // read refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, key).Result() dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 { if err != nil || len(dataMap) == 0 {
return "", errors.New("invalid refresh token") return "", errors.New("[Auth Token] invalid refresh token")
} }
userIdStr := dataMap["user_id"] userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"] clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" { if userIdStr == "" || clientId == "" {
return "", errors.New("refresh token corrupted") return "", errors.New("[Auth Token] refresh token corrupted")
} }
userId, err := uuid.Parse(userIdStr) userId, err := uuid.Parse(userIdStr)
@@ -157,14 +157,14 @@ func (self *Token) RenewRefreshToken(refreshToken string) (string, error) {
// read old refresh token bind data // read old refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result() dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result()
if err != nil || len(dataMap) == 0 { if err != nil || len(dataMap) == 0 {
return "", errors.New("invalid refresh token") return "", errors.New("[Auth Token] invalid refresh token")
} }
userIdStr := dataMap["user_id"] userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"] clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" { if userIdStr == "" || clientId == "" {
return "", errors.New("refresh token corrupted") return "", errors.New("[Auth Token] refresh token corrupted")
} }
// generate new refresh token // generate new refresh token
@@ -254,7 +254,7 @@ func (self *Token) HeaderVerify(header string) (string, error) {
// Split header to 2 // Split header to 2
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" { if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("invalid Authorization header format") return "", errors.New("[Auth Token] invalid Authorization header format")
} }
tokenStr := parts[1] tokenStr := parts[1]
@@ -266,11 +266,11 @@ func (self *Token) HeaderVerify(header string) (string, error) {
claims, claims,
func(token *jwt.Token) (any, error) { func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method") return nil, errors.New("[Auth Token] unexpected signing method")
} }
if claims.ClientId == "" { if claims.ClientId == "" {
return nil, errors.New("client_id missing in token") return nil, errors.New("[Auth Token] client_id missing in token")
} }
clientData, err := new(data.Client).GetClientByClientId(claims.ClientId) clientData, err := new(data.Client).GetClientByClientId(claims.ClientId)
@@ -288,7 +288,8 @@ func (self *Token) HeaderVerify(header string) (string, error) {
) )
if err != nil || !token.Valid { if err != nil || !token.Valid {
return "", errors.New("invalid or expired token") fmt.Println(err)
return "", errors.New("[Auth Token] invalid or expired token")
} }
return claims.UserID.String(), nil return claims.UserID.String(), nil

View File

@@ -1,313 +1,84 @@
package email package email
import ( import (
"bytes"
"context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt"
"net"
"net/smtp"
"strings" "strings"
"sync"
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
gomail "gopkg.in/gomail.v2" gomail "gopkg.in/gomail.v2"
) )
type Client struct { type Client struct {
// basic smtp
dialer *gomail.Dialer dialer *gomail.Dialer
// shared
from string
host string host string
port int port int
username string username string
security string security string
insecure bool insecure bool
// auth mode
authMode string
// oauth2
oauth *oauthTokenProvider
} }
type oauthTokenProvider struct { func (self *Client) NewSMTPClient() (*Client, error) {
cfg clientcredentials.Config
mu sync.Mutex
token *oauth2.Token
fetchErr error
}
func (p *oauthTokenProvider) getToken(ctx context.Context) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.token != nil && p.token.Valid() && time.Until(p.token.Expiry) > 60*time.Second {
return p.token.AccessToken, nil
}
tok, err := p.cfg.Token(ctx)
if err != nil {
p.fetchErr = err
return "", err
}
p.token = tok
p.fetchErr = nil
return tok.AccessToken, nil
}
func NewSMTPClient() (*Client, error) {
host := viper.GetString("email.host") host := viper.GetString("email.host")
port := viper.GetInt("email.port") port := viper.GetInt("email.port")
user := viper.GetString("email.username") user := viper.GetString("email.username")
pass := viper.GetString("email.password") pass := viper.GetString("email.password")
from := viper.GetString("email.from")
security := strings.ToLower(viper.GetString("email.security")) security := strings.ToLower(viper.GetString("email.security"))
insecure := viper.GetBool("email.insecure_skip_verify") insecure := viper.GetBool("email.insecure_skip_verify")
authMode := strings.ToLower(viper.GetString("email.auth"))
if authMode == "" {
authMode = "basic"
}
if host == "" || port == 0 || user == "" { if host == "" || port == 0 || user == "" {
return nil, errors.New("SMTP config not set") return nil, errors.New("[Email] SMTP config not set")
} }
c := &Client{ if pass == "" {
from: from, return nil, errors.New("[Email] SMTP basic auth requires email.password")
}
dialer := gomail.NewDialer(host, port, user, pass)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}
switch security {
case "ssl":
dialer.SSL = true
case "starttls":
dialer.SSL = false
case "plain", "":
dialer.SSL = false
dialer.TLSConfig = nil
default:
return nil, errors.New("[Email] unknown smtp security mode: " + security)
}
return &Client{
dialer: dialer,
host: host, host: host,
port: port, port: port,
username: user, username: user,
security: security, security: security,
insecure: insecure, insecure: insecure,
authMode: authMode, }, nil
}
switch authMode {
case "basic":
if pass == "" {
return nil, errors.New("SMTP basic auth requires email.password")
}
dialer := gomail.NewDialer(host, port, user, pass)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}
switch security {
case "ssl":
dialer.SSL = true
case "starttls":
dialer.SSL = false
case "plain", "":
dialer.SSL = false
dialer.TLSConfig = nil
default:
return nil, errors.New("unknown smtp security mode: " + security)
}
c.dialer = dialer
return c, nil
case "oauth2":
if security == "" {
security = "starttls"
c.security = "starttls"
}
if security == "plain" {
return nil, errors.New("oauth2 requires TLS (starttls or ssl); plain is not allowed")
}
tenantID := viper.GetString("email.oauth2.tenant_id")
clientID := viper.GetString("email.oauth2.client_id")
clientSecret := viper.GetString("email.oauth2.client_secret")
scope := viper.GetString("email.oauth2.scope")
if scope == "" {
// Microsoft Learn: client credentials for SMTP uses https://outlook.office365.com/.default :contentReference[oaicite:3]{index=3}
scope = "https://outlook.office365.com/.default"
}
if tenantID == "" || clientID == "" || clientSecret == "" {
return nil, errors.New("oauth2 requires email.oauth2.tenant_id/client_id/client_secret")
}
c.oauth = &oauthTokenProvider{
cfg: clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
Scopes: []string{scope},
},
}
return c, nil
default:
return nil, errors.New("unknown email.auth: " + authMode)
}
} }
func (c *Client) Send(to, subject, html string) (string, error) { func (c *Client) Send(from, to, subject, html string) (string, error) {
if c.dialer == nil {
return "", errors.New("[Email] SMTP dialer not initialized")
}
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", c.from) m.SetHeader("From", from)
m.SetHeader("To", to) m.SetHeader("To", to)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/html", html) m.SetBody("text/html", html)
switch c.authMode { if err := c.dialer.DialAndSend(m); err != nil {
case "basic": return "", err
if c.dialer == nil {
return "", errors.New("basic dialer not initialized")
}
if err := c.dialer.DialAndSend(m); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
case "oauth2":
if err := c.sendWithXOAUTH2(m, to); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
default:
return "", errors.New("unsupported auth mode: " + c.authMode)
} }
}
return time.Now().Format(time.RFC3339Nano), nil
// XOAUTH2 auth for net/smtp
type xoauth2Auth struct {
username string
token string
}
func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
return "", nil, errors.New("refusing to authenticate over insecure connection")
}
// Microsoft Learn XOAUTH2 Format: user=<user>\x01auth=Bearer <token>\x01\x01 :contentReference[oaicite:4]{index=4}
resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
return "XOAUTH2", []byte(resp), nil
}
func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, errors.New("unexpected server challenge during XOAUTH2 auth")
}
return nil, nil
}
func (c *Client) sendWithXOAUTH2(m *gomail.Message, rcpt string) error {
if c.oauth == nil {
return errors.New("oauth2 provider not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
token, err := c.oauth.getToken(ctx)
if err != nil {
return fmt.Errorf("oauth2 token error: %w", err)
}
// write gomail.Message to RFC822
var buf bytes.Buffer
if _, err := m.WriteTo(&buf); err != nil {
return err
}
msg := buf.Bytes()
addr := fmt.Sprintf("%s:%d", c.host, c.port)
tlsCfg := &tls.Config{
ServerName: c.host,
InsecureSkipVerify: c.insecure,
}
var (
conn net.Conn
cl *smtp.Client
)
switch c.security {
case "ssl":
conn, err = tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return err
}
cl, err = smtp.NewClient(conn, c.host)
if err != nil {
_ = conn.Close()
return err
}
case "starttls", "":
conn, err = net.Dial("tcp", addr)
if err != nil {
return err
}
cl, err = smtp.NewClient(conn, c.host)
if err != nil {
_ = conn.Close()
return err
}
// Upgrade with STARTTLS
if ok, _ := cl.Extension("STARTTLS"); ok {
if err := cl.StartTLS(tlsCfg); err != nil {
_ = cl.Close()
return err
}
} else {
_ = cl.Close()
return errors.New("server does not support STARTTLS")
}
default:
return errors.New("unknown smtp security mode: " + c.security)
}
defer func() { _ = cl.Quit() }()
// AUTH XOAUTH2
if err := cl.Auth(&xoauth2Auth{username: c.username, token: token}); err != nil {
return err
}
// MAIL FROM / RCPT TO / DATA
if err := cl.Mail(extractAddress(c.from)); err != nil {
return err
}
if err := cl.Rcpt(rcpt); err != nil {
return err
}
w, err := cl.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
_ = w.Close()
return err
}
return w.Close()
}
func extractAddress(from string) string {
if i := strings.LastIndex(from, "<"); i >= 0 {
if j := strings.LastIndex(from, ">"); j > i {
return strings.TrimSpace(from[i+1 : j])
}
}
return strings.TrimSpace(from)
} }

View File

@@ -21,12 +21,12 @@ import (
func DecodeB64Json(b64Json string) (*KycInfo, error) { func DecodeB64Json(b64Json string) (*KycInfo, error) {
rawJson, err := base64.StdEncoding.DecodeString(b64Json) rawJson, err := base64.StdEncoding.DecodeString(b64Json)
if err != nil { if err != nil {
return nil, errors.New("invalid base64 json") return nil, errors.New("[KYC] invalid base64 json")
} }
var kyc KycInfo var kyc KycInfo
if err := json.Unmarshal(rawJson, &kyc); err != nil { if err := json.Unmarshal(rawJson, &kyc); err != nil {
return nil, errors.New("invalid json structure") return nil, errors.New("[KYC] invalid json structure")
} }
return &kyc, nil return &kyc, nil
@@ -56,7 +56,7 @@ func DecodeAES(cipherStr string) (*KycInfo, error) {
var kyc KycInfo var kyc KycInfo
if err := json.Unmarshal(plainBytes, &kyc); err != nil { if err := json.Unmarshal(plainBytes, &kyc); err != nil {
return nil, errors.New("invalid decrypted json") return nil, errors.New("[KYC] invalid decrypted json")
} }
return &kyc, nil return &kyc, nil

View File

@@ -11,8 +11,8 @@ import (
func Router(e *gin.Engine) { func Router(e *gin.Engine) {
// API Services // API Services
api := e.Group("/api/v1", middleware.ApiVersionCheck()) api := e.Group("/api/v1")
auth.Handler(api.Group("/auth")) auth.Handler(api.Group("/auth"))
user.Handler(api.Group("/user")) user.Handler(api.Group("/user", middleware.ApiVersionCheck()))
event.Handler(api.Group("/event")) event.Handler(api.Group("/event", middleware.ApiVersionCheck()))
} }

View File

@@ -1,18 +1,23 @@
package server package server
import ( import (
"log/slog"
"net/http" "net/http"
"nixcn-cms/logger" "nixcn-cms/middleware"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func Start() { func Start() {
r := gin.Default() if !viper.GetBool("server.debug_mode") {
r.Use(logger.Gin(), gin.Recovery()) gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor()
}
r := gin.New()
r.Use(gin.Recovery(), middleware.GinLogger())
Router(r) Router(r)
@@ -25,8 +30,8 @@ func Start() {
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
} }
log.Info("Starting server on " + viper.GetString("server.address")) slog.Info("[Server] Starting server on " + viper.GetString("server.address"))
if err := server.ListenAndServe(); err != nil { if err := server.ListenAndServe(); err != nil {
log.Errorf("Error starting server: %v\n", err) slog.Error("[Server] Error starting server!", "err", err)
} }
} }

117
service/auth/exchange.go Normal file
View File

@@ -0,0 +1,117 @@
package auth
import (
"fmt"
"net/url"
"nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const ()
func Exchange(c *gin.Context) {
var exchangeReq struct {
ClientId string `json:"client_id"`
RedirectUri string `json:"redirect_uri"`
State string `json:"state"`
}
err := c.ShouldBindJSON(&exchangeReq)
if err != nil {
fmt.Println(err)
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUnauthorized).
Build()
utils.HttpResponse(c, 401, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return
}
userData := new(data.User)
user, err := userData.GetByUserId(userId)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthExchangeGetUserIdFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return
}
code, err := authcode.NewAuthCode(exchangeReq.ClientId, user.Email)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthExchangeCodeGenFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return
}
url, err := url.Parse(exchangeReq.RedirectUri)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthExchangeInvalidRedirectUri).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
exchangeResp := struct {
RedirectUri string `json:"redirect_uri"`
}{url.String()}
utils.HttpResponse(c, 200, "", "success", exchangeResp)
}

View File

@@ -7,8 +7,9 @@ import (
) )
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.GET("/redirect", Redirect, middleware.JWTAuth(false)) r.GET("/redirect", Redirect)
r.POST("/magic", Magic) r.POST("/magic", middleware.ApiVersionCheck(), Magic)
r.POST("/token", Token) r.POST("/token", middleware.ApiVersionCheck(), Token)
r.POST("/refresh", Refresh) r.POST("/refresh", middleware.ApiVersionCheck(), Refresh)
r.POST("/exchange", middleware.ApiVersionCheck(), middleware.JWTAuth(), Exchange)
} }

View File

@@ -2,6 +2,7 @@ package auth
import ( import (
"net/url" "net/url"
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authcode" "nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email" "nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/turnstile" "nixcn-cms/pkgs/turnstile"
@@ -23,27 +24,59 @@ func Magic(c *gin.Context) {
// Parse request // Parse request
var req MagicRequest var req MagicRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
// Cloudflare turnstile // Cloudflare turnstile
ok, err := turnstile.VerifyTurnstile(req.TurnstileToken, c.ClientIP()) ok, err := turnstile.VerifyTurnstile(req.TurnstileToken, c.ClientIP())
if err != nil || !ok { if err != nil || !ok {
utils.HttpResponse(c, 403, "", "turnstile failed") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthMagicTurnstileFailed).
SetError(err).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
code, err := authcode.NewAuthCode(req.ClientId, req.Email) code, err := authcode.NewAuthCode(req.ClientId, req.Email)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "code gen failed") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthMagicCodeGenFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
externalUrl := viper.GetString("server.external_url") externalUrl := viper.GetString("server.external_url")
url, err := url.Parse(externalUrl) url, err := url.Parse(externalUrl)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "invalid external url") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthMagicInvalidExternalUrl).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
@@ -60,17 +93,25 @@ func Magic(c *gin.Context) {
uriData := struct { uriData := struct {
Uri string `json:"uri"` Uri string `json:"uri"`
}{url.String()} }{url.String()}
utils.HttpResponse(c, 200, "", "magiclink sent", uriData) utils.HttpResponse(c, 200, "", "magiclink sent", uriData)
return return
} else { } else {
// Send email using resend // Send email using resend
emailClient, err := email.NewSMTPClient() emailClient, err := new(email.Client).NewSMTPClient()
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "invalid email config") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthMagicInvalidEmailConfig).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
emailClient.Send( emailClient.Send(
"NixCN CMS <cms@yuri.nix.org.cn>",
req.Email, req.Email,
"NixCN CMS Email Verify", "NixCN CMS Email Verify",
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>", "<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",

View File

@@ -3,6 +3,7 @@ package auth
import ( import (
"net/url" "net/url"
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authcode" "nixcn-cms/pkgs/authcode"
"nixcn-cms/utils" "nixcn-cms/utils"
@@ -14,65 +15,56 @@ import (
func Redirect(c *gin.Context) { func Redirect(c *gin.Context) {
clientId := c.Query("client_id") clientId := c.Query("client_id")
if clientId == "" { if clientId == "" {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
redirectUri := c.Query("redirect_uri") redirectUri := c.Query("redirect_uri")
if redirectUri == "" { if redirectUri == "" {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
state := c.Query("state") state := c.Query("state")
if state == "" { if state == "" {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
code := c.Query("code") code := c.Query("code")
if code == "" {
userIdOrig, ok := c.Get("user_id")
if !ok || userIdOrig == "" {
utils.HttpResponse(c, 401, "", "unauthorized")
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid")
return
}
userData := new(data.User)
user, err := userData.GetByUserId(userId)
if err != nil {
utils.HttpResponse(c, 500, "", "failed to get user id")
return
}
code, err := authcode.NewAuthCode(clientId, user.Email)
if err != nil {
utils.HttpResponse(c, 500, "", "code gen failed")
return
}
url, err := url.Parse(redirectUri)
if err != nil {
utils.HttpResponse(c, 400, "", "invalid redirect uri")
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}
// Verify email token // Verify email token
authCode, ok := authcode.VerifyAuthCode(code) authCode, ok := authcode.VerifyAuthCode(code)
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "invalid or expired token") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRedirectTokenInvalid).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
@@ -89,11 +81,27 @@ func Redirect(c *gin.Context) {
user.Username = user.UserId.String() user.Username = user.UserId.String()
user.PermissionLevel = 10 user.PermissionLevel = 10
if err := user.Create(); err != nil { if err := user.Create(); err != nil {
utils.HttpResponse(c, 500, "", "internal server error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInternal).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
} else { } else {
utils.HttpResponse(c, 500, "", "internal server error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInternal).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
} }
@@ -101,25 +109,57 @@ func Redirect(c *gin.Context) {
clientData := new(data.Client) clientData := new(data.Client)
client, err := clientData.GetClientByClientId(clientId) client, err := clientData.GetClientByClientId(clientId)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "client not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRedirectClientNotFound).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
err = client.ValidateRedirectURI(redirectUri) err = client.ValidateRedirectURI(redirectUri)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "redirect uri not match") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRedirectUriMismatch).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
newCode, err := authcode.NewAuthCode(clientId, authCode.Email) newCode, err := authcode.NewAuthCode(clientId, authCode.Email)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "internal server error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInternal).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
url, err := url.Parse(redirectUri) url, err := url.Parse(redirectUri)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "invalid redirect uri") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRedirectInvalidUri).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
query := url.Query() query := url.Query()

View File

@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authtoken" "nixcn-cms/pkgs/authtoken"
"nixcn-cms/utils" "nixcn-cms/utils"
@@ -14,7 +15,15 @@ func Refresh(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
@@ -24,13 +33,29 @@ func Refresh(c *gin.Context) {
accessToken, err := JwtTool.RefreshAccessToken(req.RefreshToken) accessToken, err := JwtTool.RefreshAccessToken(req.RefreshToken)
if err != nil { if err != nil {
utils.HttpResponse(c, 401, "", "invalid refresh token") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRefreshInvalidToken).
SetError(err).
Build()
utils.HttpResponse(c, 401, errorCode)
return return
} }
refreshToken, err := JwtTool.RenewRefreshToken(req.RefreshToken) refreshToken, err := JwtTool.RenewRefreshToken(req.RefreshToken)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "error renew refresh token") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthRefreshRenewFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }

View File

@@ -2,6 +2,7 @@ package auth
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/pkgs/authcode" "nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/authtoken" "nixcn-cms/pkgs/authtoken"
"nixcn-cms/utils" "nixcn-cms/utils"
@@ -19,20 +20,43 @@ func Token(c *gin.Context) {
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "invalid request") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
authCode, ok := authcode.VerifyAuthCode(req.Code) authCode, ok := authcode.VerifyAuthCode(req.Code)
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "invalid or expired token") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthTokenInvalidToken).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
userData := new(data.User) userData := new(data.User)
user, err := userData.GetByEmail(authCode.Email) user, err := userData.GetByEmail(authCode.Email)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "internal server error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInternal).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
@@ -42,7 +66,15 @@ func Token(c *gin.Context) {
} }
accessToken, refreshToken, err := JwtTool.IssueTokens(authCode.ClientId, user.UserId) accessToken, refreshToken, err := JwtTool.IssueTokens(authCode.ClientId, user.UserId)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "error generating tokens") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeSpecific).
SetOriginal(exception.AuthTokenGenFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }

View File

@@ -2,6 +2,7 @@ package event
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"time" "time"
@@ -13,31 +14,69 @@ func Checkin(c *gin.Context) {
data := new(data.Attendance) data := new(data.Attendance)
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "userid error") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
} }
// Get event id from query // Get event id from query
eventIdOrig, ok := c.GetQuery("event_id") eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
utils.HttpResponse(c, 400, "", "undefinded event id") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
// Parse event id to uuid // Parse event id to uuid
eventId, err := uuid.Parse(eventIdOrig) eventId, err := uuid.Parse(eventIdOrig)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "error parsing string to uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
data.UserId = userId data.UserId = userId
code, err := data.GenCheckinCode(eventId) code, err := data.GenCheckinCode(eventId)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "error generating code") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeSpecific).
SetOriginal(exception.EventCheckinGenCodeFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
@@ -56,7 +95,15 @@ func CheckinSubmit(c *gin.Context) {
attendanceData := new(data.Attendance) attendanceData := new(data.Attendance)
err := attendanceData.VerifyCheckinCode(req.ChekinCode) err := attendanceData.VerifyCheckinCode(req.ChekinCode)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "error verify checkin code") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
@@ -66,23 +113,53 @@ func CheckinSubmit(c *gin.Context) {
func CheckinQuery(c *gin.Context) { func CheckinQuery(c *gin.Context) {
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
utils.HttpResponse(c, 400, "", "userid error") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
eventIdOrig, ok := c.GetQuery("event_id") eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
utils.HttpResponse(c, 400, "", "could not found event_id") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
eventId, err := uuid.Parse(eventIdOrig) eventId, err := uuid.Parse(eventIdOrig)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "event_id is not valid") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
@@ -90,10 +167,25 @@ func CheckinQuery(c *gin.Context) {
attendance, err := attendanceData.GetAttendance(userId, eventId) attendance, err := attendanceData.GetAttendance(userId, eventId)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "database error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorDatabase).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} else if attendance == nil { } else if attendance == nil {
utils.HttpResponse(c, 404, "", "event checkin record not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeSpecific).
SetOriginal(exception.EventCheckinQueryRecordNotFound).
Build()
utils.HttpResponse(c, 404, errorCode)
return return
} else if attendance.CheckinAt.IsZero() { } else if attendance.CheckinAt.IsZero() {
utils.HttpResponse(c, 200, "", "success", gin.H{"checkin_at": nil}) utils.HttpResponse(c, 200, "", "success", gin.H{"checkin_at": nil})

View File

@@ -7,9 +7,9 @@ import (
) )
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth(true), middleware.Permission(10)) r.Use(middleware.JWTAuth(), middleware.Permission(10))
r.GET("/info", Info) r.GET("/info", Info)
r.GET("/checkin", Checkin) r.GET("/checkin", Checkin)
r.GET("/checkin/query", CheckinQuery) r.GET("/checkin/query", CheckinQuery)
r.POST("/checkin/submit", CheckinSubmit, middleware.Permission(20)) r.POST("/checkin/submit", middleware.Permission(20), CheckinSubmit)
} }

View File

@@ -2,6 +2,7 @@ package event
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"time" "time"
@@ -13,20 +14,43 @@ func Info(c *gin.Context) {
eventData := new(data.Event) eventData := new(data.Event)
eventIdOrig, ok := c.GetQuery("event_id") eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
utils.HttpResponse(c, 400, "", "undefinded event id") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
// Parse event id // Parse event id
eventId, err := uuid.Parse(eventIdOrig) eventId, err := uuid.Parse(eventIdOrig)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "error parsing string to uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
event, err := eventData.GetEventById(eventId) event, err := eventData.GetEventById(eventId)
if err != nil { if err != nil {
utils.HttpResponse(c, 404, "", "event id not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeSpecific).
SetOriginal(exception.EventInfoNotFound).
SetError(err).
Build()
utils.HttpResponse(c, 404, errorCode)
return return
} }

View File

@@ -2,6 +2,7 @@ package user
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -11,24 +12,55 @@ import (
func Full(c *gin.Context) { func Full(c *gin.Context) {
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "userid error") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceFull).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceFull).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
userData, err := new(data.User).GetByUserId(userId) userData, err := new(data.User).GetByUserId(userId)
if err != nil { if err != nil {
utils.HttpResponse(c, 404, "", "user not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceFull).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUserNotFound).
SetError(err).
Build()
utils.HttpResponse(c, 404, errorCode)
return return
} }
users, err := userData.GetFullTable() users, err := userData.GetFullTable()
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "database error") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceFull).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorDatabase).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }

View File

@@ -7,10 +7,10 @@ import (
) )
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth(true), middleware.Permission(5)) r.Use(middleware.JWTAuth(), middleware.Permission(5))
r.GET("/info", Info) r.GET("/info", Info)
r.PATCH("/update", Update) r.PATCH("/update", Update)
r.GET("/list", List, middleware.Permission(20)) r.GET("/list", middleware.Permission(20), List)
r.POST("/full", Full, middleware.Permission(40)) r.POST("/full", middleware.Permission(40), Full)
r.POST("/create", Create, middleware.Permission(50)) r.POST("/create", middleware.Permission(50), Create)
} }

View File

@@ -2,6 +2,7 @@ package user
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -12,19 +13,42 @@ func Info(c *gin.Context) {
userData := new(data.User) userData := new(data.User)
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "userid error") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
// Get user from database // Get user from database
user, err := userData.GetByUserId(userId) user, err := userData.GetByUserId(userId)
if err != nil { if err != nil {
utils.HttpResponse(c, 404, "", "user not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUserNotFound).
SetError(err).
Build()
utils.HttpResponse(c, 404, errorCode)
return return
} }

View File

@@ -2,6 +2,7 @@ package user
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"strconv" "strconv"
@@ -16,26 +17,57 @@ func List(c *gin.Context) {
} }
offset, ok := c.GetQuery("offset") offset, ok := c.GetQuery("offset")
if !ok { if !ok {
utils.HttpResponse(c, 400, "", "offset not found") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
// Parse string to int64 // Parse string to int64
limitNum, err := strconv.ParseInt(limit, 10, 64) limitNum, err := strconv.ParseInt(limit, 10, 64)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "parse string to int error") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
offsetNum, err := strconv.ParseInt(offset, 10, 64) offsetNum, err := strconv.ParseInt(offset, 10, 64)
if err != nil { if err != nil {
utils.HttpResponse(c, 400, "", "parse string to int error") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return return
} }
// Get user list from search engine // Get user list from search engine
list, err := new(data.User).FastListUsers(limitNum, offsetNum) list, err := new(data.User).FastListUsers(limitNum, offsetNum)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed list users from meilisearch") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeSpecific).
SetOriginal(exception.UserListMeilisearchFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
} }
userListResp := struct { userListResp := struct {

View File

@@ -1,9 +1,12 @@
package user package user
import ( import (
"net/url"
"nixcn-cms/data" "nixcn-cms/data"
"nixcn-cms/internal/cryptography" "nixcn-cms/internal/cryptography"
"nixcn-cms/internal/exception"
"nixcn-cms/utils" "nixcn-cms/utils"
"unicode/utf8"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -13,47 +16,145 @@ func Update(c *gin.Context) {
// New user model // New user model
userIdOrig, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
utils.HttpResponse(c, 403, "", "userid error") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Build()
utils.HttpResponse(c, 403, errorCode)
return return
} }
userId, err := uuid.Parse(userIdOrig.(string)) userId, err := uuid.Parse(userIdOrig.(string))
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to parse uuid") errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
var ReqInfo data.User var ReqInfo data.User
c.BindJSON(&ReqInfo) err = c.ShouldBindJSON(&ReqInfo)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
// Get user info // Get user info
userData, err := new(data.User).GetByUserId(userId) userData, err := new(data.User).GetByUserId(userId)
if err != nil { if err != nil {
utils.HttpResponse(c, 500, "", "failed to find user") errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUserNotFound).
SetError(err).
Build()
utils.HttpResponse(c, 500, errorCode)
return return
} }
if len(ReqInfo.Email) < 5 || len(ReqInfo.Email) >= 255 { // if len(ReqInfo.Email) < 5 || len(ReqInfo.Email) >= 255 {
utils.HttpResponse(c, 400, "", "invilad email") // utils.HttpResponse(c, 400, "", "invilad email")
return // return
} // }
userData.Email = ReqInfo.Email // userData.Email = ReqInfo.Email
if len(ReqInfo.Username) < 5 || len(ReqInfo.Username) >= 255 { // utils.HttpResponse(c, 400, "", "invilad user name")
utils.HttpResponse(c, 400, "", "invilad user name") // return
return
}
userData.Username = ReqInfo.Username
userData.Nickname = ReqInfo.Nickname if ReqInfo.Username != "" {
userData.Subtitle = ReqInfo.Subtitle if len(ReqInfo.Username) < 5 || len(ReqInfo.Username) >= 255 {
userData.Avatar = ReqInfo.Avatar errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
userData.Username = ReqInfo.Username
}
if ReqInfo.Nickname != "" {
if utf8.RuneCountInString(ReqInfo.Nickname) > 24 {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
userData.Nickname = ReqInfo.Nickname
}
if ReqInfo.Subtitle != "" {
if utf8.RuneCountInString(ReqInfo.Subtitle) > 32 {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
userData.Subtitle = ReqInfo.Subtitle
}
if ReqInfo.Avatar != "" {
_, err := url.ParseRequestURI(ReqInfo.Avatar)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Build()
utils.HttpResponse(c, 400, errorCode)
return
}
userData.Avatar = ReqInfo.Avatar
}
if ReqInfo.Bio != "" { if ReqInfo.Bio != "" {
if !cryptography.IsBase64Std(ReqInfo.Bio) { if !cryptography.IsBase64Std(ReqInfo.Bio) {
utils.HttpResponse(c, 400, "", "invalid base64") errorCode := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Build()
utils.HttpResponse(c, 400, errorCode)
return
} }
userData.Bio = ReqInfo.Bio
} }
userData.Bio = ReqInfo.Bio
// Update user info // Update user info
userData.UpdateByUserID(userId) userData.UpdateByUserID(userId)

View File

@@ -1,30 +1,54 @@
package utils package utils
import "github.com/gin-gonic/gin" import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
)
type RespStatus struct { type RespStatus struct {
Code int `json:"code"` Code int `json:"code"`
ErrorId string `json:"error_id"`
Status string `json:"status"` Status string `json:"status"`
ErrorId string `json:"error_id"`
Data any `json:"data"` Data any `json:"data"`
} }
func HttpResponse(c *gin.Context, code int, errorId string, status string, data ...any) { func render(c *gin.Context, code int, errId string, data []any, abort bool) {
var resp = RespStatus{ resp := RespStatus{
Code: code, Code: code,
ErrorId: errorId, Status: http.StatusText(code),
Status: status, ErrorId: errId,
Data: data,
} }
c.JSON(code, resp)
switch len(data) {
case 0:
resp.Data = nil
case 1:
resp.Data = data[0]
default:
resp.Data = data
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
if abort {
c.AbortWithStatus(code)
} else {
c.Status(code)
}
_, _ = c.Writer.Write(jsonBytes)
} }
func HttpAbort(c *gin.Context, code int, errorId string, status string, data ...any) { func HttpResponse(c *gin.Context, code int, errId string, data ...any) {
var resp = RespStatus{ render(c, code, errId, data, false)
Code: code, }
ErrorId: errorId,
Status: status, func HttpAbort(c *gin.Context, code int, errId string, data ...any) {
Data: data, render(c, code, errId, data, true)
}
c.AbortWithStatusJSON(code, resp)
} }