Compare commits

..

30 Commits

Author SHA1 Message Date
af43b86a61 feat(client): refactor auth/login
Some checks failed
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build failed
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-02 16:48:16 +08:00
0a4f459188 feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-02 16:47:37 +08:00
61d2d2aef3 Sign new code for new redirect
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:47:15 +08:00
0b710fd538 Change magic_link_ttl old name to auth_code_ttl
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:37:30 +08:00
d70ade4907 Change resend to using smtp
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:26:21 +08:00
a98ab26fa4 Add oauth2 like auth service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 15:57:42 +08:00
62da1e096e Fix default config
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:59:43 +08:00
fd1c89392f Add abort for jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:55:47 +08:00
ae93f49691 Fix jwt middleware cnext
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:52:04 +08:00
743f8373b0 Fix request return
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:34:13 +08:00
4796653896 Fix jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:17:25 +08:00
4dfd4cd529 Modify auth middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:00:02 +08:00
bd8eecbc7d Fix dup err logic
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:37:27 +08:00
cbec9bf2b3 Modify jwt middleware logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:36:07 +08:00
3d685b5a86 Add hot reload for backend
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 20:22:55 +08:00
83fe326962 Add event type for event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:59:57 +08:00
5b6bc9ce42 Return user bio in user info service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:54:43 +08:00
e0e1abab93 Add Bio to user table, set varchar for role in attendance table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:52:24 +08:00
9f927c907a Fix a bug
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:27:00 +08:00
27ba3b7bef Add aes cryptography library
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:25:44 +08:00
63f71d3b81 Add bcrypt and aes crypto lib
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:24:41 +08:00
e40d175c8e Remove user.type from auth/magic service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:12:05 +08:00
304e1d95ed Refactor checkin table to attendance table
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:08:59 +08:00
acd3c95c80 Refactor mass data structure
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 13:31:28 +08:00
8973d518a2 refactor(client): qr dialog skeleton
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
b5b4bb9d66 refactor(client): optimize suspense components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
4c438cf4e4 Add contributing guide to README
Some checks failed
Build Development (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 19:13:45 +08:00
d44eef6bb7 chore(just): do not run frontend install in backend commands
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:30 +08:00
a49450bf9e feat(auth/magic): log to console instead of sending email in debug mode
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:13 +08:00
228d838c37 fix(devenv): use correct just command
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:11:31 +08:00
64 changed files with 1798 additions and 785 deletions

View File

@@ -1,32 +1 @@
SERVER_APPLICATION=nixcn-cms
SERVER_ADDRESS=:8000
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
SERVER_DEBUG_MODE=true
SERVER_FILE_LOGGER=false
DATABASE_TYPE=postgres
DATABASE_HOST=localhost:5432
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
CACHE_HOSTS=localhost:6379
CACHE_MASTER=
CACHE_USERNAME=
CACHE_PASSWORD=
CACHE_DB=0
SEARCH_HOST=localhost
SEARCH_API_KEY=
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
TTL_MAGIC_LINK_TTL=10m
TTL_ACCESS_TTL=15s
TTL_REFRESH_TTL=168h
TTL_CHECKIN_COSE_TTL=10m
TZ=Asia/Shanghai

View File

@@ -1,2 +1,25 @@
# nixcn-cms
## Contribution
1. **Root docs serve the zh-CN version** _[MUST]_
2. **Use sign-off via `git commit -s`** _[MUST]_
3. **Do not modify the `main` branch for any reason** _[MUST]_
4. **Do not omit the commit subject for any reason** _[MUST]_
5. **Describe all changes in the commit message** _[MUST]_
6. **Rebase before submitting patches** _[MUST]_
7. **Commit message written in english** _[MUST]_
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
9. **Split commits for large or multi-part changes** _[OPTION]_
10. **Have fun contributing :)** _[VERY NECESSARY]_
## Toolchain
- Nix
- Devenv
- Direnv
## Notice
1. Client and all nix files use 2 space tab.
2. All Golang files and other configs use 4 space tab.

View File

@@ -9,6 +9,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -24,11 +25,13 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -39,6 +42,7 @@
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -254,6 +258,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -406,6 +412,8 @@
"@sindresorhus/base62": ["@sindresorhus/base62@1.0.0", "", {}, "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="],
"@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="],
@@ -466,12 +474,20 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
"@tanstack/form-core": ["@tanstack/form-core@1.27.7", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw=="],
"@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="],
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-form": ["@tanstack/react-form@1.27.7", "", { "dependencies": { "@tanstack/form-core": "1.27.7", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.141.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.141.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg=="],
@@ -500,6 +516,8 @@
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.143.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-yrdxNCKPaMjIXM5ZFf3jWNtGlOEZWh2nPdN5NQagkOrYK/l87SZRASB/vFerBXupXPaXvEL8C0qzb894trHW5w=="],
"@tanstack/zod-form-adapter": ["@tanstack/zod-form-adapter@0.42.1", "", { "dependencies": { "@tanstack/form-core": "0.42.1" }, "peerDependencies": { "zod": "^3.x" } }, "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -1238,6 +1256,8 @@
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
@@ -1540,10 +1560,14 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/zod-form-adapter/@tanstack/form-core": ["@tanstack/form-core@0.42.1", "", { "dependencies": { "@tanstack/store": "^0.7.0" } }, "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1634,6 +1658,8 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tanstack/zod-form-adapter/@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],

View File

