forked from nixcn/nixcn-cms
Compare commits
30 Commits
develop
...
noa.virell
| Author | SHA1 | Date | |
|---|---|---|---|
|
af43b86a61
|
|||
|
0a4f459188
|
|||
|
61d2d2aef3
|
|||
|
0b710fd538
|
|||
|
d70ade4907
|
|||
|
a98ab26fa4
|
|||
|
62da1e096e
|
|||
|
fd1c89392f
|
|||
|
ae93f49691
|
|||
|
743f8373b0
|
|||
|
4796653896
|
|||
|
4dfd4cd529
|
|||
|
bd8eecbc7d
|
|||
|
cbec9bf2b3
|
|||
|
3d685b5a86
|
|||
|
83fe326962
|
|||
|
5b6bc9ce42
|
|||
|
e0e1abab93
|
|||
|
9f927c907a
|
|||
|
27ba3b7bef
|
|||
|
63f71d3b81
|
|||
|
e40d175c8e
|
|||
|
304e1d95ed
|
|||
|
acd3c95c80
|
|||
| 8973d518a2 | |||
| b5b4bb9d66 | |||
|
4c438cf4e4
|
|||
|
d44eef6bb7
|
|||
|
a49450bf9e
|
|||
|
228d838c37
|
@@ -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
|
||||
|
||||
23
README.md
23
README.md
@@ -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.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
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={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">
|
||||
{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={checkinCode} className="size-60" />
|
||||
<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">
|
||||
{checkinCode}
|
||||
Loading...
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
20
client/src/components/hoc/with-fallback.tsx
Normal file
20
client/src/components/hoc/with-fallback.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
104
client/src/components/profile/form.tsx
Normal file
104
client/src/components/profile/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
client/src/components/profile/main-profile.tsx
Normal file
34
client/src/components/profile/main-profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
@@ -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}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -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 />);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
client/src/components/workbenchCards/card-skeleton.tsx
Normal file
9
client/src/components/workbenchCards/card-skeleton.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<Skeleton
|
||||
className="gap-6 rounded-xl py-6 h-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,29 +2,31 @@ 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>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export function useUserInfo() {
|
||||
>('/user/info');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
21
client/src/lib/navData.ts
Normal 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
14
client/src/lib/random.ts
Normal 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(/=+$/, '');
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
14
client/src/routes/_sidebarLayout/profile.tsx
Normal file
14
client/src/routes/_sidebarLayout/profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
client/src/routes/authorize.tsx
Normal file
42
client/src/routes/authorize.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
25
client/src/routes/token.tsx
Normal file
25
client/src/routes/token.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
From string `yaml:"from"`
|
||||
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
263
data/attendance.go
Normal 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
91
data/client.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
177
data/event.go
177
data/event.go
@@ -1,26 +1,22 @@
|
||||
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 {
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
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"`
|
||||
StartTime time.Time `json:"start_time" gorm:"index"`
|
||||
EndTime time.Time `json:"end_time" gorm:"index"`
|
||||
JoinedUsers datatypes.JSONSlice[uuid.UUID] `json:"joined_users"`
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
// Reload to ensure struct is fresh
|
||||
return tx.
|
||||
Where("event_id = ?", eventId).
|
||||
First(self).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
*self = event
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Search index (eventual consistency)
|
||||
if err := self.UpdateSearchIndex(); err != nil {
|
||||
// TODO: async retry / log
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
232
data/user.go
232
data/user.go
@@ -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
|
||||
@@ -24,16 +14,15 @@ import (
|
||||
// Super User: 30
|
||||
|
||||
type User struct {
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
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"`
|
||||
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||
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"`
|
||||
Nickname string `json:"nickname"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Avatar string `json:"avatar"`
|
||||
Bio string `json:"bio" gorm:"type:text"`
|
||||
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
||||
}
|
||||
|
||||
type UserSearchDoc struct {
|
||||
@@ -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, err
|
||||
}
|
||||
return nil
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (self *User) GetByUserId(userId uuid.UUID) error {
|
||||
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil {
|
||||
return err
|
||||
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
|
||||
var user User
|
||||
|
||||
err := Database.
|
||||
Where("user_id = ?", userId).
|
||||
First(&user).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return 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"}).
|
||||
Where("user_id = ?", userId).
|
||||
First(self).Error; err != nil {
|
||||
return err // if error then rollback
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
self.UUID = uuid.New()
|
||||
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,
|
||||
}
|
||||
index := MeiliSearch.Index("user")
|
||||
docPrimaryKey := "user_id"
|
||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
||||
if _, err := index.AddDocuments([]UserSearchDoc{doc}, meiliOptions); 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
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
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 *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")
|
||||
|
||||
primaryKey := "user_id"
|
||||
opts := &meilisearch.DocumentOptions{
|
||||
PrimaryKey: &primaryKey,
|
||||
}
|
||||
|
||||
if _, err := index.UpdateDocuments(
|
||||
[]UserSearchDoc{doc},
|
||||
opts,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
208
internal/cryptography/aes.go
Normal file
208
internal/cryptography/aes.go
Normal 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
|
||||
}
|
||||
22
internal/cryptography/bcrypt.go
Normal file
22
internal/cryptography/bcrypt.go
Normal 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
|
||||
}
|
||||
10
justfile
10
justfile
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
70
pkgs/email/email.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
// Send email using resend
|
||||
resend, err := email.NewResendClient()
|
||||
externalUrl := viper.GetString("server.external_url")
|
||||
url, err := url.Parse(externalUrl)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
c.JSON(500, gin.H{"status": "invilad email config"})
|
||||
return
|
||||
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
|
||||
emailClient, err := email.NewSMTPClient()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "invalid email config"})
|
||||
return
|
||||
}
|
||||
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="+url.String()+">"+url.String()+"</a>",
|
||||
)
|
||||
}
|
||||
resend.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>",
|
||||
)
|
||||
|
||||
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
128
service/auth/redirect.go
Normal 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())
|
||||
}
|
||||
@@ -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
52
service/auth/token.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,6 @@ import (
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.Use(middleware.JWTAuth())
|
||||
r.Use(middleware.JWTAuth(true))
|
||||
r.GET("/info", Info)
|
||||
}
|
||||
|
||||
@@ -8,36 +8,29 @@ 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
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"name": event.Name,
|
||||
"start_time": event.StartTime,
|
||||
"end_time": event.EndTime,
|
||||
"joined_users": event.JoinedUsers,
|
||||
"name": event.Name,
|
||||
"start_time": event.StartTime,
|
||||
"end_time": event.EndTime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user