@@ -14,6 +14,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -29,11 +30,13 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -44,6 +47,7 @@
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -14,9 +15,9 @@ import { Button } from '../ui/button';
export function QrDialog(
{ eventId }: { eventId: string },
) {
const { data } = useCheckinCode(eventId);
const [open, setOpen] = useState(false);
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="w-20"></Button>
</DialogTrigger>
@@ -27,21 +28,41 @@ export function QrDialog(
</DialogDescription>
</DialogHeader>
<QrDialogContent checkinCode={data.data.checkin_code} />
<QrSection eventId={eventId} enabled={open} />
</DialogContent>
</Dialog>
);
}
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
return (
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
const { data } = useCheckinCode(eventId, enabled);
return data
? (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data={checkinCode} className="size-60" />
<QRCode data={data.data.checkin_code} className="size-60" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
{checkinCode}
{data.data.checkin_code}
</div>
</DialogFooter>
</>
)
: (
<QrSectionSkeleton />
);
}
function QrSectionSkeleton() {
return (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data="114514" className="size-60 blur-xs" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
Loading...
</div>
</DialogFooter>
</>

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import React, { Suspense } from 'react';
export function withFallback<P extends object>(
Component: React.ComponentType<P>,
fallback: ReactNode,
) {
const Wrapped: React.FC<P> = (props) => {
return (
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
);
};
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
})`;
return Wrapped;
}

View File

@@ -1,4 +1,5 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react';
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils';
export function LoginForm({
oauthParams,
className,
...props
}: React.ComponentProps<'div'>) {
}: React.ComponentProps<'div'> & {
oauthParams: AuthorizeSearchParams;
}) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
@@ -28,7 +32,7 @@ export function LoginForm({
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => {
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);

View File

@@ -0,0 +1,104 @@
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 SettingsForm() {
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();
}}
className="space-y-3 max-w-5xl mr-auto py-10"
>
<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

@@ -0,0 +1,34 @@
import { Mail } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useUserInfo } from '@/hooks/data/useUserInfo';
export function MainProfile() {
const { data: user } = useUserInfo();
return (
<div className="flex flex-col w-full">
<div className="flex w-full flex-row gap-4 mt-2">
<Avatar className="size-16 rounded-full border-2 border-muted">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col justify-center">
<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>
<Button className="w-full mt-4" variant="outline" size="lg">
</Button>
<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">
{/* Bio */}
</section>
</div>
);
}

View File

@@ -1,13 +1,7 @@
import {
IconDashboard,
IconSettings,
} from '@tabler/icons-react';
import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/nav-main';
import { NavSecondary } from '@/components/nav-secondary';
import { NavUser } from '@/components/nav-user';
import { NavMain } from '@/components/sidebar/nav-main';
import { NavSecondary } from '@/components/sidebar/nav-secondary';
import {
Sidebar,
SidebarContent,
@@ -17,28 +11,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
const data = {
user: {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
},
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '设置',
url: '#',
icon: IconSettings,
},
],
};
import { navData } from '@/lib/navData';
import { NavUser } from './nav-user';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
@@ -48,7 +22,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
className="data-[slot=sidebar-menu-button]:p-1.5!"
>
<a href="#">
<NixOSLogo />
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
<NavMain items={navData.navMain} />
<NavSecondary items={navData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />

View File

@@ -1,8 +1,9 @@
'use client';
import type { Icon } from '@tabler/icons-react';
import * as React from 'react';
import { Link } from '@tanstack/react-router';
import * as React from 'react';
import {
SidebarGroup,
SidebarGroupContent,
@@ -27,12 +28,16 @@ export function NavSecondary({
<SidebarMenu>
{items.map(item => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<Link to={item.url}>
{({ isActive }) => {
return (
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
);
}}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>

View File

@@ -24,8 +24,10 @@ import {
} from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
export function NavUser() {
function NavUser_() {
const { isMobile } = useSidebar();
const { data: user } = useUserInfo();
const { logout } = useLogout();
@@ -83,3 +85,20 @@ export function NavUser() {
</SidebarMenu>
);
}
function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);

View File

@@ -1,7 +1,18 @@
import { useRouterState } from '@tanstack/react-router';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export function SiteHeader() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const currentTitle
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -10,7 +21,7 @@ export function SiteHeader() {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium"></h1>
<h1 className="text-base font-medium">{currentTitle}</h1>
</div>
</header>
);

View File

@@ -190,7 +190,7 @@ function FieldError({
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(async () => {
const content = useMemo(() => {
if (children) {
return children;
}

View File

@@ -0,0 +1,9 @@
import { Skeleton } from '../ui/skeleton';
export function CardSkeleton() {
return (
<Skeleton
className="gap-6 rounded-xl py-6 h-full"
/>
);
}

View File

@@ -2,11 +2,12 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { QrDialog } from '../checkin/qr-dialog';
import { withFallback } from '../hoc/with-fallback';
import { CardSkeleton } from './card-skeleton';
export function CheckinCard() {
function CheckinCard_() {
const { data } = useUserInfo();
return (
<>
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
@@ -25,6 +26,7 @@ export function CheckinCard() {
</QrDialog>
</CardFooter>
</Card>
</>
);
}
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);

View File

@@ -1,8 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useCheckinCode(eventId: string) {
return useSuspenseQuery({
export function useCheckinCode(eventId: string, enabled: boolean) {
return useQuery({
queryKey: ['getCheckinCode', eventId],
queryFn: async () => {
return axiosClient.get<{
@@ -13,5 +13,6 @@ export function useCheckinCode(eventId: string) {
},
});
},
enabled,
});
}

View File

@@ -1,7 +1,8 @@
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload {
interface GetMagicLinkPayload extends AuthorizeSearchParams {
email: string;
turnstile_token: string;
}
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<object>('/auth/magic', payload);
return axiosClient.post<{ status: string }>('/auth/magic', payload);
},
});
}

View File

@@ -17,5 +17,6 @@ export function useUserInfo() {
>('/user/info');
return response.data;
},
staleTime: 10 * 60 * 1000,
});
}

View File

@@ -7,7 +7,7 @@ export function useLogout() {
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/login' });
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };

21
client/src/lib/navData.ts Normal file
View File

@@ -0,0 +1,21 @@
import {
IconDashboard,
IconUser,
} from '@tabler/icons-react';
export const navData = {
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '个人资料',
url: '/profile',
icon: IconUser,
},
],
};

14
client/src/lib/random.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Generate a cryptographically secure OAuth2 state string
* base64url encoded, URL-safe
*/
export function generateOAuthState(bytes: number = 32): string {
const random = new Uint8Array(bytes);
crypto.getRandomValues(random);
// base64url encode
return btoa(String.fromCharCode(...random))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

View File

@@ -29,6 +29,12 @@ export function clearTokens() {
setRefreshToken('');
}
export async function doSetTokenByCode(code: string) {
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
setToken(data.access_token);
setRefreshToken(data.refresh_token);
}
export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
}

View File

@@ -9,19 +9,26 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TokenRouteImport } from './routes/token'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthorizeRouteImport } from './routes/authorize'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
const TokenRoute = TokenRouteImport.update({
id: '/token',
path: '/token',
getParentRoute: () => rootRouteImport,
} as any)
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent',
path: '/magicLinkSent',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const AuthorizeRoute = AuthorizeRouteImport.update({
id: '/authorize',
path: '/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
path: '/',
getParentRoute: () => SidebarLayoutRoute,
} as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/login': typeof LoginRoute
'/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/magicLinkSent' | '/'
fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/magicLinkSent' | '/'
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/login'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute
AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/token': {
id: '/token'
path: '/token'
fullPath: '/token'
preLoaderRoute: typeof TokenRouteImport
parentRoute: typeof rootRouteImport
}
'/magicLinkSent': {
id: '/magicLinkSent'
path: '/magicLinkSent'
@@ -79,11 +107,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/authorize': {
id: '/authorize'
path: '/authorize'
fullPath: '/authorize'
preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
@@ -100,14 +128,23 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute
}
'/_sidebarLayout/profile': {
id: '/_sidebarLayout/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
parentRoute: typeof SidebarLayoutRoute
}
}
}
interface SidebarLayoutRouteChildren {
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
}
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
}
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute,
AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -4,7 +4,24 @@ import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import '@/index.css';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// eslint-disable-next-line ts/no-unsafe-assignment
const status
// eslint-disable-next-line ts/no-unsafe-member-access
= error?.response?.status ?? error?.status;
if (status >= 400 && status < 500) {
return false;
}
return failureCount < 3;
},
},
},
});
function RootLayout() {
return (

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -7,24 +7,22 @@ export const Route = createFileRoute('/_sidebarLayout/')({
loader: async () => {
if (!hasToken()) {
throw redirect({
to: '/login',
to: '/authorize',
});
}
},
});
function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<CheckinCard />
</div>
);
}
function Index() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
{/* Section Cards */}
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<CheckinCard />
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';
import { MainProfile } from '@/components/profile/main-profile';
export const Route = createFileRoute('/_sidebarLayout/profile')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6">
<MainProfile />
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { generateOAuthState } from '@/lib/random';
import { getToken } from '@/lib/token';
const authorizeSchema = z.object({
response_type: z.literal('code').default('code'),
client_id: z.literal('org_client').default('org_client'),
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
state: z.string().default(generateOAuthState()),
});
export type AuthorizeSearchParams = z.infer<typeof authorizeSchema>;
export const Route = createFileRoute('/authorize')({
component: RouteComponent,
validateSearch: zodValidator(authorizeSchema),
});
function RouteComponent() {
const token = getToken();
const oauthParams = Route.useSearch();
if (token !== null) {
const base = new URL(window.location.origin);
const url = new URL('/api/v1/auth/redirect', base);
url.searchParams.set('client_id', oauthParams.client_id);
url.searchParams.set('response_type', oauthParams.response_type);
url.searchParams.set('redirect_uri', oauthParams.redirect_uri);
url.searchParams.set('state', oauthParams.state);
window.location.href = url.toString();
return null;
}
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm oauthParams={oauthParams} />
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
import { setRefreshToken, setToken } from '@/lib/token';
const loginMagicLinkReceiverSchema = z.object({
ticket: z.string().optional(),
});
export const Route = createFileRoute('/login')({
component: RouteComponent,
validateSearch: zodValidator(loginMagicLinkReceiverSchema),
});
function ReceiveMagicLinkComponent() {
const { ticket } = Route.useSearch();
const { data } = useValidateMagicLink(ticket!);
setToken(data.data.access_token);
setRefreshToken(data.data.refresh_token);
return <Navigate to="/" />;
}
function RouteComponent() {
const { ticket } = Route.useSearch();
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
{ticket === undefined ? <LoginForm /> : <ReceiveMagicLinkComponent />}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import z from 'zod';
import { doSetTokenByCode } from '@/lib/token';
const tokenCodeSchema = z.object({
code: z.string().nonempty(),
});
export const Route = createFileRoute('/token')({
component: RouteComponent,
validateSearch: tokenCodeSchema,
});
function RouteComponent() {
const { code } = Route.useSearch();
const [status, setStatus] = useState('Loading...');
const navigate = useNavigate();
doSetTokenByCode(code).then(() => {
void navigate({ to: '/' });
}).catch((_) => {
setStatus('Error getting token');
});
return <div>{status}</div>;
}

View File

@@ -23,7 +23,7 @@ export default defineConfig({
},
server: {
proxy: {
'/api': 'http://10.0.0.10:8000',
'/api': 'http://10.0.0.250:8000',
},
host: '0.0.0.0',
port: 5173,

View File

@@ -20,13 +20,19 @@ search:
host: 127.0.0.1
api_key: ""
email:
resend_api_key: abc
host:
port:
username:
password:
security:
insecure_skip_verify:
from:
secrets:
jwt_secret: example
turnstile_secret: example
client_secret_key: example
ttl:
magic_link_ttl: 10m
jwt_ttl: 15s
refresh_ttl: 7d
checkin_code_ttl: 5m
auth_code_ttl: 10m
access_ttl: 15s
refresh_ttl: 168h
checkin_code_ttl: 10m

View File

@@ -13,9 +13,9 @@ type config struct {
type server struct {
Application string `yaml:"application"`
Address string `yaml:"address"`
ExternalUrl string `yaml:"external_url"`
DebugMode string `yaml:"debug_mode"`
FileLogger string `yaml:"file_logger"`
JwtSecret string `yaml:"jwt_secret"`
}
type database struct {
@@ -40,17 +40,23 @@ type search struct {
}
type email struct {
ResendApiKey string `yaml:"resend_api_key"`
Host string `yaml:"host"`
Port string `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Security string `yaml:"security"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
From string `yaml:"from"`
}
type secrets struct {
JwtSecret string `yaml:"jwt_secret"`
TurnstileSecret string `yaml:"turnstile_secret"`
ClientSecretKey string `yaml:"client_secret_key"`
}
type ttl struct {
MagicLinkTTL string `yaml:"magic_link_ttl"`
AuthCodeTTL string `yaml:"auth_code_ttl"`
AccessTTL string `yaml:"access_ttl"`
RefreshTTL string `yaml:"refresh_ttl"`
CheckinCodeTTL string `yaml:"checkin_code_ttl"`

263
data/attendance.go Normal file
View File

@@ -0,0 +1,263 @@
package data
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Attendance struct {
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
Role string `json:"role" gorm:"type:varchar(255);not null"`
CheckinAt time.Time `json:"checkin_at"`
}
type AttendanceSearchDoc struct {
AttendanceId string `json:"attendance_id"`
EventId string `json:"event_id"`
UserId string `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) {
var checkin Attendance
err := Database.
Where("user_id = ? AND event_id = ?", userId, eventId).
First(&checkin).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &checkin, err
}
type AttendanceUsers struct {
UserId uuid.UUID `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) {
var result []AttendanceUsers
err := Database.
Model(&Attendance{}).
Select("user_id, checkin_at").
Where("event_id = ?", eventID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
type AttendanceEvent struct {
EventId uuid.UUID `json:"event_id"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) {
var result []AttendanceEvent
err := Database.
Model(&Attendance{}).
Select("event_id, checkin_at").
Where("user_id = ?", userID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
func (self *Attendance) Create() error {
self.UUID = uuid.New()
self.AttendanceId = uuid.New()
// DB transaction for strong consistency
err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
return nil
})
if err != nil {
return err
}
if err := self.UpdateSearchIndex(); err != nil {
return err
}
return nil
}
func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
var attendance Attendance
err := Database.Transaction(func(tx *gorm.DB) error {
// Lock the row for update
if err := tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error; err != nil {
return err
}
updates := map[string]any{}
if checkinTime != nil {
updates["checkin_at"] = *checkinTime
}
if len(updates) == 0 {
return nil
}
if err := tx.
Model(&attendance).
Updates(updates).Error; err != nil {
return err
}
// Reload to ensure struct is up to date
return tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error
})
if err != nil {
return nil, err
}
// Sync to MeiliSearch (eventual consistency)
if err := attendance.UpdateSearchIndex(); err != nil {
return nil, err
}
return &attendance, nil
}
func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "event_id = \"" + eventID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "user_id = \"" + userID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) UpdateSearchIndex() error {
doc := AttendanceSearchDoc{
AttendanceId: self.AttendanceId.String(),
EventId: self.EventId.String(),
UserId: self.UserId.String(),
CheckinAt: self.CheckinAt,
}
index := MeiliSearch.Index("attendance")
primaryKey := "attendance_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Attendance) DeleteSearchIndex() error {
index := MeiliSearch.Index("attendance")
_, err := index.DeleteDocument(self.AttendanceId.String(), nil)
return err
}
func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
ctx := context.Background()
ttl := viper.GetDuration("ttl.checkin_code_ttl")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
ok, err := Redis.SetNX(
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
}
}
func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
ctx := context.Background()
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
if err != nil {
return errors.New("invalid or expired checkin code")
}
// Expected format: user_id:<uuid>:event_id:<uuid>
parts := strings.Split(val, ":")
if len(parts) != 4 {
return errors.New("invalid checkin code format")
}
userIdStr := parts[1]
eventIdStr := parts[3]
userId, err := uuid.Parse(userIdStr)
if err != nil {
return err
}
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
return err
}
attendanceData, err := self.GetAttendance(userId, eventId)
if err != nil {
return err
}
time := time.Now()
_, err = self.Update(attendanceData.AttendanceId, &time)
if err != nil {
return err
}
return nil
}

91
data/client.go Normal file
View File

@@ -0,0 +1,91 @@
package data
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"nixcn-cms/internal/cryptography"
"strings"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/datatypes"
)
type Client struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
}
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
var client Client
if err := Database.
Where("client_id = ?", clientId).
First(&client).Error; err != nil {
return nil, err
}
return &client, nil
}
func (self *Client) GetDecryptedSecret() (string, error) {
secretKey := viper.GetString("secrets.client_secret_key")
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
return string(secret), err
}
type ClientParams struct {
ClientId string
ClientName string
RedirectUri []string
}
func (self *Client) Create(params *ClientParams) (*Client, error) {
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
if err != nil {
return nil, err
}
encKey := viper.GetString("secrets.client_secret_key")
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
clientSecret := base64.RawURLEncoding.EncodeToString(b)
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
if err != nil {
return nil, err
}
client := &Client{
UUID: uuid.New(),
ClientId: params.ClientId,
ClientSecret: encryptedSecret,
ClientName: params.ClientName,
RedirectUri: jsonRedirectUri,
}
if err := Database.Create(&client).Error; err != nil {
return nil, err
}
return client, nil
}
func (self *Client) ValidateRedirectURI(redirectURI string) error {
var uris []string
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
return err
}
for _, prefix := range uris {
if strings.HasPrefix(redirectURI, prefix) {
return nil
}
}
return errors.New("redirect uri not match")
}

View File

@@ -35,7 +35,7 @@ func Init() {
}
// Auto migrate
err = db.AutoMigrate(&User{}, &Event{})
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
if err != nil {
log.Error("[Database] Error migrating database: ", err)
}

View File

@@ -1,16 +1,12 @@
package data
import (
"errors"
"slices"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Event struct {
@@ -18,9 +14,9 @@ type Event struct {
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
Type string `json:"type" gotm:"type:varchar(255);index;not null"`
StartTime time.Time `json:"start_time" gorm:"index"`
EndTime time.Time `json:"end_time" gorm:"index"`
JoinedUsers datatypes.JSONSlice[uuid.UUID] `json:"joined_users"`
}
type EventSearchDoc struct {
@@ -30,93 +26,71 @@ type EventSearchDoc struct {
EndTime time.Time `json:"end_time"`
}
func (self *Event) GetEventById(eventId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("event_id = ?", eventId).First(&self).Error; err != nil {
return err
func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
var event Event
err := Database.
Where("event_id = ?", eventId).
First(&event).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil
})
return nil, err
}
return &event, nil
}
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Updates(&self).Error; err != nil {
return err
}
// Update event to document index
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
docPrimaryKey := "event_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil
})
}
func (self *Event) CreateEvent() error {
if self.UUID == uuid.Nil {
self.UUID = uuid.New()
}
if self.EventId == uuid.Nil {
self.EventId = uuid.New()
}
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
// Add event to document index
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
docPrimaryKey := "event_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.AddDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil
})
}
func (self *Event) UserJoinEvent(userId, eventId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error {
var event Event
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
// DB transaction
if err := Database.Transaction(func(tx *gorm.DB) error {
// Update by business key
if err := tx.
Model(&Event{}).
Where("event_id = ?", eventId).
First(&event).Error; err != nil {
Updates(self).Error; err != nil {
return err
}
// Check if user already joined
if slices.Contains(event.JoinedUsers, userId) {
return errors.New("user already joined")
}
// Add user to list
event.JoinedUsers = append(event.JoinedUsers, userId)
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Update("joined_users", event.JoinedUsers).Error; err != nil {
// Reload to ensure struct is fresh
return tx.
Where("event_id = ?", eventId).
First(self).Error
}); err != nil {
return err
}
// Sync search index
if err := self.UpdateSearchIndex(); err != nil {
return err
}
return nil
}
func (self *Event) Create() error {
self.UUID = uuid.New()
self.EventId = uuid.New()
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
*self = event
return nil
})
}
func (self *Event) GetFullTable() (*[]Event, error) {
@@ -130,6 +104,8 @@ func (self *Event) GetFullTable() (*[]Event, error) {
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
index := MeiliSearch.Index("event")
// Fast read from MeiliSearch (no DB involved)
result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit,
Offset: offset,
@@ -137,9 +113,38 @@ func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error
if err != nil {
return nil, err
}
var list []EventSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err
}
return &list, nil
}
func (self *Event) UpdateSearchIndex() error {
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
primaryKey := "event_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Event) DeleteSearchIndex() error {
index := MeiliSearch.Index("event")
_, err := index.DeleteDocument(self.EventId.String(), nil)
return err
}

View File

@@ -1,20 +1,10 @@
package data
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"github.com/spf13/viper"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Permission Level
@@ -28,11 +18,10 @@ type User struct {
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
Type string `json:"type" gorm:"type:varchar(32);index;not null"`
Nickname string `json:"nickname"`
Subtitle string `json:"subtitle"`
Avatar string `json:"avatar"`
Checkin datatypes.JSONMap `json:"checkin"`
Bio string `json:"bio" gorm:"type:text"`
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
}
@@ -46,88 +35,61 @@ type UserSearchDoc struct {
PermissionLevel uint `json:"permission_level"`
}
func (self *User) GetByEmail(email string) error {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil {
return err
func (self *User) GetByEmail(email string) (*User, error) {
var user User
err := Database.
Where("email = ?", email).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil
return nil, err
}
func (self *User) GetByUserId(userId uuid.UUID) error {
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil {
return err
}
return nil
return &user, nil
}
func (self *User) UpdateCheckin(userId, eventId uuid.UUID, time time.Time) error {
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
var user User
err := Database.
Where("user_id = ?", userId).
First(self).Error; err != nil {
return err // if error then rollback
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
self.Checkin = datatypes.JSONMap{eventId.String(): time}
if err := tx.Save(self).Error; err != nil {
return err // rollback
}
// Update user to document index
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Type: self.Type,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
}
index := MeiliSearch.Index("user")
docPrimaryKey := "user_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.UpdateDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil // commit
})
return &user, err
}
func (self *User) Create() error {
return Database.Transaction(func(tx *gorm.DB) error {
if self.UUID == uuid.Nil {
self.UUID = uuid.New()
}
if self.UserId == uuid.Nil {
self.UserId = uuid.New()
}
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err
}
// Create user to document index
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Type: self.Type,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
return nil
}); err != nil {
return err
}
index := MeiliSearch.Index("user")
docPrimaryKey := "user_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.AddDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
return nil
})
}
func (self *User) UpdateByUserID(userId uuid.UUID) error {
@@ -150,6 +112,8 @@ func (self *User) GetFullTable() (*[]User, error) {
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
index := MeiliSearch.Index("user")
// Fast read from MeiliSearch, no DB involved
result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit,
Offset: offset,
@@ -157,75 +121,43 @@ func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
if err != nil {
return nil, err
}
var list []UserSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err
}
return &list, nil
}
func (self *User) GenCheckinCode(eventId uuid.UUID) (*string, error) {
ctx := context.Background()
ttl := viper.GetDuration("ttl.checkin_code_ttl")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
func (self *User) UpdateSearchIndex() error {
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
}
index := MeiliSearch.Index("user")
for {
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
ok, err := Redis.SetNX(
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
}
primaryKey := "user_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
func (self *User) VerifyCheckinCode(checkinCode string) (*uuid.UUID, error) {
ctx := context.Background()
result := Redis.Get(ctx, "checkin_code:"+checkinCode).String()
if result == "" {
return nil, errors.New("invalid or expired checkin code")
}
split := strings.Split(result, ":")
if len(split) < 2 {
return nil, errors.New("invalid checkin code format")
}
userId := split[0]
eventId := split[1]
var returnedUserId uuid.UUID
err := Database.Transaction(func(tx *gorm.DB) error {
checkinData := map[string]interface{}{
eventId: time.Now(),
}
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(map[string]interface{}{
"checkin": checkinData,
}).Error; err != nil {
if _, err := index.UpdateDocuments(
[]UserSearchDoc{doc},
opts,
); err != nil {
return err
}
parsedUserId, err := uuid.Parse(userId)
if err != nil {
return err
}
returnedUserId = parsedUserId
return nil
})
if err != nil {
return nil, err
}
return &returnedUserId, nil
func (self *User) DeleteSearchIndex() error {
index := MeiliSearch.Index("user")
_, err := index.DeleteDocument(self.UserId.String(), nil)
return err
}

View File

@@ -8,6 +8,7 @@
packages = [
pkgs.git
pkgs.just
pkgs.watchexec
];
dotenv = {
@@ -32,11 +33,7 @@
exec = "bun run dev";
cwd = "./client";
};
backend.exec = "just run";
};
tasks = {
"backend:build".exec = "just clean && just build";
backend.exec = "just dev-back";
};
services = {

2
go.mod
View File

@@ -64,6 +64,8 @@ require (
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/postgres v1.6.0 // indirect

4
go.sum
View File

@@ -139,7 +139,11 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=

View File

@@ -0,0 +1,208 @@
package cryptography
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := io.ReadFull(rand.Reader, b)
return b, err
}
func normalizeKey(key []byte) ([]byte, error) {
switch len(key) {
case 16, 24, 32:
return key, nil
default:
return nil, errors.New("AES key length must be 16, 24, or 32 bytes")
}
}
func AESGCMEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := randomBytes(gcm.NonceSize())
if err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
out := append(nonce, ciphertext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("invalid padding")
}
padding := int(data[length-1])
if padding == 0 || padding > length {
return nil, errors.New("invalid padding")
}
return data[:length-padding], nil
}
func AESCBCEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Pad(plaintext, block.BlockSize())
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(data, data)
return pkcs7Unpad(data)
}
func AESCFBEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(data, data)
return data, nil
}

View File

@@ -0,0 +1,22 @@
package cryptography
import "golang.org/x/crypto/bcrypt"
func BcryptHash(input string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(input),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(hash), nil
}
func BcryptVerify(input string, hashed string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hashed),
[]byte(input),
)
return err == nil
}

View File

@@ -9,15 +9,14 @@ bun_cmd := `realpath $(which bun)`
default: install clean build-back build-client run-back
backend: install clean build-back run-back
backend: clean build-back run-back
install:
cd {{ client_dir }} && {{ bun_cmd }} install
clean:
rm -rf {{ output_dir }}
mkdir -p {{ output_dir }}
cp {{ join(project_dir, "config.default.yaml") }} {{ join(output_dir, "config.yaml") }}
mkdir -p .outputs
find .outputs -mindepth 1 ! -path .outputs/config.yaml -exec rm -rf {} +
client:
cd {{ client_dir }} && {{ bun_cmd }} dev
@@ -34,5 +33,8 @@ run-back:
test-back:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./...
dev-back: clean
watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ join(output_dir, "nixcn-cms") }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}'
dev:
devenv up --verbose

View File

@@ -1,57 +1,31 @@
package middleware
import (
"net/http"
"strings"
"nixcn-cms/internal/cryptography"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
func JWTAuth() gin.HandlerFunc {
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
func JWTAuth(required bool) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing Authorization header",
})
authtoken := new(authtoken.Token)
uid, err := authtoken.HeaderVerify(auth)
if err != nil {
c.JSON(401, gin.H{"status": err.Error()})
c.Abort()
return
}
// Split header to 2
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid Authorization header format",
})
if required == true && uid == "" {
c.JSON(401, gin.H{"status": "unauthorized"})
c.Abort()
return
}
tokenStr := parts[1]
// Verify access token
claims := &cryptography.JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
c.Set("user_id", claims.UserID)
c.Set("user_id", uid)
c.Next()
}
}

View File

@@ -1,4 +1,4 @@
package magiclink
package authcode
import (
"crypto/rand"
@@ -19,35 +19,35 @@ var (
)
// Generate magic token
func NewMagicToken(email string) (string, error) {
func NewAuthCode(email string) (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(b)
code := base64.RawURLEncoding.EncodeToString(b)
store.Store(token, Token{
store.Store(code, Token{
Email: email,
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.auth_code_ttl")),
})
return token, nil
return code, nil
}
// Verify magic token
func VerifyMagicToken(token string) (string, bool) {
val, ok := store.Load(token)
func VerifyAuthCode(code string) (string, bool) {
val, ok := store.Load(code)
if !ok {
return "", false
}
t := val.(Token)
if time.Now().After(t.ExpiresAt) {
store.Delete(token)
store.Delete(code)
return "", false
}
store.Delete(token)
store.Delete(code)
return t.Email, true
}

View File

@@ -1,11 +1,13 @@
package cryptography
package authtoken
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"nixcn-cms/data"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -187,3 +189,34 @@ func (self *Token) RevokeRefreshToken(refreshToken string) error {
return err
}
func (self *Token) HeaderVerify(header string) (string, error) {
if header == "" {
return "", nil
}
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
// Split header to 2
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("invalid Authorization header format")
}
tokenStr := parts[1]
// Verify access token
claims := &JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
return "", errors.New("invalid or expired token")
}
return claims.UserID.String(), nil
}

70
pkgs/email/email.go Normal file
View File

@@ -0,0 +1,70 @@
package email
import (
"crypto/tls"
"errors"
"strings"
"time"
"github.com/spf13/viper"
gomail "gopkg.in/gomail.v2"
)
type Client struct {
dialer *gomail.Dialer
from string
}
func NewSMTPClient() (*Client, error) {
host := viper.GetString("email.host")
port := viper.GetInt("email.port")
user := viper.GetString("email.username")
pass := viper.GetString("email.password")
from := viper.GetString("email.from")
security := strings.ToLower(viper.GetString("email.security"))
insecure := viper.GetBool("email.insecure_skip_verify")
if host == "" || port == 0 || user == "" || pass == "" {
return nil, errors.New("SMTP config not set")
}
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)
}
return &Client{
dialer: dialer,
from: from,
}, nil
}
func (c *Client) Send(to, subject, html string) (string, error) {
m := gomail.NewMessage()
m.SetHeader("From", c.from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", html)
if err := c.dialer.DialAndSend(m); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
}

View File

@@ -1,87 +0,0 @@
package email
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/spf13/viper"
)
type Client struct {
apiKey string
http *http.Client
}
// Resend service client
func NewResendClient() (*Client, error) {
key := viper.GetString("email.resend_api_key")
if key == "" {
return nil, errors.New("RESEND_API_KEY not set")
}
return &Client{
apiKey: key,
http: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
type sendEmailRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
type sendEmailResponse struct {
ID string `json:"id"`
}
// Send email by resend API
func (c *Client) Send(to, subject, html string) (string, error) {
reqBody := sendEmailRequest{
From: viper.GetString("email.from"),
To: []string{to},
Subject: subject,
HTML: html,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
req, err := http.NewRequest(
http.MethodPost,
"https://api.resend.com/emails",
bytes.NewReader(body),
)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", errors.New("resend send failed")
}
var res sendEmailResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return "", err
}
return res.ID, nil
}

View File

@@ -1,9 +1,14 @@
package auth
import "github.com/gin-gonic/gin"
import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
r.POST("/magic", RequestMagicLink)
r.GET("/magic/verify", VerifyMagicLink)
r.GET("/redirect", Redirect, middleware.JWTAuth(false))
r.POST("/magic", Magic)
r.POST("/refresh", Refresh)
r.POST("/token", Token)
}

View File

@@ -1,28 +1,26 @@
package auth
import (
"nixcn-cms/data"
"nixcn-cms/internal/cryptography"
"net/url"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/magiclink"
"nixcn-cms/pkgs/turnstile"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
type MagicLinkRequest struct {
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token" binding:"required"`
type MagicRequest struct {
ClientId string `json:"client_id"`
RedirectUri string `json:"redirect_uri"`
State string `json:"state"`
Email string `json:"email"`
TurnstileToken string `json:"turnstile_token"`
}
func RequestMagicLink(c *gin.Context) {
func Magic(c *gin.Context) {
// Parse request
var req MagicLinkRequest
var req MagicRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
@@ -35,82 +33,42 @@ func RequestMagicLink(c *gin.Context) {
return
}
// Generate magic token
token, err := magiclink.NewMagicToken(req.Email)
code, err := authcode.NewAuthCode(req.Email)
if err != nil {
c.JSON(500, gin.H{"error": "internal error"})
return
c.JSON(500, gin.H{"status": "code gen failed"})
}
link := viper.GetString("server.external_url") + "/login?ticket=" + token
externalUrl := viper.GetString("server.external_url")
url, err := url.Parse(externalUrl)
if err != nil {
c.JSON(500, gin.H{"status": "invalid external url"})
}
url.Path = "/api/v1/auth/redirect"
query := url.Query()
query.Set("code", code)
query.Set("redirect_uri", req.RedirectUri)
query.Set("state", req.State)
query.Set("client_id", req.ClientId)
url.RawQuery = query.Encode()
debugMode := viper.GetBool("server.debug_mode")
if debugMode {
c.JSON(200, gin.H{"status": "magiclink sent", "uri": url.String()})
return
} else {
// Send email using resend
resend, err := email.NewResendClient()
emailClient, err := email.NewSMTPClient()
if err != nil {
log.Error(err)
c.JSON(500, gin.H{"status": "invilad email config"})
c.JSON(500, gin.H{"status": "invalid email config"})
return
}
resend.Send(
emailClient.Send(
req.Email,
"NixCN CMS Email Verify",
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+link+">"+link+"</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>",
)
}
c.JSON(200, gin.H{"status": "magic link sent"})
}
func VerifyMagicLink(c *gin.Context) {
// Get token from url
magicToken := c.Query("token")
if magicToken == "" {
c.JSON(400, gin.H{"error": "missing token"})
return
}
// Verify email token
email, ok := magiclink.VerifyMagicToken(magicToken)
if !ok {
c.JSON(401, gin.H{"error": "invalid or expired token"})
return
}
// Verify if user exists
user := new(data.User)
err := user.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.Type = "Normal"
user.PermissionLevel = 10
if err := user.Create(); err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
} else {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
}
// Generate jwt
JwtTool := cryptography.Token{
Application: viper.GetString("server.application"),
}
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
if err != nil {
c.JSON(500, gin.H{
"status": "error generating tokens",
})
return
}
c.JSON(200, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}

128
service/auth/redirect.go Normal file
View File

@@ -0,0 +1,128 @@
package auth
import (
"net/url"
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func Redirect(c *gin.Context) {
clientId := c.Query("client_id")
if clientId == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
redirectUri := c.Query("redirect_uri")
if redirectUri == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
state := c.Query("state")
if state == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
code := c.Query("code")
if code == "" {
userIdOrig, ok := c.Get("user_id")
if !ok || userIdOrig == "" {
c.JSON(401, gin.H{"status": "unauthorized"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return
}
userData := new(data.User)
user, err := userData.GetByUserId(userId)
if err != nil {
c.JSON(500, gin.H{"status": "failed to get user id"})
return
}
code, err := authcode.NewAuthCode(user.Email)
if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}
// Verify email token
email, ok := authcode.VerifyAuthCode(code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
// Verify if user exists
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.PermissionLevel = 10
if err := user.Create(); err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
} else {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
}
clientData := new(data.Client)
client, err := clientData.GetClientByClientId(clientId)
if err != nil {
c.JSON(400, gin.H{"status": "client not found"})
return
}
err = client.ValidateRedirectURI(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "redirect uri not match"})
return
}
newCode, err := authcode.NewAuthCode(email)
if err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", newCode)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}

View File

@@ -1,7 +1,7 @@
package auth
import (
"nixcn-cms/internal/cryptography"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
return
}
JwtTool := cryptography.Token{
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}
@@ -30,6 +30,7 @@ func Refresh(c *gin.Context) {
refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken)
if err != nil {
c.JSON(500, gin.H{"statis": "error renew refresh token"})
return
}
c.JSON(200, gin.H{

52
service/auth/token.go Normal file
View File

@@ -0,0 +1,52 @@
package auth
import (
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
type TokenRequest struct {
Code string `json:"code"`
}
func Token(c *gin.Context) {
var req TokenRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
email, ok := authcode.VerifyAuthCode(req.Code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
// Generate jwt
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
if err != nil {
c.JSON(500, gin.H{"status": "error generating tokens"})
return
}
c.JSON(200, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}

View File

@@ -7,6 +7,6 @@ import (
)
func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth())
r.Use(middleware.JWTAuth(true))
r.GET("/info", Info)
}

View File

@@ -8,29 +8,23 @@ import (
)
func Info(c *gin.Context) {
event := new(data.Event)
eventData := new(data.Event)
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{
"status": "undefinded event id",
})
c.JSON(400, gin.H{"status": "undefinded event id"})
return
}
// Parse event id
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(500, gin.H{
"status": "error parsing string to uuid",
})
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
return
}
err = event.GetEventById(eventId)
event, err := eventData.GetEventById(eventId)
if err != nil {
c.JSON(404, gin.H{
"status": "event id not found",
})
c.JSON(404, gin.H{"status": "event id not found"})
return
}
@@ -38,6 +32,5 @@ func Info(c *gin.Context) {
"name": event.Name,
"start_time": event.StartTime,
"end_time": event.EndTime,
"joined_users": event.JoinedUsers,
})
}

View File

@@ -8,70 +8,68 @@ import (
)
func Checkin(c *gin.Context) {
data := new(data.User)
userId, ok := c.Get("user_id")
data := new(data.Attendance)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(401, gin.H{
"status": "unauthorized",
})
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
// Get event id from query
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{
"status": "undefinded event id",
})
c.JSON(400, gin.H{"status": "undefinded event id"})
return
}
// Parse event id to uuid
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(500, gin.H{
"status": "error parsing string to uuid",
})
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
return
}
data.UserId = userId.(uuid.UUID)
data.UserId = userId
code, err := data.GenCheckinCode(eventId)
if err != nil {
c.JSON(500, gin.H{
"status": "error generating code",
})
c.JSON(500, gin.H{"status": "error generating code"})
return
}
c.JSON(200, gin.H{
"checkin_code": code,
})
c.JSON(200, gin.H{"checkin_code": code})
}
func CheckinSubmit(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if userIdOrig.(string) == "" || !ok {
c.JSON(401, gin.H{"status": "unauthorized"})
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
userData := new(data.User)
userData.GetByUserId(userId)
if userData.PermissionLevel <= 20 {
c.JSON(403, gin.H{"status": "access denied"})
return
}
var req struct {
ChekinCode string `json:"checkin_code"`
}
c.ShouldBindJSON(&req)
data := new(data.User)
userId, err := data.VerifyCheckinCode(req.ChekinCode)
attendanceData := new(data.Attendance)
err = attendanceData.VerifyCheckinCode(req.ChekinCode)
if err != nil {
c.JSON(400, gin.H{
"status": "error verify checkin code",
})
c.JSON(400, gin.H{"status": "error verify checkin code"})
return
}
data.GetByUserId(*userId)
if data.PermissionLevel <= 20 {
c.JSON(403, gin.H{
"status": "access denied",
})
}
c.JSON(200, gin.H{
"status": "success",
})
c.JSON(200, gin.H{"status": "success"})
}

View File

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

View File

@@ -2,46 +2,38 @@ package user
import (
"nixcn-cms/data"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Info(c *gin.Context) {
data := new(data.User)
userId, ok := c.Get("user_id")
userData := new(data.User)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(404, gin.H{
"status": "user not found",
})
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return
}
// Get user from database
err := data.GetByUserId(userId.(uuid.UUID))
user, err := userData.GetByUserId(userId)
if err != nil {
c.JSON(404, gin.H{
"status": "user not found",
})
c.JSON(404, gin.H{"status": "user not found"})
return
}
// Set time nil if time is zero
for k, v := range data.Checkin {
if t, ok := v.(time.Time); ok && t.IsZero() {
data.Checkin[k] = nil
}
}
c.JSON(200, gin.H{
"user_id": data.UserId,
"email": data.Email,
"type": data.Type,
"nickname": data.Nickname,
"subtitle": data.Subtitle,
"avatar": data.Avatar,
"checkin": data.Checkin,
"permission_level": data.PermissionLevel,
"user_id": user.UserId,
"email": user.Email,
"nickname": user.Nickname,
"subtitle": user.Subtitle,
"avatar": user.Avatar,
"bio": user.Bio,
"permission_level": user.PermissionLevel,
})
}

View File

@@ -17,34 +17,26 @@ func List(c *gin.Context) {
}
offset, ok := c.GetQuery("offset")
if !ok {
c.JSON(400, gin.H{
"status": "offset not found",
})
c.JSON(400, gin.H{"status": "offset not found"})
return
}
// Parse string to int64
limitNum, err := strconv.ParseInt(limit, 10, 64)
if err != nil {
c.JSON(400, gin.H{
"status": "parse string to int error",
})
c.JSON(400, gin.H{"status": "parse string to int error"})
return
}
offsetNum, err := strconv.ParseInt(offset, 10, 64)
if err != nil {
c.JSON(400, gin.H{
"status": "parse string to int error",
})
c.JSON(400, gin.H{"status": "parse string to int error"})
return
}
// Get user list from search engine
list, err := data.FastListUsers(limitNum, offsetNum)
if err != nil {
c.JSON(500, gin.H{
"status": "failed list users from meilisearch",
})
c.JSON(500, gin.H{"status": "failed list users from meilisearch"})
}
c.JSON(200, list)
}

View File

@@ -2,42 +2,47 @@ package user
import (
"nixcn-cms/data"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func Query(c *gin.Context) {
userId, ok := c.Get("user_id")
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(400, gin.H{"status": "could not found user_id"})
c.JSON(403, gin.H{"status": "userid error"})
return
}
eventId, ok := c.GetQuery("event_id")
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{
"status": "failed to parse uuid",
})
}
eventIdOrig, ok := c.GetQuery("event_id")
if !ok {
c.JSON(400, gin.H{"status": "could not found event_id"})
return
}
data := new(data.User)
err := data.GetByUserId(userId.(uuid.UUID))
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
c.JSON(404, gin.H{"status": "cannot found user"})
return
}
if data.Checkin[eventId] == nil {
c.JSON(404, gin.H{"status": "cannot found user checked in"})
c.JSON(400, gin.H{"status": "event_id is not valid"})
return
}
var checkinTime *time.Time
if data.Checkin[eventId].(*time.Time).IsZero() {
checkinTime = nil
} else {
checkinTime = data.Checkin[eventId].(*time.Time)
attendanceData := new(data.Attendance)
attendance, err := attendanceData.GetAttendance(userId, eventId)
if err != nil {
c.JSON(500, gin.H{"status": "database error"})
return
} else if attendance == nil {
c.JSON(404, gin.H{"status": "event checkin record not found"})
return
} else if attendance.CheckinAt.IsZero() {
c.JSON(200, gin.H{"checkin_at": nil})
return
}
c.JSON(200, gin.H{
"checkin_time": checkinTime,
})
c.JSON(200, gin.H{"checkin_at": attendance.CheckinAt})
}

View File

@@ -8,27 +8,27 @@ import (
)
func Update(c *gin.Context) {
// New user model
user := new(data.User)
userIdOrig, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{"status": "userid error"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
var ReqInfo data.User
c.BindJSON(&ReqInfo)
// New user model
user := new(data.User)
userId, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{
"status": "can not found user id",
})
return
}
// Get user info
user.GetByUserId(userId.(uuid.UUID))
user.GetByUserId(userId)
// Reject permission 0 user
if user.PermissionLevel == 0 {
c.JSON(403, gin.H{
"status": "premission denied",
})
c.JSON(403, gin.H{"status": "premission denied"})
return
}
@@ -36,15 +36,9 @@ func Update(c *gin.Context) {
user.Email = ReqInfo.Email
user.Nickname = ReqInfo.Nickname
user.Subtitle = ReqInfo.Subtitle
// Cant change user type under permission 2
if user.PermissionLevel >= 2 {
user.Type = ReqInfo.Type
}
// Update user info
user.UpdateByUserID(userId.(uuid.UUID))
user.UpdateByUserID(userId)
c.JSON(200, gin.H{
"status": "success",
})
c.JSON(200, gin.H{"status": "success"})
}