341 Commits

Author SHA1 Message Date
90f056ea6c 禁用博客Image优化 2025-06-23 09:26:20 +08:00
3c05ab394a 修复评论时会暴露用户所有字段的问题 2025-06-23 09:25:15 +08:00
e24d7938d0 获取评论时,返回的user信息仅包含必要字段 2025-06-23 09:23:26 +08:00
f140031266 修复评论时返回博客实体的问题 2025-06-23 09:18:35 +08:00
503695b28c 前端调整评论发布失败的错误消息 2025-06-23 09:15:31 +08:00
b75765a6d8 后端修复获取评论失败的bug 2025-06-23 09:15:10 +08:00
5fdd69acb8 修复博客实体与评论关系错误 2025-06-23 09:14:28 +08:00
80ee6798b7 前端管理添加允许评论选项 2025-06-23 08:57:39 +08:00
aa43136946 对评论相关接口进行权限设定 2025-06-23 08:56:03 +08:00
cb4f5907e5 博客权限添加允许评论项 2025-06-23 08:55:28 +08:00
a861cba5c3 漏了俩文件... 2025-06-23 01:40:46 +08:00
6d4e600be5 修复一个文件大小写错误.. 2025-06-23 01:39:14 +08:00
610a0fc657 .. 2025-06-23 01:38:49 +08:00
be75bb7bc1 lint 2025-06-23 01:30:02 +08:00
3ef9285278 完成链接复制 2025-06-23 01:29:02 +08:00
3e156d3f5d 不要乱给Image加fill 2025-06-23 01:13:28 +08:00
f82fc0fb77 修复了几个damn的bug,终于可以用户端访问了 2025-06-23 01:12:25 +08:00
d40392745a 完成剩余需求 2025-06-23 00:43:27 +08:00
edc605fb62 完成博客权限修改 2025-06-23 00:07:23 +08:00
a38463837f 优化组件提取实现 2025-06-22 23:34:20 +08:00
38fa0a0a07 提取blogPermissionCheckBoxs组件 2025-06-22 23:31:55 +08:00
d4679f3733 完成博客添加时的权限指定 2025-06-22 23:26:12 +08:00
5ae62d5d22 调整数据库结构、添加博客权限enum 2025-06-22 23:25:38 +08:00
063181da5a lint 2025-06-22 21:17:39 +08:00
0eed6cfdbf 提交1.0.0的README 2025-06-22 21:06:56 +08:00
e6087f43d0 修复oss中effect重复循环执行的问题 2025-06-22 20:43:38 +08:00
da98961b8b 调整web到3002端口 2025-06-20 00:48:15 +08:00
6d3efa57ca 修复博客ip错误 2025-06-20 00:42:03 +08:00
d1d1870100 lint 2025-06-19 23:53:28 +08:00
0758b9f75a 重构ossStore 2025-06-19 23:07:06 +08:00
6e73220962 lint 2025-06-19 22:03:57 +08:00
8f2df85208 重构OssStore 2025-06-19 22:03:50 +08:00
1bd717f84f 标准化博客评论命名 2025-06-19 16:06:09 +08:00
bb9fa3bcaa 优化博客与博客评论关系 2025-06-19 16:03:27 +08:00
bf3b2f7a94 博客评论限流5/min,添加前端错误处理 2025-06-19 15:42:38 +08:00
325c2e3a87 userMe处理无token情况,节省资源 2025-06-19 15:24:44 +08:00
c262778373 添加userMe错误重试判定 2025-06-19 15:20:02 +08:00
c782145e7e 实现博客评论用户提示 2025-06-19 15:16:55 +08:00
cb2258aa64 提升userMe到hook中 2025-06-19 15:01:08 +08:00
da16bc1dbe 添加控制台菜单加载态 2025-06-19 14:55:58 +08:00
43e814fbe7 为hook的useSWR添加错误消息 2025-06-19 11:51:23 +08:00
4c9505d476 添加邮件限流 2025-06-19 11:41:30 +08:00
b79c84a004 发送验证码、登陆接口限流20/min 2025-06-19 11:27:00 +08:00
70b90c3ed7 前端禁用发送短信验证码 2025-06-19 11:23:16 +08:00
83b15ddf37 实现邮件验证发送 2025-06-19 11:21:34 +08:00
0f4d6be36f 暂时移除手机登陆方式 2025-06-19 09:36:08 +08:00
a1a16afb76 创建账户信息组件 2025-06-19 09:30:04 +08:00
70c89b783a 移动USER_ME_CACHE_KEY到UserApi中、登出移除该缓存 2025-06-19 09:15:03 +08:00
bd862e54fa 实现权限级菜单、localStorageSWR缓存 2025-06-19 09:03:52 +08:00
af0e9c6522 前端user/me提升到console/layout 2025-06-18 17:26:50 +08:00
acaf14c403 后端实现权限验证 2025-06-18 17:10:55 +08:00
f62e2ad2a6 彻底移除权限模块。。。 2025-06-18 16:23:08 +08:00
ec090e3e20 彻底移除权限模块 2025-06-18 16:16:46 +08:00
4e306adc9f 后端移除所有权限模块 2025-06-18 15:54:13 +08:00
38fa850e38 修改网站描述 2025-06-18 15:46:30 +08:00
041e27c87d 修复批量删除object参数错误 2025-06-17 17:01:53 +08:00
6d09087289 上传文件成功自动从列表删除 2025-06-17 16:58:53 +08:00
7e05789fe5 封装ossStore 2025-06-17 16:53:53 +08:00
e418476b20 添加登陆接口限流 2025-06-17 09:37:24 +08:00
90a67b681e format + lint 2025-06-14 14:12:18 +08:00
e777afc433 添加邮件系统page 2025-06-10 21:37:36 +08:00
749cb2e13b 调整控制台侧边栏用户管理 2025-06-10 21:21:57 +08:00
7b7f940a60 调整博客、资源表格,允许任意位置换行 2025-06-10 15:37:56 +08:00
0461c0ce70 下载存储文件在新窗口打开 2025-06-10 15:03:28 +08:00
613f9e1a8c 优化存储界面文件数量提示 2025-06-10 14:59:57 +08:00
2c75d6de4a 优化存储界面 2025-06-10 14:57:52 +08:00
65db82ce60 修复修改密码导致界面无法点击的问题 2025-06-10 14:53:54 +08:00
19dc49b10d 完成ali-oss文件管理 2025-06-10 13:52:35 +08:00
3211e25bd6 ossSts添加缓存 2025-06-08 22:36:14 +08:00
359ab3b072 后端实现OssSts 2025-06-08 22:24:59 +08:00
b16e454058 完成博客评论添加ip的控制器 2025-06-08 22:10:47 +08:00
2698d5f133 博客评论添加ip及地址 2025-06-08 22:00:02 +08:00
e0b80ea422 博客图片优化鼠标hover样式 2025-06-08 21:50:55 +08:00
448bed89bd 控制台允许返回首页 2025-06-08 12:00:48 +08:00
accd5b8754 实现博客评论 2025-06-07 15:19:43 +08:00
73e409ce84 实现评论本地更新 2025-06-07 13:49:33 +08:00
3821ef6657 初步完成评论 2025-06-07 03:21:27 +08:00
c872b55083 添加邮件模块 2025-05-20 14:11:22 +08:00
c2914d8e29 移除所有的.DS_Store 2025-05-20 13:48:16 +08:00
0ebda96d62 实现修改密码,但引入了修改密码后无法点击窗口的bug 2025-05-18 23:18:42 +08:00
1baff0712a 实现用户注销和删除系统 2025-05-18 22:25:05 +08:00
4d9245aabb user List方法包含软删除的用户 2025-05-18 21:47:02 +08:00
263ad2a0ae 删除adminUserService,合并到userService中 2025-05-18 21:44:33 +08:00
4bace58823 对软删除的用户登录进行处理 2025-05-18 21:39:39 +08:00
d5d799b425 user服务findOne方法添加withDeleted选项 2025-05-18 21:39:13 +08:00
b0a8ac9d66 优化修改用户密码时失败处理 2025-05-18 21:29:17 +08:00
ef7dae1bae 优化用户登录账号删除的情况 2025-05-18 21:28:55 +08:00
e25c4f0455 优化Blog管理列表样式 2025-05-18 21:02:53 +08:00
f1f32f03ec 博客文章添加a标签样式 2025-05-18 20:53:42 +08:00
cbd4ca5686 添加博客成功自动重置表单 2025-05-18 16:04:30 +08:00
8992963da8 修复blank拼写错误 2025-05-18 16:02:08 +08:00
6f172c6cb6 实现访问博客计数 2025-05-18 16:01:12 +08:00
02a73a5e33 优化加载台和错误 2025-05-18 15:56:54 +08:00
44a1da5be2 实现iframe等元素的渲染 2025-05-18 15:47:27 +08:00
b4ade3bdd1 优化图片预览 2025-05-18 15:38:09 +08:00
5d9dfe7382 实现博客内容解析 2025-05-18 15:25:25 +08:00
393377b5da 修复footer联系我文本换行问题 2025-05-18 15:25:13 +08:00
42e9bd0f0b 整理依赖 2025-05-18 14:32:15 +08:00
7730ae7981 初步完成渲染博客文章 2025-05-18 14:30:53 +08:00
3f0e281d42 实现blog后端api 2025-05-18 14:30:18 +08:00
327f49bff0 实现blog列表 2025-05-18 14:30:03 +08:00
e1606b707e 添加base62编码工具 2025-05-18 14:29:50 +08:00
39f3e6b93c 完成BlogApi 2025-05-18 14:29:42 +08:00
4f5f771e2b 完成博客增删改查 2025-05-16 22:10:21 +08:00
e782b926a4 实现添加博客 2025-05-16 21:58:33 +08:00
dc0a8a1071 添加资源界面添加描述 2025-05-16 21:29:29 +08:00
7ab982e4fd 添加资源界面加载态和错误 2025-05-16 17:33:01 +08:00
7a612f8480 实现登出 2025-05-13 11:58:29 +08:00
c6cc3a7098 修复一个apiError的错误 2025-05-13 11:58:16 +08:00
088e168460 优化header 2025-05-13 11:35:17 +08:00
b6d9a518e8 优化控制台user 2025-05-13 11:35:03 +08:00
96e8d0e090 添加图片oss域 2025-05-13 11:34:37 +08:00
5e7ed3d6f7 完成me接口 2025-05-13 10:53:25 +08:00
134335021d 修复header导致服务端客户端渲染不一致问题 2025-05-12 22:51:43 +08:00
b764d3c932 调整组件位置 2025-05-12 22:43:24 +08:00
367ec1d9a0 调整用户图标 2025-05-12 22:41:21 +08:00
af3b4b0cc3 移除未使用的引用 2025-05-12 22:41:11 +08:00
fcec99f1c5 优化资源图片质量、添加tagId 2025-05-12 22:40:58 +08:00
7d065d1264 资源标题样式调整 2025-05-12 22:29:43 +08:00
51ed3a7747 样式小调整 2025-05-12 22:27:27 +08:00
92a3510547 优化文字溢出样式 2025-05-12 22:24:23 +08:00
ee4feb57c5 优化编辑资源标签样式 2025-05-12 22:16:34 +08:00
5159fa3606 实现资源删除管理 2025-05-12 22:13:09 +08:00
53665f8847 实现resource界面 2025-05-12 21:50:53 +08:00
30224774e6 添加编辑资源加载态 2025-05-12 21:34:59 +08:00
e3ca7ac027 完成资源CRUD 2025-05-12 21:31:24 +08:00
bb054f7f5a 完成资源的增加和查询 2025-05-12 20:53:10 +08:00
2a1e0d45dd 修复前端api漏掉/api路径问题 2025-05-12 16:01:37 +08:00
8e0d7dc873 为resourceListAPi添加类型声明 2025-05-12 16:00:23 +08:00
259fae4c63 修复文件夹命名错误,添加命名空间 2025-05-12 15:54:06 +08:00
9133b45744 完成博客、资源管理api 2025-05-12 15:50:50 +08:00
eb4301ba98 实现后端webResource CRUD 2025-05-12 15:30:42 +08:00
7f441f5126 修复创建用户 密码name错误 2025-05-12 14:00:54 +08:00
054d505117 调整用户名长度4~32,昵称1~30 2025-05-12 13:56:20 +08:00
26142f9a71 新增自动刷新 2025-05-12 13:52:38 +08:00
11d503ab49 调整路径 2025-05-12 13:34:00 +08:00
748ea70c0d 优化错误处理表达 2025-05-12 13:30:48 +08:00
f1876e19bf 修复错误捕获 2025-05-12 13:30:09 +08:00
17bcb8787a 优化后端,实现前端添加用户 2025-05-12 13:29:29 +08:00
c17108e094 完成修改密码删除用户 2025-05-12 12:47:27 +08:00
5c29938730 修复前端直接修改user的问题 2025-05-12 12:18:14 +08:00
f16cbe5443 调整user实体,去掉id 2025-05-12 12:09:42 +08:00
f49a1503f0 完成了一些接口 2025-05-12 12:07:01 +08:00
4f782e4cea 完成admin-user-update 2025-05-12 11:40:21 +08:00
2dd088fdf3 完成admin-user-get 2025-05-12 10:45:56 +08:00
e5c0c354e5 完成管理员user-list 2025-05-12 10:27:50 +08:00
21cee7c3f1 引入俩新组件 2025-05-12 10:27:37 +08:00
369fe28c5c 后端验证服务成功返回true 2025-05-12 10:22:18 +08:00
17c065ae79 完成项目结构搭建 2025-05-10 19:13:50 +08:00
7bbcf2fac7 移除重复的控制台 2025-05-10 12:18:32 +08:00
d281a6c804 调整前端目录结构 2025-05-10 12:08:04 +08:00
2f86362f4b 完成用户权限管理 2025-05-08 23:13:24 +08:00
448a7b48ba 实现角色权限管理 2025-05-08 22:59:03 +08:00
a2972de417 实现admin-permission 2025-05-08 22:42:26 +08:00
98745895eb 实现admin-role 2025-05-08 22:38:50 +08:00
87d6c738c7 实现用户角色服务 2025-05-08 22:28:25 +08:00
2f9b922485 实现权限管理的服务 2025-05-08 22:26:04 +08:00
d2287bc363 实现管理段用户增删改查改密码 2025-05-08 22:07:06 +08:00
8e33f1b61b 验证pipe禁止未定义的字段 2025-05-08 22:06:44 +08:00
c2e5ab51df 完成管理员获取用户列表 2025-05-07 23:48:40 +08:00
b6f1495981 完成权限角色守卫 2025-05-07 23:14:57 +08:00
4df7de91d0 加入权限模块、用户模块加入userRole实体 2025-05-07 22:12:18 +08:00
c5463e9ffe 移除user实体不需要的引用 2025-05-07 22:04:34 +08:00
96a76568cc 添加验证码日志 2025-05-07 18:57:12 +08:00
9206b7fcc0 移除错误引入的服务 2025-05-07 18:33:41 +08:00
464931cc98 添加博客模块 2025-05-07 18:33:13 +08:00
9e09a7bc72 优化资源删除处理 2025-05-07 18:21:50 +08:00
3b2f4f1f40 优化资源查询 2025-05-07 18:17:37 +08:00
a661827842 添加资源模块,添加资源获取接口 2025-05-07 18:14:31 +08:00
9570fb4524 放弃短信验证了 2025-05-07 17:21:49 +08:00
8039a3571d 实现登录验证 2025-05-07 16:06:46 +08:00
c6471cc169 完成验证码发送和验证 2025-05-07 16:01:37 +08:00
8645380fbf 优化loginDto错误信息 2025-05-07 15:16:06 +08:00
37988b4582 优化表单验证错误处理 2025-05-07 15:14:09 +08:00
f5f80385ad 调整logout从get为post 2025-05-07 15:04:36 +08:00
97352e26b2 添加相应成功标准响应 2025-05-07 15:02:56 +08:00
6a44e902fd 实现登出接口 2025-05-07 14:44:07 +08:00
1246613fb1 完成jwt鉴权 2025-05-07 14:34:49 +08:00
d2744689b2 加入userSession Service 2025-05-07 13:38:14 +08:00
b75c4fb551 调整user实体🚰 2025-05-07 13:38:01 +08:00
166201371c user模块注册userSession实体 2025-05-07 13:37:27 +08:00
d03ce79653 实现auth模块的登录 2025-05-07 13:37:05 +08:00
9f60ea9228 auth模块加入userSession和jwt 2025-05-07 13:36:51 +08:00
fda2eb01ec app加入PassportModule 2025-05-07 13:36:36 +08:00
dda4f8da05 加入userSession实体 2025-05-07 13:36:26 +08:00
4d21045303 调整user实体位置 2025-05-07 13:36:06 +08:00
5316d922ab 加入了一些依赖 2025-05-07 13:35:58 +08:00
5dd0d7c2f3 auth模块导入user模块 2025-05-07 00:02:04 +08:00
d4c40db011 user模块导出Service 2025-05-07 00:01:49 +08:00
7985b141c0 允许userService进行or查询 2025-05-07 00:01:32 +08:00
1d434f03dd 更新user实体的密码哈希命名 2025-05-07 00:01:13 +08:00
062719adce 完善auth服务注释 2025-05-06 22:58:47 +08:00
e954f2fe76 优化登录controller对登录方式的处理 2025-05-06 22:54:38 +08:00
eef23909f4 完成后端登录dto验证 2025-05-06 22:52:51 +08:00
c2868b5128 完善前端blog types声明 2025-05-06 19:13:20 +08:00
36246d3263 更新登录注册逻辑 2025-05-06 09:25:57 +08:00
ebf48b6062 移除无用依赖 2025-04-26 22:00:13 +08:00
641f08a042 调整资源组件 2025-04-26 21:59:05 +08:00
e67a7f2f01 引入swr用于请求 2025-04-26 21:58:51 +08:00
c548add86e 优化资源样式 2025-04-26 21:11:17 +08:00
aa33643982 lint 2025-04-26 20:42:05 +08:00
77edc576ea 优化资源样式 2025-04-26 16:18:05 +08:00
6a1ef7b409 现在才是完成博客,刚刚是资源 2025-04-26 16:13:29 +08:00
1be4fea2f5 完成博客 2025-04-26 15:05:47 +08:00
c9fab9fa8f 优化登录页登录方式样式 2025-04-26 13:03:01 +08:00
016ba13466 完善console界面登录样式 2025-04-26 13:00:17 +08:00
d46b5a36b1 提交登录页背景图 2025-04-26 12:59:55 +08:00
5975b71dcc 完善登录page 2025-04-26 12:59:45 +08:00
78fe191845 完善matedata 2025-04-26 12:59:36 +08:00
2c53203473 完成登录界面样式 2025-04-25 23:23:46 +08:00
bcecdcf9ef 优化首页 2025-04-25 22:25:42 +08:00
5c956ba949 引入头像,完成首页 2025-04-25 22:24:37 +08:00
c161a6d298 优化footer 2025-04-25 21:43:42 +08:00
842d834c6d 实现header、调整路由 2025-04-25 21:42:02 +08:00
1416043529 引入对话框和vaul图标库 2025-04-25 21:41:01 +08:00
3a10e6abb7 优化footer 2025-04-24 22:47:12 +08:00
8fbbcabc57 添加默认背景颜色 2025-04-24 22:45:19 +08:00
1a57c5ca49 优化header 2025-04-24 22:43:21 +08:00
870b04bb28 添加基础结构 2025-04-24 22:40:18 +08:00
777d36617f 加入依赖 2025-04-24 22:40:05 +08:00
440732bfb9 初步完成header和footor 2025-04-24 22:39:53 +08:00
2e3aed0038 使用shadcn重构 2025-04-24 19:37:31 +08:00
72f6893660 提交nextjs新项目 2025-04-24 17:46:11 +08:00
b0728746cd 更新gitignore文件 2025-04-24 17:44:22 +08:00
6a46d6dce7 logo也先不要了 2025-04-18 22:00:41 +08:00
25ec96492f 重构项目 2025-04-17 16:11:44 +08:00
28a113c132 优化Blog样式 2025-03-04 23:20:53 +08:00
ebf6530c1f 修复user表作用域问题 2025-02-16 23:51:56 +08:00
bb83a87748 移除无用的时间函数 2025-02-16 23:40:29 +08:00
52cbe2a7ed 修改后端启动方式 2025-02-16 23:28:51 +08:00
1d7e2dc5b4 修改后段博客内容时间字段 2025-02-16 23:23:49 +08:00
58f5a715f2 修复博客内容时间字段问题 2025-02-16 23:16:54 +08:00
5c0951fc64 移除无用的依赖 2025-02-16 23:09:00 +08:00
9af60b0dbe 使用pg数据库重构 2025-02-16 23:08:25 +08:00
3d729996bb 前端首页置标题 2025-01-17 12:42:44 +08:00
65927214ed 修复 前端Header菜单文本换行问题 2024-10-25 21:52:54 +08:00
35f11ccb83 优化 前端博客内容页面样式,调整最小宽度为700px 2024-10-16 22:49:19 +08:00
30f84486fa 优化 前端使用协议样式 2024-10-16 22:44:25 +08:00
19ba954c6b 优化 前端博客列表中描述样式 2024-10-16 22:29:16 +08:00
2589b24673 修复 后端获取博客内容后 访问次数不+1的情况 2024-10-16 22:21:08 +08:00
e3eef8e23f 优化 前端博客内容空提示 2024-10-16 22:17:22 +08:00
2bc3e5221d 添加 前端博客列表加密文章提示 2024-10-16 22:10:01 +08:00
96ff60e3ec 添加 服务端查询博客列表时返回access_level字段 2024-10-16 21:54:03 +08:00
923f499709 添加 自动刷新Token功能以维持登录状态 2024-10-16 21:49:04 +08:00
87977298e9 添加 服务端API中RequestData数据类型定义 2024-10-16 21:48:14 +08:00
539050629d 修复 前端BlogContent a标签深色模式的样式 2024-10-16 01:50:21 +08:00
ed64e2b846 添加 前端BlogContent对表格的样式支持 2024-10-16 01:41:27 +08:00
8061f3d292 添加 加密博客文章功能 2024-10-16 01:35:41 +08:00
702869e1f8 优化 前端控制台界面样式 2024-10-15 23:43:05 +08:00
46396153e3 优化 前端Dashboard样式实现 2024-10-14 10:37:53 +08:00
b707b27020 优化 前端深色模式样式 2024-10-13 14:31:24 +08:00
5c7bd5bb74 修复 elementplus深色模式不能实时变化的问题 2024-10-13 14:18:36 +08:00
0ebcb79259 优化 前端BlogContent 2024-10-12 12:27:25 +08:00
f990d81ca2 优化 前端深色模式样式 2024-10-12 11:43:46 +08:00
07b95549fd 添加 ElementPlus深色模式 2024-10-12 11:02:49 +08:00
53e63f4d6a 优化 前端Login登录按钮样式实现 2024-10-12 11:00:57 +08:00
65a2ad75dd 修复 前端NotFound组件销毁标题不重置的问题 2024-10-12 11:00:18 +08:00
607f0a7a4d 优化 前端NotFound界面 2024-10-12 10:54:32 +08:00
8399c9e8a7 优化 文件上传后提示逻辑 2024-10-08 15:27:00 +08:00
07ee1f906f 添加 文件上传失败原因提示 2024-10-08 12:57:52 +08:00
4c3731da70 添加 深色模式 2024-10-08 02:43:17 +08:00
e856af135c 优化 Ace编辑器对语言的支持 2024-10-06 00:38:19 +08:00
ed5be9ec39 添加 OSS文件在线编辑功能package依赖 2024-10-06 00:04:43 +08:00
19ef964c08 添加 OSS文件在线编辑功能 2024-10-06 00:04:21 +08:00
f2bb14bb17 优化 OSS文件上传 文件切片、下载文件逻辑、文件操作界面逻辑 2024-10-05 22:37:04 +08:00
840b427a7e 添加 前端 OSS文件管理 2024-10-05 01:32:06 +08:00
9adcc812a3 添加 服务端GetOSSToken接口 2024-10-05 01:30:59 +08:00
57e8d9ac21 优化 Resources管理界面 2024-10-01 16:39:32 +08:00
35fcf700e2 移除 tonecn中的pnpm-lock 2024-09-30 12:52:44 +08:00
09df4d6475 gitignore加入pnpm-lock 2024-09-30 12:50:49 +08:00
c477af2fad 优化 Resource Download中标签样式 2024-09-30 00:19:43 +08:00
3d56faa361 重构 Footer tailwind 2024-09-30 00:17:18 +08:00
fc28b9cf04 重构 Rotation tailwind 2024-09-29 23:22:04 +08:00
d86a9bfe94 重构 Login tailwind 2024-09-29 23:05:34 +08:00
d754ad1eba 重构 BlogContent tailwind 2024-09-29 22:51:17 +08:00
57997bf184 重构 Blog tailwindcss 2024-09-29 22:18:17 +08:00
f6720db786 修复 Header 菜单下边框选中样式层叠失败的问题 2024-09-29 20:40:20 +08:00
83335d01ef 重构 Download tailwind 2024-09-29 19:36:49 +08:00
670f4f9ea5 重构 Resource tailwind 2024-09-29 19:36:41 +08:00
ae15665b2f 重构 HomeView tailwind 2024-09-29 01:18:17 +08:00
e8bea369b8 重构 Header.vue-tailwind 2024-09-29 00:55:06 +08:00
f9a7c0c5c4 添加 tailwindcss 2024-09-28 22:27:23 +08:00
8d301bdbd1 优化 Logger日志颜色显示 2024-09-13 17:36:51 +08:00
986fde4724 优化 login控制台响应式布局 2024-09-12 22:00:55 +08:00
d01efd710e 优化 notfound界面响应式布局 2024-09-12 21:27:37 +08:00
36d33d9f11 优化 blogContent加载完成前工具栏出现的问题 2024-09-12 20:32:31 +08:00
7b5dacf723 添加404界面,优化homeview代码可读性 2024-09-12 20:29:45 +08:00
d41e43fcb8 优化config.ts 2024-09-12 20:14:09 +08:00
f183e7566e 登录时按下回车键触发登录 2024-09-06 16:10:51 +08:00
2eab11561d 修复typescript类型错误 2024-09-06 16:10:46 +08:00
5b7c5d0f1d 前端加入request中间件,jwtToken失效自动清除localstorage及刷新界面 2024-09-06 15:38:55 +08:00
9815f0efdf 优化captchaSession 2024-09-06 12:00:07 +08:00
bfa5042f07 修改APILoader以保持API统一 2024-09-06 11:59:04 +08:00
52a1cd2817 为API抽象类添加注解和类型限定 2024-09-06 11:58:24 +08:00
b232d6648c 优化MySQL连接池 2024-09-06 11:57:45 +08:00
672b4b771e 删除无用配置文件 2024-09-06 11:57:15 +08:00
dbdf9e415e 前端添加网站图标及优化标签页标题 2024-09-01 16:42:22 +08:00
8ac701f214 更改IPlocationAPI查询接口 2024-09-01 16:41:35 +08:00
fdac3b1433 修改服务端部署配置 2024-09-01 16:41:18 +08:00
7e30cef008 自动导入element-plug 2024-09-01 15:49:10 +08:00
1709552ed4 修复路由守卫next多次调用的问题 2024-09-01 15:47:33 +08:00
0d33e18a88 博客评论后重加载评论 2024-09-01 15:47:10 +08:00
d3106a6576 后端添加tonesc.cn跨域 2024-09-01 15:46:51 +08:00
ac86ab23ad 前端添加实用工具 2024-09-01 15:28:34 +08:00
acbf404780 前端完成Blogs管理界面 2024-09-01 14:37:47 +08:00
d533bd528d 后端完成博客管理接口 2024-09-01 14:37:21 +08:00
55b631cd9c 后端实现Resources修改接口 2024-09-01 13:46:10 +08:00
241834feb8 Dashboard完成、Resources编辑完成 2024-09-01 13:45:41 +08:00
96e8afca57 request自动添加jwtToken 2024-09-01 13:44:59 +08:00
8755df31c9 cors允许DELETE方法 2024-09-01 13:44:46 +08:00
f48395dda1 优化博客评论显示 2024-08-31 23:43:57 +08:00
c397f7b1e5 优化Blog容器高度 2024-08-31 22:57:45 +08:00
8ecf54d8af 修复因棒棒糖导致的Header高度不统一情况 2024-08-31 22:56:07 +08:00
97b02556ba 修复Header中控制台RouterLink指向 2024-08-31 22:53:33 +08:00
49415eb4db 前端登录功能补充 2024-08-31 22:53:02 +08:00
a008ff96ed 前端完成登录功能 2024-08-31 22:52:44 +08:00
1cd705665e 后端完成jwt登录接口 2024-08-31 22:52:31 +08:00
9b0cdf18f5 修复博客内容样式 2024-08-31 16:24:36 +08:00
7d6e056e6b 优化博客访问量显示 2024-08-31 15:05:16 +08:00
30e8e89422 将博客评论组件加入到博客内容中 2024-08-31 14:57:30 +08:00
a154b3fa39 完成前端博客评论展示组件 2024-08-31 14:57:10 +08:00
c6ab1ea75d 新增获取博客评论接口 2024-08-31 14:50:53 +08:00
c2956adb4b 修复提交博客评论未进行人机验证检验的bug 2024-08-31 14:50:13 +08:00
8ea3a7b25c 完善博客评论功能 2024-08-31 13:38:22 +08:00
29f2c09a69 后端加入Redis、旋转图片验证接口 2024-08-30 21:34:24 +08:00
a69cd3af61 前端加入旋转图像验证组件 2024-08-30 21:33:43 +08:00
458b38b2d9 优化MysqlConnection日志 2024-08-30 21:33:16 +08:00
3b82fbbd1a 加入BlogContent工具栏,完成点赞功能 2024-08-30 16:28:25 +08:00
a5d1842895 添加访问博客次数显示 2024-08-30 16:07:00 +08:00
3f01207c29 BlogList接口返回访问量及点赞量 2024-08-30 12:54:22 +08:00
1da28cd78d 添加pnpm-lock 到gitignore 2024-08-30 12:47:30 +08:00
2a9cc506f0 完成前端BlogContent界面 2024-08-30 12:46:58 +08:00
223fbf39b2 完善BlogContent接口,完善ServerDtdResponse 2024-08-30 12:46:35 +08:00
8a6ee6d98c 后端移除AuthAdmin中间件 2024-08-30 12:46:07 +08:00
39b93997c2 修复Header组件中博客文章和列表显示的问题 2024-08-30 12:45:37 +08:00
3a6e364c70 完成前端Blog界面 2024-08-30 01:09:05 +08:00
87166564fc 加入时间戳转字符串库 2024-08-30 01:08:44 +08:00
2078b0a934 修改主页描述 2024-08-29 22:21:43 +08:00
f7ee30f5dd 完善资源/下载页面 2024-08-29 22:21:32 +08:00
4d45b22592 修复request封装返回数据结构 2024-08-29 22:02:57 +08:00
bc99fba385 重构后端,完善获取资源/下载列表、博客列表接口 2024-08-29 21:59:55 +08:00
324 changed files with 4269 additions and 6529 deletions

View File

@@ -1,129 +0,0 @@
# .gitea/workflows/deploy.yml
name: Deploy to K3s
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: localhost:5000/tiny-ci-runner:latest
env:
IMAGE_TAG: ${{ github.sha }}
KUBECONFIG: /tmp/.kube/config
NODE_ENV: production
steps:
- name: Write kubeconfig
run: |
mkdir -p /tmp/.kube
cat << 'EOF' > /tmp/.kube/config
${{ secrets.KUBECONFIG_DATA }}
EOF
chmod 600 /tmp/.kube/config
- name: Verify Kubernetes access
run: |
kubectl cluster-info
kubectl get nodes
- name: Checkout code
run: |
git clone --depth=1 --branch master \
https://git.tonesc.cn/tone/tonePage.git \
/workspace/tone/tonePage
cd /workspace/tone/tonePage
git log -1 --oneline
- name: Build and push backend image
run: |
cd /workspace/tone/tonePage/apps/backend
docker build -t localhost:5000/backend:${IMAGE_TAG} .
docker push localhost:5000/backend:${IMAGE_TAG}
- name: Build and push frontend image
run: |
cd /workspace/tone/tonePage/apps/frontend
docker build \
--build-arg API_BASE="http://backend-service:3001" \
-t localhost:5000/frontend:${IMAGE_TAG} .
docker push localhost:5000/frontend:${IMAGE_TAG}
- name: Run database migrations with temporary container
run: |
echo "Running database migrations using backend image: localhost:5000/backend:${IMAGE_TAG}"
echo "Waiting for PostgreSQL service to be ready..."
kubectl wait --for=condition=ready pod -l app=postgres --timeout=30s
# 获取密码等敏感信息
DB_PASSWORD=$(kubectl get secret backend-secret -o jsonpath='{.data.DATABASE_PASSWORD}' | base64 -d)
ALIYUN_ACCESS_KEY_ID=$(kubectl get secret backend-secret -o jsonpath='{.data.ALIYUN_ACCESS_KEY_ID}' | base64 -d)
ALIYUN_ACCESS_KEY_SECRET=$(kubectl get secret backend-secret -o jsonpath='{.data.ALIYUN_ACCESS_KEY_SECRET}' | base64 -d)
ALIYUN_OSS_STS_ROLE_ARN=$(kubectl get secret backend-secret -o jsonpath='{.data.ALIYUN_OSS_STS_ROLE_ARN}' | base64 -d)
JWT_SECRET=$(kubectl get secret backend-secret -o jsonpath='{.data.JWT_SECRET}' | base64 -d)
WEBAUTHN_RP_ID=$(kubectl get secret backend-secret -o jsonpath='{.data.WEBAUTHN_RP_ID}' | base64 -d)
WEBAUTHN_ORIGIN=$(kubectl get secret backend-secret -o jsonpath='{.data.WEBAUTHN_ORIGIN}' | base64 -d)
WEBAUTHN_RP_NAME=$(kubectl get secret backend-secret -o jsonpath='{.data.WEBAUTHN_RP_NAME}' | base64 -d)
# 检查是否成功获取了密码
if [ -z "$DB_PASSWORD" ]; then
echo "Error: Could not retrieve DATABASE_PASSWORD from backend-secret."
exit 1
fi
docker run --rm \
-e NODE_ENV=production \
-e DATABASE_HOST=postgres-service \
-e DATABASE_PORT=5432 \
-e DATABASE_NAME=tone_page \
-e DATABASE_USERNAME=tone_page \
-e DATABASE_PASSWORD="$DB_PASSWORD" \
-e JWT_SECRET="$JWT_SECRET" \
-e JWT_EXPIRES_IN=1d \
-e ALIYUN_ACCESS_KEY_ID="$ALIYUN_ACCESS_KEY_ID" \
-e ALIYUN_ACCESS_KEY_SECRET="$ALIYUN_ACCESS_KEY_SECRET" \
-e ALIYUN_OSS_STS_ROLE_ARN="$ALIYUN_OSS_STS_ROLE_ARN" \
-e WEBAUTHN_RP_ID="$WEBAUTHN_RP_ID" \
-e WEBAUTHN_ORIGIN="$WEBAUTHN_ORIGIN" \
-e WEBAUTHN_RP_NAME="$WEBAUTHN_RP_NAME" \
localhost:5000/backend:${IMAGE_TAG} \
pnpm run migration:run
# 检查上一步命令是否成功
if [ $? -ne 0 ]; then
echo "Database migration failed!"
exit 1
fi
echo "Database migrations completed successfully."
- name: Deploy to K3s
run: |
cd /workspace/tone/tonePage/apps/deploy
# 基础资源
kubectl apply -f postgres-deployment.yaml
kubectl apply -f backend-deployment.yaml
kubectl apply -f frontend-deployment.yaml
# 更新镜像(触发滚动更新)
kubectl set image deployment/backend \
backend=localhost:5000/backend:${IMAGE_TAG}
kubectl set image deployment/frontend \
frontend=localhost:5000/frontend:${IMAGE_TAG}
# 等待滚动完成
kubectl rollout status deployment/backend --timeout=120s
kubectl rollout status deployment/frontend --timeout=120s
- name: Post-deploy sanity check
run: |
kubectl get pods
kubectl get svc

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 tonecn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +0,0 @@
node_modules
.next
.git
.env.local

View File

@@ -1,23 +0,0 @@
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN CI=true pnpm prune --prod
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3001
CMD ["node", "dist/main.js"]

View File

@@ -1,42 +0,0 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Resource } from "src/resource/entity/resource.entity";
import { Repository } from "typeorm";
@Injectable()
export class AdminResourceService {
constructor(
@InjectRepository(Resource)
private readonly resourceRepository: Repository<Resource>,
) { }
async findAll() {
return this.resourceRepository.find({
order: {
updatedAt: 'DESC',
}
});
}
async findById(id: string): Promise<Resource> {
return this.resourceRepository.findOne({ where: { id } });
}
async create(data: Partial<Resource>): Promise<Resource> {
const resource = this.resourceRepository.create(data);
return this.resourceRepository.save(resource);
}
async update(data: Partial<Resource>): Promise<Resource> {
// const updateRes = await this.resourceRepository.update(id, data);
// updateRes.affected
// return this.resourceRepository.findOne({ where: { id } });
return this.resourceRepository.save(data);
}
async delete(id: string): Promise<void> {
await this.resourceRepository.delete(id);
}
}

View File

@@ -1,183 +0,0 @@
import {
BadRequestException,
Body,
Controller,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { LoginByPasswordDto } from './dto/login.dto';
import { AuthService } from './auth.service';
import { UserSessionService } from 'src/auth/service/user-session.service';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { UserService } from 'src/user/user.service';
import { AuthGuard } from './guards/auth.guard';
import { SmsLoginDto } from './dto/sms-login.dto';
import { SmsService } from 'src/sms/sms.service';
import { UserSession } from 'src/auth/entity/user-session.entity';
import { PasskeyService } from './service/passkey.service';
import { v4 as uuidv4 } from 'uuid';
import { PasskeyLoginDto } from './dto/passkey-login.dto';
import { AuthUser, CurrentUser } from './decorator/current-user.decorator';
import { PasskeyRegisterDto } from './dto/passkey-register.dto';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly userSessionService: UserSessionService,
private readonly smsService: SmsService,
private readonly passkeyService: PasskeyService,
) { }
private setUserSession(res: Response, session: UserSession) {
res.cookie('session', session.sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
// 永不过期不用设置maxAge
path: '/',
})
}
@Post('login/password')
@UseGuards(ThrottlerGuard)
@Throttle({
'min': { limit: 5, ttl: 60 * 1000 },
'hour': { limit: 20, ttl: 60 * 60 * 1000 },
'day': { limit: 50, ttl: 24 * 60 * 60 * 1000 }
})
async loginByPassword(
@Body() loginDto: LoginByPasswordDto,
@Res({ passthrough: true }) res: Response,
) {
const { identifier, password } = loginDto;
const session = await this.authService.loginWithPassword(identifier, password);
this.setUserSession(res, session);
return {
user: await this.userService.findById(session.userId),
};
}
@Post('login/sms')
@UseGuards(ThrottlerGuard)
@Throttle({
'day': { limit: 50, ttl: 24 * 60 * 60 * 1000 }
})
async loginBySms(
@Body() dto: SmsLoginDto,
@Res({ passthrough: true }) res: Response,
) {
const { phone, code } = dto;
await this.smsService.checkSms(phone, 'login', code);
// 验证通过,(注册并)登陆
const session = await this.authService.loginWithPhone(phone);
this.setUserSession(res, session);
return {
user: await this.userService.findById(session.userId),
}
}
@Post('passkey/login/options')
@UseGuards(ThrottlerGuard)
@Throttle({
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
})
async loginByPasskeyOptions(
@Res({ passthrough: true }) res: Response,
) {
const tempSessionId = uuidv4();
const options = await this.passkeyService.getAuthenticationOptions(tempSessionId);
res.cookie('passkey_temp_session', tempSessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth/passkey/login',
maxAge: 1 * 60 * 1000,
});
return options;
}
@Post('passkey/login')
@UseGuards(ThrottlerGuard)
@Throttle({
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
})
async loginByPasskey(
@Req() req: Request,
@Body() body: PasskeyLoginDto,
@Res({ passthrough: true }) res: Response,
) {
const tempSessionId = req.cookies?.passkey_temp_session;
if (!tempSessionId) {
throw new BadRequestException('登录失败,请重试');
}
try {
const user = await this.passkeyService.login(tempSessionId, body.credentialResponse);
const session = await this.userSessionService.createSession(user.userId);
this.setUserSession(res, session);
return {
user: await this.userService.findById(user.userId),
};
} catch (error) {
throw error;
} finally {
res.clearCookie('passkey_temp_session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth/passkey/login',
});
}
}
@UseGuards(AuthGuard)
@Post('passkey/register/options')
async getPasskeyRegisterOptions(
@CurrentUser() user: AuthUser,
) {
const { userId } = user;
return this.passkeyService.getRegistrationOptions(userId);
}
@UseGuards(AuthGuard)
@Post('passkey/register')
async registerPasskey(
@CurrentUser() user: AuthUser,
@Body() dto: PasskeyRegisterDto,
) {
const { userId } = user;
const { credentialResponse, name } = dto;
const passkey = await this.passkeyService.register(userId, credentialResponse, name.trim());
return {
id: passkey.id,
name: passkey.name,
createdAt: passkey.createdAt,
};
}
@UseGuards(AuthGuard)
@Post('logout')
async logout(@CurrentUser() user: AuthUser, @Res({ passthrough: true }) res: Response) {
const { sessionId } = user;
await this.userSessionService.invalidateSession(sessionId, '用户主动登出');
res.clearCookie('session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
return true;
}
}

View File

@@ -1,28 +0,0 @@
import { forwardRef, Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSession } from 'src/auth/entity/user-session.entity';
import { ConfigModule } from '@nestjs/config';
import { VerificationModule } from 'src/verification/verification.module';
import { AuthGuard } from './guards/auth.guard';
import { OptionalAuthGuard } from './guards/optional-auth.guard';
import { SmsModule } from 'src/sms/sms.module';
import { PasskeyCredential } from './entity/passkey-credential.entity';
import { UserSessionService } from './service/user-session.service';
import { PasskeyService } from './service/passkey.service';
@Module({
imports: [
ConfigModule,
forwardRef(() => UserModule),
TypeOrmModule.forFeature([UserSession, PasskeyCredential]),
VerificationModule,
SmsModule,
],
controllers: [AuthController],
providers: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
exports: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
})
export class AuthModule { }

View File

@@ -1,76 +0,0 @@
import { createHash } from 'crypto';
import { BadRequestException, Injectable } from '@nestjs/common';
import { UserService } from 'src/user/user.service';
import { UserSessionService } from 'src/auth/service/user-session.service';
import { BusinessException } from 'src/common/exceptions/business.exception';
import { ErrorCode } from 'src/common/constants/error-codes';
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly userSessionService: UserSessionService,
) { }
async loginWithPassword(identifier: string, password: string) {
// 依次使用邮箱、手机号、账号登陆(防止有大聪明给账号改成别人的邮箱或手机号)
const user = await this.userService.findOne(
[{ email: identifier }, { phone: identifier }, { username: identifier }],
{
withDeleted: true,
},
);
if (user && user.deletedAt !== null) {
throw new BusinessException({
message: '该账号注销中',
code: ErrorCode.USER_ACCOUNT_DEACTIVATED,
});
}
if (user === null || !user.password_hash || !user.salt) {
throw new BusinessException({
message: '账户或密码错误',
code: ErrorCode.AUTH_INVALID_CREDENTIALS
});
}
// 判断密码是否正确
const hashedPassword = this.hashPassword(password, user.salt);
if (hashedPassword !== user.password_hash) {
throw new BusinessException({
message: '账户或密码错误',
code: ErrorCode.AUTH_INVALID_CREDENTIALS
});
}
const { userId } = user;
return this.userSessionService.createSession(userId);
}
async loginWithPhone(phone: string) {
// 判断用户是否存在,若不存在则进行注册
let user = await this.userService.findOne({ phone }, { withDeleted: true });
if (user && user.deletedAt !== null) {
throw new BadRequestException('该账号注销中,请使用其他手机号');
}
if (!user) {
// 执行注册操作
user = await this.userService.register({ phone });
}
if (!user || !user.userId) {
// 注册失败或用户信息错误
throw new BadRequestException('请求失败,请稍后再试');
}
return this.userSessionService.createSession(user.userId);
}
private hashPassword(password: string, salt: string): string {
return createHash('sha256').update(`${password}${salt}`).digest('hex');
}
}

View File

@@ -1,14 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
export interface AuthUser {
sessionId: string;
userId: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthUser => {
const request = ctx.switchToHttp().getRequest<Request>();
return request.user;
},
);

View File

@@ -1,37 +0,0 @@
import { IsEnum, IsString, Length, ValidateIf } from 'class-validator';
// export class LoginDto {
// @IsEnum(['password', 'phone', 'email'], { message: '请求类型错误' })
// type: 'password' | 'phone' | 'email';
// @ValidateIf((o) => o.type === 'password')
// account?: string;
// @ValidateIf((o) => o.type === 'phone')
// @IsString({ message: '手机号必须输入' })
// @Length(11, 11, { message: '手机号异常' }) // 中国大陆11位数字
// phone?: string;
// @ValidateIf((o) => o.type === 'email')
// @IsString({ message: '邮箱必须输入' })
// @Length(6, 254, { message: '邮箱异常' }) // RFC 5321
// email?: string;
// @ValidateIf((o) => o.type === 'phone' || o.type === 'email')
// @IsString({ message: '验证码必须输入' })
// @Length(6, 6, { message: '验证码异常' }) // 6位数字
// code?: string;
// }
export class LoginByPasswordDto {
@IsString({ message: '账户必须输入' })
@Length(1, 254, { message: '账户异常' }) // 用户名、邮箱、手机号
identifier: string;
@IsString({ message: '密码必须输入' })
@Length(6, 32, { message: '密码异常' }) // 6-32位
password: string;
}

View File

@@ -1,6 +0,0 @@
import { IsObject } from "class-validator";
export class PasskeyLoginDto {
@IsObject()
credentialResponse: any;
}

View File

@@ -1,9 +0,0 @@
import { IsObject, IsString } from "class-validator";
export class PasskeyRegisterDto {
@IsObject()
credentialResponse: any;
@IsString({ message: '通行证名称只能是字符串' })
name: string;
}

View File

@@ -1,13 +0,0 @@
import { IsPhoneNumber, Matches } from "class-validator";
export class SmsLoginDto {
@IsPhoneNumber('CN', {
message: '请输入有效的中国大陆手机号',
})
phone: string;
@Matches(/^\d{6}$/, {
message: '验证码必须是6位数字',
})
code: string;
}

View File

@@ -1,36 +0,0 @@
import { User } from "src/user/entities/user.entity";
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity()
@Index(['user'])
export class PasskeyCredential {
@PrimaryGeneratedColumn('uuid')
id: string;
// 关联用户
@ManyToOne(() => User, user => user.passkeys, { onDelete: 'CASCADE' })
user: User;
// WebAuthn 必需字段
@Column({ length: 255 })
name: string; // 用户自定义名称,如 "iPhone", "工作笔记本"
@Column({ unique: true })
credentialId: string; // Base64URL 编码的 credentialId唯一标识
@Column({ type: 'text' })
publicKey: string; // Base64URL 编码的公钥SPKI 格式)
@Column({ type: 'int' })
signCount: number; // 防重放攻击,每次签名递增
// 是否已验证(注册时验证,登录时更新)
@Column({ default: false })
verified: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -1,34 +0,0 @@
// auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { UserSessionService } from 'src/auth/service/user-session.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private userSessionService: UserSessionService,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
// 从 Cookie 读取 session
const sessionId = request.cookies?.['session'];
if (!sessionId) {
throw new UnauthorizedException('登陆凭证无效,请重新登陆');
}
// 验证 session
const session = await this.userSessionService.getSession(sessionId);
if (!session) {
throw new UnauthorizedException('登陆凭证无效,请重新登陆');
}
const { userId } = session;
request.user = {
sessionId,
userId,
};
return true;
}
}

View File

@@ -1,16 +0,0 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "./auth.guard";
@Injectable()
export class OptionalAuthGuard extends AuthGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return await super.canActivate(context);
} catch (error) {
// 验证失败时req.user = null但允许继续
const request = context.switchToHttp().getRequest();
request.user = null;
return true;
}
}
}

View File

@@ -1,249 +0,0 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { PasskeyCredential } from "../entity/passkey-credential.entity";
import { Repository } from "typeorm";
import { User } from "src/user/entities/user.entity";
import { randomBytes } from 'crypto';
import { generateAuthenticationOptions, GenerateAuthenticationOptionsOpts, generateRegistrationOptions, GenerateRegistrationOptionsOpts, VerifiedAuthenticationResponse, VerifiedRegistrationResponse, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
import { isoBase64URL } from '@simplewebauthn/server/helpers';
interface ChallengeEntry {
value: string;
expiresAt: number;
}
class MemoryChallengeStore {
private store = new Map<string, ChallengeEntry>();
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(private ttlMs: number = 5 * 60 * 100) {
this.startCleanup();
}
set(key: string, value: string): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
});
}
get(key: string): string | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
delete(key: string): void {
this.store.delete(key);
}
private startCleanup(): void {
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(key);
}
}
}, 60_000); // 每分钟清理一次
}
stopCleanup(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
}
const registrationChallenges = new MemoryChallengeStore(5 * 60 * 1000); // 5 分钟过期
const authenticationChallenges = new MemoryChallengeStore(5 * 60 * 1000);
@Injectable()
export class PasskeyService implements OnModuleDestroy {
private readonly rpID: string;
private readonly origin: string;
private readonly rpName: string;
constructor(
@InjectRepository(PasskeyCredential)
private readonly passkeyRepo: Repository<PasskeyCredential>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
this.rpID = process.env.WEBAUTHN_RP_ID;
this.origin = process.env.WEBAUTHN_ORIGIN;
this.rpName = process.env.WEBAUTHN_RP_NAME;
if (!this.rpID || !this.origin || !this.rpName) {
throw new Error('Missing required env: WEBAUTHN_RP_ID or WEBAUTHN_ORIGIN');
}
}
onModuleDestroy() {
registrationChallenges.stopCleanup();
authenticationChallenges.stopCleanup();
}
private generateChallenge(length: number = 32): string {
return randomBytes(length).toString('base64');
}
async getRegistrationOptions(userId: string) {
const user = await this.userRepository.findOneBy({ userId });
if (!user) {
throw new NotFoundException('用户不存在');
}
const challenge = this.generateChallenge();
const opts: GenerateRegistrationOptionsOpts = {
rpName: this.rpName,
rpID: this.rpID,
userID: Buffer.from(userId),
userName: user.username || 'user',
userDisplayName: user.nickname || 'User',
challenge,
authenticatorSelection: {
residentKey: 'required', // 必须是可发现凭证Passkey
userVerification: 'preferred',
},
supportedAlgorithmIDs: [-7], // ES256
timeout: 60000,
};
const options = await generateRegistrationOptions(opts);
registrationChallenges.set(userId, options.challenge)
return options;
}
async register(userId: string, credentialResponse: any, name: string): Promise<PasskeyCredential> {
const expectedChallenge = registrationChallenges.get(userId);
if (!expectedChallenge) {
throw new BadRequestException('注册失败,请重试');
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: credentialResponse,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
requireUserVerification: false,
});
} catch (err) {
throw new BadRequestException('注册失败');
}
if (!verification.verified) {
throw new BadRequestException('注册失败');
}
const { credential } = verification.registrationInfo;
if (!credential) {
throw new InternalServerErrorException('服务器内部错误');
}
// 保存凭证到数据库
const passkey = this.passkeyRepo.create({
user: { userId } as User,
name: name || '新的通行证',
credentialId: credential.id,
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
signCount: credential.counter,
verified: true,
});
await this.passkeyRepo.save(passkey);
registrationChallenges.delete(userId);
return passkey;
}
async getAuthenticationOptions(sessionId: string) {
const challenge = this.generateChallenge();
const opts: GenerateAuthenticationOptionsOpts = {
rpID: this.rpID,
challenge,
timeout: 60000,
userVerification: 'preferred',
};
const options = await generateAuthenticationOptions(opts);
authenticationChallenges.set(sessionId, options.challenge);
return options;
}
async login(sessionId: string, credentialResponse: any): Promise<User> {
const expectedChallenge = authenticationChallenges.get(sessionId);
if (!expectedChallenge) {
throw new BadRequestException('认证失败,请重试');
}
const credentialId = credentialResponse.id;
const passkey = await this.passkeyRepo.findOne({
where: { credentialId, verified: true },
relations: ['user'],
});
if (!passkey) {
throw new NotFoundException('未找到可用的通行证');
}
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
credential: {
id: passkey.credentialId,
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
counter: passkey.signCount,
},
requireUserVerification: false,
});
} catch (err) {
throw new BadRequestException('认证失败');
}
if (!verification.verified) {
throw new BadRequestException('认证失败');
}
const newSignCount = verification.authenticationInfo.newCounter;
if (newSignCount !== passkey.signCount) {
passkey.signCount = newSignCount;
await this.passkeyRepo.save(passkey);
}
authenticationChallenges.delete(sessionId);
return passkey.user;
}
async listUserPasskeys(userId: string): Promise<PasskeyCredential[]> {
return this.passkeyRepo.find({
where: { user: { userId }, verified: true },
select: ['id', 'name', 'createdAt'],
});
}
async removePasskey(userId: string, passkeyId: string): Promise<void> {
const result = await this.passkeyRepo.delete({
id: passkeyId,
user: { userId },
});
if (result.affected === 0) {
throw new NotFoundException('未找到对应的通行证');
}
}
}

View File

@@ -1,39 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { UserSession } from '../entity/user-session.entity';
import { Repository } from 'typeorm';
@Injectable()
export class UserSessionService {
constructor(
@InjectRepository(UserSession)
private readonly userSessionRepository: Repository<UserSession>,
) { }
async createSession(userId: string): Promise<UserSession> {
const session = this.userSessionRepository.create({
userId,
});
return this.userSessionRepository.save(session);
}
async getSession(sessionId: string) {
const session = await this.userSessionRepository.findOne({
where: {
sessionId,
},
});
return session;
}
async invalidateSession(sessionId: string, reason?: string): Promise<void> {
await this.userSessionRepository.update(
{ sessionId, deletedAt: null },
{
deletedAt: new Date(),
disabledReason: reason || null,
}
)
}
}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CaptchaController } from './captcha.controller';
describe('CaptchaController', () => {
let controller: CaptchaController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CaptchaController],
}).compile();
controller = module.get<CaptchaController>(CaptchaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -1,10 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { GetCaptchaDto } from './dto/get-captcha.dto';
@Controller('captcha')
export class CaptchaController {
@Get()
async getCaptcha(dto: GetCaptchaDto) {
}
}

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { CaptchaService } from './captcha.service';
import { CaptchaController } from './captcha.controller';
import { CaptchaRateLimitService } from './service/rate-limit';
@Module({
providers: [CaptchaService, CaptchaRateLimitService],
controllers: [CaptchaController],
imports: [],
})
export class CaptchaModule { }

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CaptchaService } from './captcha.service';
describe('CaptchaService', () => {
let service: CaptchaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CaptchaService],
}).compile();
service = module.get<CaptchaService>(CaptchaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ErrorCode } from 'src/common/constants/error-codes';
import { BusinessException } from 'src/common/exceptions/business.exception';
export enum CaptchaContext {
SEND_SMS = 'send_sms',
PASSKEY = 'passkey',
}
@Injectable()
export class CaptchaService {
public async generate(context: CaptchaContext, ip: string, userId?: string) {
await this.checkRateLimit(ip, context)
}
public async verify(token: string, ip: string, userId?: string) {
}
private async checkRateLimit(ip: string, context: CaptchaContext) {
/** @todo */
throw new BusinessException({
code: ErrorCode.CAPTCHA_RARE_LIMIT,
message: '服务器处理不过来了,过会儿再试试吧',
});
}
}

View File

@@ -1,16 +0,0 @@
import { IsEnum, IsOptional, IsUUID } from "class-validator";
export enum CaptchaContext {
SEND_SMS = 'send_sms',
PASSKEY = 'passkey',
}
export class GetCaptchaDto {
@IsEnum(CaptchaContext, { message: '无效的context' })
context: string;
@IsOptional()
@IsUUID('4', { message: 'userId不合法' })
userId?: string;
}

View File

@@ -1,3 +0,0 @@
export class CaptchaRateLimitService {
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { RolesGuard } from './guard/roles.guard';
import { UserModule } from 'src/user/user.module';
@Module({
providers: [RolesGuard],
imports: [UserModule],
exports: [RolesGuard],
})
export class CommonModule { }

View File

@@ -1,47 +0,0 @@
/**
* 全局业务错误码规范:
* - 每个模块分配一个 1000 起始的段(如 USER: -1000~1999, AUTH: -2000~2999
* - 代码结构:{ 模块名大写 }_{ 错误语义 }
*/
export const ErrorCode = {
// 通用错误0 ~ 999
COMMON_INTERNAL_ERROR: -1,
COMMON_INVALID_PARAM: -2,
COMMON_NOT_FOUND: -3,
// 用户模块1000 ~ 1999
USER_NOT_FOUND: -1001,
USER_ALREADY_EXISTS: -1002,
USER_ACCOUNT_DISABLED: -1003,
USER_FIND_OPTIONS_EMPTY: -1004,
USER_ACCOUNT_DEACTIVATED: -1005,
// 认证模块
AUTH_INVALID_CREDENTIALS: -2001,
AUTH_PASSKEY_NOT_REGISTERED: -2002,
AUTH_SESSION_EXPIRED: -2003,
// 博客模块
BLOG_NOT_FOUND: -3001,
BLOG_PERMISSION_DENIED: -3002,
// 验证模块
CAPTCHA_RARE_LIMIT: -4001,
// 通知模块
NOTIFICATION_SEND_FAILED: -5001,
// Sms模块
SMS_CODE_INCORRECT: -6001,
SMS_CODE_EXPIRED: -6002,
// 资源模块
RESOURCE_UPLOAD_FAILED: -7001,
RESOURCE_NOT_FOUND: -7002,
// 管理员模块
ADMIN_FORBIDDEN: -8001,
} as const;
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];

View File

@@ -1,22 +0,0 @@
import { HttpStatus } from '@nestjs/common';
export class BusinessException {
public statusCode: HttpStatus;
public message: string;
public code: number;
public data: any;
constructor(args: {
statusCode?: HttpStatus,
message?: string,
code?: number,
data?: any,
}) {
const { statusCode, message, code, data } = args;
this.statusCode = statusCode || HttpStatus.BAD_REQUEST;
this.message = message || '请求错误';
this.code = code || -1;
this.data = data || null;
}
}

View File

@@ -1,56 +0,0 @@
import { ArgumentsHost, ExceptionFilter, HttpException, HttpStatus, Logger } from "@nestjs/common";
import { Request, Response } from "express";
import { BusinessException } from "../exceptions/business.exception";
export class GlobalExceptionsFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorResponse = {
success: false,
message: '服务器内部错误',
code: -1,
data: null as any,
};
if (exception instanceof BusinessException) {
statusCode = exception.statusCode;
const { message, code, data } = exception;
errorResponse = {
...errorResponse,
message, code, data,
}
} else if (exception instanceof HttpException) {
// 当HttpException传入类型为string时响应data为nullmessage为传入的string
// 其他请况object/number响应为传入数据message为HttpException的错误码
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse() as Record<string, any>;
if (exceptionResponse.message) {
errorResponse.message = exceptionResponse.message;
} else {
errorResponse.message = '请求失败';
errorResponse.data = exceptionResponse;
}
if (statusCode === HttpStatus.UNAUTHORIZED && request.cookies?.['session']) {
response.clearCookie('session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
}
if (statusCode === HttpStatus.TOO_MANY_REQUESTS) {
errorResponse.message = '请求过于频繁,请稍后再试';
}
} else {
Logger.warn(exception, request.path);
}
response.status(statusCode).json(errorResponse);
}
}

View File

@@ -1,59 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { AuthUser } from 'src/auth/decorator/current-user.decorator';
import { Role } from 'src/auth/role.enum';
import { UserService } from 'src/user/user.service';
@Injectable()
export class RolesGuard implements CanActivate {
private logger = new Logger(RolesGuard.name);
constructor(
private reflector: Reflector,
private readonly userService: UserService,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<Role[] | undefined>(
'roles',
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) return true;
const request = context.switchToHttp().getRequest<Request>();
const authUser = request.user as AuthUser;
if (!authUser) {
this.logger.warn(
`Path: ${request.path} has RolesGuard enabled, but it seems AuthGuard was forgotten.`
)
throw new InternalServerErrorException('服务器内部错误');
}
const { userId } = authUser;
const user = await this.userService.findOne({ userId })
if (!user) {
this.logger.warn(
`UserId: ${user.userId} has a valid login credential, but the user information does not exist.`
)
throw new UnauthorizedException('用户不存在');
}
if (!requiredRoles.some((role) => user.roles.includes(role))) {
throw new ForbiddenException('权限不足');
}
return true;
}
}

View File

@@ -1,9 +0,0 @@
import { AuthUser } from "src/auth/decorator/current-user.decorator";
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}

View File

@@ -1,20 +0,0 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT ?? 5432),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
synchronize: false,
logging: false,
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
});

View File

@@ -1,16 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSlugToBlog1766809565876 implements MigrationInterface {
name = 'AddSlugToBlog1766809565876'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blog" ADD "slug" character varying`);
await queryRunner.query(`ALTER TABLE "blog" ADD CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599" UNIQUE ("slug")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blog" DROP CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599"`);
await queryRunner.query(`ALTER TABLE "blog" DROP COLUMN "slug"`);
}
}

View File

@@ -1,19 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { OssService } from './oss.service';
import { AuthGuard } from 'src/auth/guards/auth.guard';
import { AuthUser, CurrentUser } from 'src/auth/decorator/current-user.decorator';
@Controller('oss')
export class OssController {
constructor(private readonly ossService: OssService) { }
@UseGuards(AuthGuard)
@Get('sts')
async getStsToken(@CurrentUser() user: AuthUser) {
const { userId } = user;
return {
...(await this.ossService.getStsToken(`${userId}`)),
userId,
};
}
}

View File

@@ -1,21 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { PublicResource, Resource } from './entity/resource.entity';
import { InjectRepository } from '@nestjs/typeorm';
@Injectable()
export class ResourceService {
constructor(
@InjectRepository(Resource)
private readonly resourceRepository: Repository<Resource>,
) { }
async findAll(): Promise<PublicResource[]> {
return this.resourceRepository.find({
select: ['id', 'title', 'description', 'imageUrl', 'link', 'tags'],
order: {
updatedAt: 'DESC',
},
});
}
}

View File

@@ -1,6 +0,0 @@
import { IsPhoneNumber } from "class-validator";
export class SendLoginSmsDto {
@IsPhoneNumber('CN', { message: '请输入有效的中国大陆手机号' })
phone: string;
}

View File

@@ -1,30 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
@Entity()
@Index('IDX_SMS_PHONE_TYPE', ['phone', 'type'])
@Index('IDX_SMS_EXPIRED', ['expiredAt'])
export class SmsRecord {
@PrimaryGeneratedColumn('identity')
id: number;
@Column()
phone: string;
@Column()
type: string;
@Column()
code: string;
@Column({ type: 'smallint', default: 0 })
tryCount: number;
@CreateDateColumn({ precision: 3 })
createdAt: Date;
@Column({ type: 'timestamp with time zone', precision: 3 })
expiredAt: Date;
@Column({ type: 'timestamp with time zone', precision: 3, nullable: true })
usedAt: Date;
}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SmsController } from './sms.controller';
describe('SmsController', () => {
let controller: SmsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SmsController],
}).compile();
controller = module.get<SmsController>(SmsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -1,22 +0,0 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { SendLoginSmsDto } from './dto/send-login-sms.dto';
import { SmsService } from './sms.service';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@Controller('sms')
export class SmsController {
constructor(private readonly smsService: SmsService) { }
@Post('send/login')
@UseGuards(ThrottlerGuard)
@Throttle({
'min': { limit: 3, ttl: 60 * 1000 },
'hour': { limit: 10, ttl: 60 * 60 * 1000 },
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
})
async sendLoginSms(@Body() dto: SendLoginSmsDto) {
await this.smsService.sendSms(dto.phone, 'login');
return null;
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
import { SmsController } from './sms.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SmsRecord } from './entity/sms-record.entity';
@Module({
imports: [TypeOrmModule.forFeature([SmsRecord])],
providers: [SmsService],
controllers: [SmsController],
exports: [SmsService],
})
export class SmsModule { }

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SmsService } from './sms.service';
describe('SmsService', () => {
let service: SmsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SmsService],
}).compile();
service = module.get<SmsService>(SmsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,184 +0,0 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import Dypnsapi, * as $Dypnsapi from '@alicloud/dypnsapi20170525';
import * as $OpenApi from '@alicloud/openapi-client';
import { randomInt } from 'crypto';
import { MoreThan, Repository } from 'typeorm';
import { SmsRecord } from './entity/sms-record.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { BusinessException } from 'src/common/exceptions/business.exception';
import { ErrorCode } from 'src/common/constants/error-codes';
const LoginSmsExpiredMin = 5;
const LoginSmsMaxTryCount = 5;
const devMode = process.env.NODE_ENV !== 'production';
@Injectable()
export class SmsService {
private logger = new Logger(SmsService.name);
private client: Dypnsapi;
constructor(
@InjectRepository(SmsRecord)
private readonly smsRecordRepository: Repository<SmsRecord>
) {
const config = new $OpenApi.Config({})
config.accessKeyId = process.env.ALIYUN_ACCESS_KEY_ID;
config.accessKeySecret = process.env.ALIYUN_ACCESS_KEY_SECRET;
this.client = new Dypnsapi(config as any);
}
private generateSmsCode(): string {
// 生成 0 到 999999 的随机整数,补零到 6 位
const code = randomInt(0, 1_000_000);
return code.toString().padStart(6, '0');
}
async checkSendSmsLimit(phone: string, type: string): Promise<void> {
const now = new Date();
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// 1. 检查 1 分钟内是否已发送
const recentRecord = await this.smsRecordRepository.findOne({
where: {
phone,
type,
createdAt: MoreThan(twentyFourHoursAgo),
},
order: { createdAt: 'DESC' },
});
if (recentRecord && recentRecord.createdAt > oneMinuteAgo) {
throw new BusinessException({ message: '操作太快了,稍后再重试吧' }); // 距离上一条不足 1 分钟
}
// 2. 检查 24 小时内是否超过 5 条
const count = await this.smsRecordRepository.count({
where: {
phone,
type,
createdAt: MoreThan(twentyFourHoursAgo),
},
});
if (count >= 5) {
throw new BusinessException({ message: '操作太快了,稍后再重试吧' }); // 24 小时超过 5 条
}
}
async sendSms(phone: string, type: 'login') {
if (type === 'login') {
// 检查限流
await this.checkSendSmsLimit(phone, type);
// 生成
const code = this.generateSmsCode();
const smsRecord = this.smsRecordRepository.create({
phone,
type,
code,
expiredAt: new Date(Date.now() + LoginSmsExpiredMin * 60 * 1000),
});
// 发送
const request = new $Dypnsapi.SendSmsVerifyCodeRequest({});
request.phoneNumber = phone;
request.signName = '速通互联验证码';
request.templateCode = '100001';
request.templateParam = JSON.stringify({
code,
min: `${LoginSmsExpiredMin}`,
})
let success: boolean = false;
if (devMode) {
success = true;
this.logger.debug(`${phone}:${code}`)
} else {
await this.client.sendSmsVerifyCode(request).then(a => {
success = a.body?.success || false;
}, err => {
console.error(err);
});
}
if (success) {
this.smsRecordRepository.save(smsRecord).catch(e => {
this.logger.warn(e, 'sendSms:saveRecord');
})
}
return success;
} else {
throw new InternalServerErrorException('未知的Sms类型');
}
}
async checkSms(phone: string, type: 'login', code: string) {
if (type === 'login') {
const now = new Date();
const record = await this.smsRecordRepository.findOne({
where: {
phone,
type,
expiredAt: MoreThan(now),
},
order: { createdAt: 'DESC' },
});
if (!record) {
throw new BusinessException({
code: ErrorCode.SMS_CODE_EXPIRED,
message: '验证码已失效,请重新获取',
})
}
// 检查被用过没
if (record.usedAt !== null) {
throw new BusinessException({
code: ErrorCode.SMS_CODE_EXPIRED,
message: '验证码已失效,请重新获取',
})
}
// 检查尝试次数
if (record.tryCount >= LoginSmsMaxTryCount) {
throw new BusinessException({
code: ErrorCode.SMS_CODE_EXPIRED,
message: '验证码已失效,请重新获取',
})
}
// 检查是否匹配
if (record.code !== code) {
// 增加尝试次数
record.tryCount = (record.tryCount || 0) + 1;
await this.smsRecordRepository.save(record);
if (record.tryCount >= LoginSmsMaxTryCount) {
throw new BusinessException({
code: ErrorCode.SMS_CODE_EXPIRED,
message: '验证码已失效,请重新获取',
})
}
throw new BusinessException({
code: ErrorCode.SMS_CODE_INCORRECT,
message: '验证码不对的喔~',
})
}
record.usedAt = new Date();
await this.smsRecordRepository.save(record);
} else {
throw new InternalServerErrorException('未知的Sms类型');
}
}
}

View File

@@ -1,25 +0,0 @@
import { Body, Controller, Get, Put, Request, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
import { AuthGuard } from 'src/auth/guards/auth.guard';
import { AuthUser, CurrentUser } from 'src/auth/decorator/current-user.decorator';
@Controller('user')
export class UserController {
constructor(
private readonly userService: UserService,
) { }
@UseGuards(AuthGuard)
@Get('me')
async getMe(@CurrentUser() user: AuthUser) {
return this.userService.findById(user.userId);
}
@UseGuards(AuthGuard)
@Put('password')
async update(@CurrentUser() user: AuthUser, @Body() dto: UpdateUserPasswordDto): Promise<null> {
await this.userService.setPassword(user.userId, dto.password.trim());
return null;
}
}

View File

@@ -1,34 +0,0 @@
import {
BadRequestException,
Body,
Controller,
Post,
UseGuards,
} from '@nestjs/common';
import { SendVerificationCodeDto } from './dto/send-verification-code.dto';
import { VerificationService } from './verification.service';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@Controller('verification')
export class VerificationController {
constructor(private readonly verificationService: VerificationService) { }
// @Post('send')
// @UseGuards(ThrottlerGuard)
// @Throttle({ default: { limit: 20, ttl: 60000 } })
// async sendVerificationCode(@Body() dto: SendVerificationCodeDto) {
// switch (dto.type) {
// case 'login':
// switch (dto.targetType) {
// case 'phone':
// return this.verificationService.sendPhoneCode(dto.phone, dto.type);
// case 'email':
// return this.verificationService.sendEmailCode(dto.email, dto.type);
// default:
// throw new BadRequestException('不支持的目标类型');
// }
// default:
// throw new BadRequestException('不支持的验证码类型');
// }
// }
}

View File

@@ -1,77 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: localhost:5000/backend:latest
ports:
- containerPort: 3001
env:
- name: DATABASE_HOST
value: "postgres-service"
- name: DATABASE_PORT
value: "5432"
- name: DATABASE_NAME
value: "tone_page"
- name: DATABASE_USERNAME
value: "tone_page"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: backend-secret
key: DATABASE_PASSWORD
- name: ALIYUN_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: backend-secret
key: ALIYUN_ACCESS_KEY_ID
- name: ALIYUN_ACCESS_KEY_SECRET
valueFrom:
secretKeyRef:
name: backend-secret
key: ALIYUN_ACCESS_KEY_SECRET
- name: ALIYUN_OSS_STS_ROLE_ARN
valueFrom:
secretKeyRef:
name: backend-secret
key: ALIYUN_OSS_STS_ROLE_ARN
- name: WEBAUTHN_RP_ID
valueFrom:
secretKeyRef:
name: backend-secret
key: WEBAUTHN_RP_ID
- name: WEBAUTHN_ORIGIN
valueFrom:
secretKeyRef:
name: backend-secret
key: WEBAUTHN_ORIGIN
- name: WEBAUTHN_RP_NAME
valueFrom:
secretKeyRef:
name: backend-secret
key: WEBAUTHN_RP_NAME
- name: NODE_ENV
value: "production"
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
spec:
selector:
app: backend
ports:
- protocol: TCP
port: 3001
targetPort: 3001

View File

@@ -1,28 +0,0 @@
apiVersion: batch/v1
kind: Job
metadata:
name: backend-migration
spec:
backoffLimit: 0 # 失败不自动重试(防止重复执行)
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: 192.168.0.200:5000/backend:IMAGE_TAG
imagePullPolicy: Always
command:
- sh
- -c
- |
echo "Running database migrations..."
node ./node_modules/typeorm/cli.js migration:run \
-d dist/data-source.js
envFrom:
# 和 backend Deployment 用同一套
- secretRef:
name: backend-secret
- secretRef:
name: postgres-secret

View File

@@ -1,13 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: backend-secret
type: Opaque
# stringData:
# DATABASE_PASSWORD:
# ALIYUN_ACCESS_KEY_ID:
# ALIYUN_ACCESS_KEY_SECRET:
# ALIYUN_OSS_STS_ROLE_ARN:
# WEBAUTHN_RP_ID:
# WEBAUTHN_ORIGIN:
# WEBAUTHN_RP_NAME:

View File

@@ -1,32 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: localhost:5000/frontend:latest
ports:
- containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: frontend-service
spec:
type: NodePort
ports:
- port: 3000
targetPort: 3000
nodePort: 30000
selector:
app: frontend

View File

@@ -1,42 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:17-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: postgres-secret
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-storage
hostPath:
path: /var/lib/postgres-data
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: postgres-service
spec:
selector:
app: postgres
ports:
- protocol: TCP
port: 5432
targetPort: 5432

View File

@@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: postgres-nodeport
spec:
type: NodePort
ports:
- port: 5432
targetPort: 5432
nodePort: 30001
selector:
app: postgres

View File

@@ -1,9 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
type: Opaque
# stringData:
# POSTGRES_USER:
# POSTGRES_PASSWORD:
# POSTGRES_DB:

View File

@@ -1,33 +0,0 @@
# 安装依赖
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 编译
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG API_BASE
ENV API_BASE=$API_BASE
RUN pnpm run build
# 运行
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ARG API_BASE
ENV API_BASE=$API_BASE
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,41 +0,0 @@
'use client';
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { PhotoProvider, PhotoView } from 'react-photo-view';
import 'react-photo-view/dist/react-photo-view.css';
import rehypeRaw from 'rehype-raw'
import Image from "next/image";
export function BlogContent({ content }: { content?: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
components={{
h1: ({ ...props }) => <h2 className="text-3xl font-bold py-2" {...props} />,
h2: ({ ...props }) => <h3 className="text-2xl font-bold py-1" {...props} />,
h3: ({ ...props }) => <h4 className="text-xl font-bold py-0.5" {...props} />,
h4: ({ ...props }) => <h5 className="text-lg font-bold" {...props} />,
h5: ({ ...props }) => <h6 className="text-md font-bold" {...props} />,
p: ({ ...props }) => <p className="py-1 text-zinc-700" {...props} />,
img: ({ src }) => (
<PhotoProvider className="w-full">
<PhotoView src={src as string}>
<div style={{ width: '100%' }}>
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
</div>
</PhotoView>
</PhotoProvider>
),
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 p-2" {...props} />,
td: ({ ...props }) => <td className="border border-zinc-300 p-1" {...props} />,
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
a: ({ ...props }) => <a className="hover:underline" {...props} />,
}}
>{content}</ReactMarkdown>
)
}

View File

@@ -1,102 +0,0 @@
import { base62 } from "@/lib/utils";
import { BlogContent } from "./BlogContent";
import { BlogAPI } from "@/lib/api/server";
import { handleAPIError } from "@/lib/api/common";
import { BlogComments } from "./components/BlogComments";
interface PageRouteProps {
params: Promise<{ id: string }>
searchParams: Promise<{
[key: string]: string | string[] | undefined;
} | undefined>
}
async function parseBlogParams({ params: paramsPromise, searchParams: searchParamsPromise }: PageRouteProps) {
const params = await paramsPromise ?? {};
const searchParams = await searchParamsPromise ?? {};
if (Array.isArray(searchParams.p)) {
return {
errorMsg: '密码错误或文章不存在'
}
}
if (typeof params.id !== 'string' || params.id.trim() === '') {
return {
errorMsg: '文章不存在或无权限访问'
}
}
return {
id: params.id,
p: searchParams.p,
}
}
async function getBlog(paramsResult: ReturnType<typeof parseBlogParams>) {
const { errorMsg, id, p } = await paramsResult;
if (errorMsg) {
return {
errorMsg,
}
} else {
try {
const data = await BlogAPI.getBlogBySlug(`${id}`, p);
return {
data,
}
} catch (error) {
return {
errorMsg: handleAPIError(error, ({ message }) => message)
}
}
}
}
export async function generateMetadata({ params, searchParams }: PageRouteProps) {
const { errorMsg, data } = await getBlog(parseBlogParams({ params, searchParams }));
if (data) {
return {
title: `${data.title} - 特恩的日志`,
description: `${data.description}`
}
} else {
return {
title: `${errorMsg || '错误'} - 特恩的日志`,
description: `出错啦`
}
}
}
export default async function Page({ params, searchParams }: PageRouteProps) {
const res = await parseBlogParams({ params, searchParams });
const { id, p } = res;
let { errorMsg } = res;
const data = errorMsg ? null
: await BlogAPI.getBlogBySlug(`${id}`, p).catch(e => handleAPIError(e, ({ message }) => { errorMsg = message; return null }));
return (
<div className="w-full overflow-x-hidden">
<div className="max-w-200 mx-auto px-5 overflow-x-hidden mb-10">
{errorMsg && <div className="my-20 text-center text-zinc-600">{errorMsg}</div>}
{data && (
<article className="w-full">
<header className="flex flex-col items-center">
<h1 className="text-center text-2xl sm:text-3xl font-bold mt-10 transition-all duration-500">{data.title}</h1>
<time className="text-sm text-zinc-500 text-center my-2 sm:my-5 mb-5 transition-all duration-500">{new Date(data.createdAt).toLocaleString()}</time>
</header>
<BlogContent content={data.content} />
</article>
)}
{data && (
<>
<div className="border my-5"></div>
<BlogComments blogId={data.id} />
</>
)}
</div>
</div>
)
}

View File

@@ -1,74 +0,0 @@
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { BlogAPI } from "@/lib/api/server";
import { handleAPIError } from "@/lib/api/common";
const formatNumber = (num: number): string => {
if (num >= 1_000_000) {
return (num / 1_000_000).toFixed(1) + 'M';
}
if (num >= 1_000) {
return (num / 1_000).toFixed(1) + 'K';
}
return num.toString();
};
const getBlogDetailUrl = (slug: string): string => {
return `/blog/${slug}`;
};
export const metadata = {
title: '日志 - 特恩的日志',
description: '我随便发点,你也随便看看~',
};
export default async function Blog() {
let errorMsg = '';
const blogs = await BlogAPI.list().catch(e => {
handleAPIError(e, ({ message }) => { errorMsg = message });
return null;
});
return (
<section className="max-w-120 w-auto mx-auto my-10 flex flex-col gap-8">
{
errorMsg && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{errorMsg}
</AlertDescription>
</Alert>
)
}
{
blogs && blogs.map((blog) => (
<article className="w-full px-5 cursor-default" key={blog.id}>
<h2 className="text-2xl font-medium">
<a
className="hover:underline focus:outline-none focus:ring-2 focus:ring-zinc-400 rounded"
href={getBlogDetailUrl(blog.slug)}
rel="noopener noreferrer"
>
{blog.title}
</a>
</h2>
<p className="text-sm font-medium text-zinc-600">{blog.description}</p>
<footer className="mt-3 text-sm text-zinc-500 flex items-center gap-2">
<time dateTime={blog.createdAt}>
{new Date(blog.createdAt).toLocaleString('zh-CN')}
</time>
<span>·</span>
<span>{formatNumber(blog.viewCount)} 访</span>
</footer>
</article>
))
}
</section>
)
}

View File

@@ -1,37 +0,0 @@
import Image from 'next/image';
export default function Home() {
return (
<section className="w-full flex-1 flex flex-col items-center justify-center">
<figure className="flex flex-col items-center">
<Image
src="/avatar.png"
alt="TONE 的个人头像"
width={180}
height={180}
className="rounded-full duration-400 size-35 md:size-45 select-none"
priority
quality={100}
/>
</figure>
<h1 className='text-4xl md:text-5xl font-bold mt-5 md:mt-8 gradient-title duration-400 select-none'>(TONE)</h1>
<p className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 duration-400 select-none'></p>
<nav className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400' aria-label="社交媒体链接">
<a href='https://space.bilibili.com/474156211'
target='_black'
rel="noopener noreferrer"
className='bg-[#488fe9] hover:bg-[#3972ba] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
>
</a>
<a href='https://github.com/tonecn'
target='_black'
rel="noopener noreferrer"
className='bg-[#da843f] hover:bg-[#c87d3e] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
>
GitHub
</a>
</nav>
</section>
);
}

View File

@@ -1,26 +0,0 @@
'use client';
import React from "react";
import Image from "next/image";
interface ResourceCardImage {
imageUrl: string;
}
export default function ResourceCardImage({ imageUrl }: ResourceCardImage) {
const [imageError, setImageError] = React.useState(false);
return (
<>
{!imageError && <Image
src={imageUrl}
alt="资源图片"
width={90}
height={90}
className="rounded-md shadow"
priority
quality={80}
onError={() => setImageError(true)}
/>}
</>
)
}

View File

@@ -1,25 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useUserStore } from '@/store/useUserStore';
import { User } from '@/lib/types/user';
export function ClientProvider({
initialUser,
children,
}: {
initialUser: User | null;
children: React.ReactNode;
}) {
const setUser = useUserStore((state) => state.setUser);
const setInitialized = useUserStore((state) => state.setInitialized);
useEffect(() => {
if (initialUser) {
setUser(initialUser);
}
setInitialized();
}, [initialUser, setUser, setInitialized]);
return <>{children}</>;
}

View File

@@ -1,156 +0,0 @@
"use client"
import * as React from "react"
import {
CloudUpload,
Inbox,
LucideIcon,
Mail,
Server,
SquareTerminal,
Undo2,
UserPen,
UsersRound,
} from "lucide-react"
import { NavMain } from "@/app/console/(with-menu)/components/nav-main"
import { NavUser } from "@/app/console/(with-menu)/components/nav-user"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import Link from "next/link"
import { User } from "@/lib/types/user"
import { Role } from "@/lib/types/role"
export function AppSidebar({ user, ...props }: React.ComponentProps<typeof Sidebar> & { user: User | null }) {
const data = {
navMain: null as null | {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
isHidden?: boolean
items?: {
title: string
url: string
isHidden?: boolean
}[]
}[],
}
data.navMain = [
{
title: "网站管理",
url: "/console/web",
icon: SquareTerminal,
isHidden: !user?.roles.includes(Role.Admin),
items: [
{
title: "资源",
url: "/console/web/resource",
},
{
title: "博客",
url: "/console/web/blog",
},
{
title: "配置",
url: "/console/web/config",
},
],
},
{
title: "用户管理",
url: "/console/user/list",
icon: UsersRound,
isHidden: !user?.roles.includes(Role.Admin),
},
{
title: "邮件系统",
url: "/console/mail",
icon: Mail,
items: [
{
title: "收件箱",
url: "/console/mail/inbox",
},
{
title: "已发送",
url: "/console/mail/sent",
},
{
title: "发送邮件",
url: "/console/mail/send",
},
{
title: "邮件管理",
url: "/console/mail/manage",
isHidden: !user?.roles.includes(Role.Admin),
},
],
},
{
title: "文件存储",
url: "/console/storage",
icon: CloudUpload,
},
{
title: "虚拟云空间",
url: "/console/vspace",
icon: Inbox,
},
{
title: "虚拟主机",
url: "/console/vserver",
icon: Server,
},
{
title: "账户信息",
url: "/console/profile",
icon: UserPen
},
{
title: "前往首页",
url: "/",
icon: Undo2,
},
]
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<Link href="/console">
<SidebarMenuButton size="lg" asChild>
<div className="cursor-pointer">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<SquareTerminal className="size-5" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold"> - </span>
<span className="">v1.0.0</span>
</div>
</div>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -1,215 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet } from "@/components/ui/field";
import { useUserStore } from "@/store/useUserStore";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ReactElement, useState } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { AuthAPI } from "@/lib/api/client";
import { GeneralErrorHandler, handleAPIError } from "@/lib/api/common";
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from "sonner";
export default function Page() {
const userStore = useUserStore();
const user = userStore.user;
return (
<div className="w-full">
<form onSubmit={e => {
e.preventDefault();
}}>
<FieldGroup className="gap-5">
<FieldSet>
<FieldLegend></FieldLegend>
<FieldDescription></FieldDescription>
<FieldGroup className="gap-5">
{
[
{
name: 'username',
localName: '用户名',
required: true,
defaultValue: user?.username,
},
{
name: 'nickname',
localName: '昵称',
required: true,
defaultValue: user?.nickname,
},
{
name: 'email',
localName: '电子邮箱',
required: false,
defaultValue: user?.email,
},
{
name: 'phone',
localName: '手机号',
required: false,
defaultValue: user?.phone,
description: '当前仅支持中国大陆(+86手机号'
},
].map(({ name, localName, required, defaultValue, description }) => (
<Field key={name} className="gap-2">
<FieldLabel htmlFor={`console-profile-${name}`} className="text-zinc-800">
{localName}
</FieldLabel>
<Input
id={`console-profile-${name}`}
name={name}
defaultValue={defaultValue}
placeholder={localName}
required={required}
disabled
/>
{
description && (
<FieldDescription>
{description}
</FieldDescription>
)
}
</Field>
))
}
</FieldGroup>
</FieldSet>
<Field orientation="horizontal">
<Button type="submit" disabled></Button>
<Button variant="outline" type="button" disabled>
</Button>
</Field>
</FieldGroup>
</form>
<FieldSeparator className="mt-2 mb-2" />
<FieldGroup className="gap-5">
<FieldSet>
<FieldLegend></FieldLegend>
<FieldDescription>
PassKey
</FieldDescription>
<PasskeyList />
<div>
<AddPasskeyDialog>
<Button></Button>
</AddPasskeyDialog>
</div>
</FieldSet>
</FieldGroup>
</div>
)
}
interface AddPasskeyDialogProps {
children: ReactElement;
}
function AddPasskeyDialog({ children }: AddPasskeyDialogProps) {
const [open, setOpen] = useState(false);
const handleSubmit = async (name: string) => {
try {
name = name.trim();
if (name.length === 0) {
throw new Error('通行证名称不能为空')
}
const options = await AuthAPI.getPasskeyRegisterOptions();
const credential = await startRegistration({ optionsJSON: options }).catch(() => null);
if (credential === null) {
throw new Error('认证超时');
}
const registerRes = await AuthAPI.passkeyRegister(name, credential);
if (registerRes.id) {
toast.success('添加成功');
setOpen(false);
}
} catch (error) {
console.log(error)
handleAPIError(error, GeneralErrorHandler);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-100">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
😊
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleSubmit(formData.get('name')?.toString() || '');
}}>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="console-add-passkey-name"></Label>
<Input id="console-add-passkey-name" name="name" required />
</div>
</div>
<DialogFooter className="mt-6">
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button type="submit"></Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog >
)
}
function PasskeyList() {
return (
<Table>
{/* <TableCaption>A list of your recent invoices.</TableCaption> */}
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">INV001</TableCell>
<TableCell>Paid</TableCell>
<TableCell>Credit Card</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
</Table>
)
}

View File

@@ -1,32 +0,0 @@
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
import { toast } from "sonner";
export function copyShareURL(data: {
slug: string;
password: string;
permissions: BlogPermission[];
}) {
const slug = data.slug.trim();
const password = data.password.trim();
const permissions = data.permissions;
if (slug.length === 0) {
return toast.warning('请先填写Slug')
}
let url = `${window.location.origin}/blog/${slug}`;
if (permissions.includes(BlogPermission.ByPassword)) {
if (password.length === 0) {
return toast.warning('开启了密码保护但无法获取有效的密码无法生成有效URL')
} else {
url += `?p=${password}`;
}
}
navigator.clipboard.writeText(url).then(() => {
toast.success('复制成功');
}, () => {
toast.error('复制失败,请手动复制');
});
};

View File

@@ -1,5 +0,0 @@
export default function Page() {
return (
<div>config</div>
)
}

View File

@@ -1,67 +0,0 @@
// import { Button } from "@/components/ui/button";
// import { Input } from "@/components/ui/input";
// import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
// import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
// import { useState, useCallback } from "react";
// import { toast } from "sonner";
// import LoginHeader from "./LoginHeader";
// import { SendCodeFormData } from "./types";
// import { Label } from "@/components/ui/label";
// export default function EmailLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
// const [email, setEmail] = useState("");
// const handleSendCode = useCallback(() => {
// if (!email.trim().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
// toast.error('请输入正确的邮箱地址');
// return;
// }
// onSendCode({
// type: 'email',
// email,
// })
// }, [email, onSendCode]);
// return (
// <>
// <LoginHeader />
// <div className="grid gap-3">
// <Label htmlFor="email">电子邮箱</Label>
// <Input
// id="email-login-mode-email"
// name="email"
// type="text"
// placeholder="电子邮箱"
// value={email}
// onChange={(e) => setEmail(e.target.value)}
// required />
// </div>
// <div className="grid gap-3">
// <div className="flex items-center h-4">
// <Label htmlFor="code">验证码</Label>
// </div>
// <div className="flex gap-5">
// <InputOTP
// id="email-login-mode-code"
// name="code"
// maxLength={6}
// pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
// required
// >
// <InputOTPGroup>
// <InputOTPSlot index={0} />
// <InputOTPSlot index={1} />
// <InputOTPSlot index={2} />
// <InputOTPSlot index={3} />
// <InputOTPSlot index={4} />
// <InputOTPSlot index={5} />
// </InputOTPGroup>
// </InputOTP>
// <Button type="button" variant="secondary" onClick={handleSendCode}>获取验证码</Button>
// </div>
// </div>
// <Button type="submit" className="w-full">
// 注册并登录
// </Button>
// </>
// )
// }

View File

@@ -1,17 +0,0 @@
interface LoginHeaderProps {
h1?: string;
h2?: string;
}
export default function LoginHeader({ h1, h2 }: LoginHeaderProps) {
return (
<>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">{h1 || '欢迎回来'}</h1>
<p className="text-muted-foreground text-balance">
{h2 || '登陆到您的账户'}
</p>
</div>
</>
)
}

View File

@@ -1,24 +0,0 @@
import { AuthAPI } from "@/lib/api/client";
import LoginHeader from "./LoginHeader";
import { Button } from "@/components/ui/button";
import { startAuthentication } from "@simplewebauthn/browser";
export default function PasskeyLoginPage() {
return (
<>
<LoginHeader h2="使用通行证登录到您的账户" />
<div className="h-37.5 flex justify-center items-center">
<span className="text-sm text-zinc-500 border rounded-2xl p-5"></span>
</div>
<Button type="submit" className="w-full">
</Button>
</>
)
}
export async function handleSubmit() {
const optionsJSON = await AuthAPI.getLoginByPasskeyOptions();
const credentialResponse = await startAuthentication({ optionsJSON });
return AuthAPI.loginByPasskey(credentialResponse);
}

View File

@@ -1,82 +0,0 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState, useCallback } from "react";
import { toast } from "sonner";
import LoginHeader from "./LoginHeader";
import { Label } from "@/components/ui/label"
import { HumanVerification } from "@/components/human-verification";
import { AuthAPI, SmsAPI } from "@/lib/api/client";
import { handleAPIError } from "@/lib/api/common";
export default function SmsLoginMode() {
const [phone, setPhone] = useState("");
const handleSendCode = useCallback(async () => {
await SmsAPI.sendLoginSms(phone)
.then(() => toast.success('验证码已发送!'))
.catch(e => handleAPIError(e, ({ message }) => toast.error(`${message}`)))
}, [phone]);
return (
<>
<LoginHeader />
<div className="grid gap-3">
<Label htmlFor="phone"></Label>
<Input
id="phone-login-mode-phone"
name="phone"
type="text"
placeholder="+86 手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required />
</div>
<div className="grid gap-3">
<div className="flex items-center h-4">
<Label htmlFor="code"></Label>
</div>
<div className="flex gap-1 overflow-hidden items-center flex-row-reverse">
<HumanVerification onSuccess={handleSendCode} >
<Button type="button" variant="secondary" disabled>
</Button>
</HumanVerification>
<div className="flex-1 min-w-0">
<InputOTP
id="phone-login-mode-code"
name="code"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
required
>
<InputOTPGroup className="w-full flex justify-between">
{[...Array(6)].map((_, i) => (
<InputOTPSlot
key={i}
index={i}
className="flex-1 aspect-square"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled>
</Button>
<div className="hidden" aria-hidden>
oi!
BNB
</div>
</>
)
}
export async function handleSubmit(formData: FormData) {
const phone = formData.get('phone')?.toString() || '';
const code = formData.get('code')?.toString() || '';
return AuthAPI.loginBySms(phone, code)
}

View File

@@ -1,52 +0,0 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "../components/theme-provider";
import { Toaster } from "sonner";
import { UserAPI } from "@/lib/api/server";
import { ClientProvider } from "./ClientProvider";
import { Metadata } from "next";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "特恩的日志",
description: "一名在各个领域反复横跳的程序员",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const user = await UserAPI.me().catch(() => null);
return (
<html lang="zh-CN" suppressHydrationWarning>
<link rel="icon" href="/favicon.ico" />
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
suppressHydrationWarning
>
<ClientProvider initialUser={user}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</ClientProvider>
</body>
</html>
);
}

View File

@@ -1,12 +0,0 @@
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/console',
},
sitemap: 'https://www.tonesc.cn/sitemap.xml',
}
}

View File

@@ -1,42 +0,0 @@
import { BlogAPI } from '@/lib/api/server'
import { MetadataRoute } from 'next'
export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 获取所有博客
const blogs = await BlogAPI.list().catch(() => [])
const blogUrls = blogs.map(blog => {
return {
url: `https://www.tonesc.cn/blog/${blog.slug}`,
lastModified: new Date(blog.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}
})
// 静态页面
const staticUrls = [
{
url: 'https://www.tonesc.cn/',
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 1,
},
{
url: 'https://www.tonesc.cn/blog',
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
{
url: 'https://www.tonesc.cn/resource',
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
]
return [...staticUrls, ...blogUrls]
}

View File

@@ -1,51 +0,0 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Mail } from "lucide-react";
import { Button } from "./ui/button";
const EMAIL = "tonesc.cn@gmail.com";
export default function Footer() {
return (
<footer className="border-t border-zinc-300">
<div className="bg-zinc-50 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all">
{/* 版权与备案信息 */}
<div className="text-center sm:text-left">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-zinc-500 hover:text-zinc-700 hover:underline focus:outline-none focus:underline"
>
ICP备2023009516号-1
</a>
<p className="mt-1 text-sm text-zinc-500">
© {new Date().getFullYear()} TONE Page. All rights reserved.
</p>
</div>
{/* 联系方式弹出框 */}
<address className="not-italic">
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' >
<Mail className="text-zinc-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit">
<a
href={`mailto:${EMAIL}`}
className="text-sm text-zinc-800 hover:underline focus:outline-none focus:underline"
>
{EMAIL}
</a>
</PopoverContent>
</Popover>
</address>
</div>
</footer>
);
}

View File

@@ -1,128 +0,0 @@
'use client';
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { useUserStore } from "@/store/useUserStore";
export default function Header() {
const router = useRouter();
const pathname = usePathname();
const [showMenu, setShowMenu] = useState(false);
const userStore = useUserStore();
const menuItems = [
{ name: '特恩(TONE)', path: '/' },
{ name: '资源', path: '/resource' },
{ name: '博客', path: '/blog' },
{ name: '控制台', path: '/console' },
];
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string) => {
if (path === '/console') {
e.preventDefault();
router.push(userStore.user ? '/console' : '/console/login');
setShowMenu(false);
} else {
setShowMenu(false);
}
}
const menuButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!showMenu && menuButtonRef.current) {
menuButtonRef.current.focus();
}
}, [showMenu]);
return (
<>
<header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 shadow" role="banner" aria-label="网站顶部导航栏">
<div className="flex items-center justify-between px-10 md:h-18 md:px-20 h-14 duration-300" aria-label="主菜单">
<Link
href="/"
className={cn(
"cursor-pointer font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200",
pathname === "/" && "text-zinc-800"
)}
aria-current={pathname === "/" ? "page" : undefined}
>
<span className="sr-only">(TONE)</span>
{pathname === "/"
? <span className="text-2xl" aria-hidden="true" >🍭</span>
: <span className="md:text-lg" aria-hidden="true">(TONE)</span>}
</Link>
<nav className={cn(
"items-center gap-12 hidden sm:flex",
)}>
{menuItems.slice(1).map((item) => (
<Link
key={item.name}
href={item.path}
className={cn(
"cursor-pointer md:text-lg font-medium text-zinc-500 hover:text-zinc-800 border-b-4 border-transparent duration-200",
pathname.startsWith(item.path) && "text-zinc-800 border-b-pink-500"
)}
onClick={e => handleClick(e, item.path)}
aria-current={pathname === item.path ? "page" : undefined}
>
{item.name}
</Link>
))}
</nav>
<button
ref={menuButtonRef}
className="sm:hidden text-zinc-600"
onClick={() => setShowMenu(true)}
aria-label="打开主菜单"
></button>
</div>
</header >
<Drawer direction="right" open={showMenu} onOpenChange={setShowMenu}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex justify-between">
<span></span>
<button
onClick={() => setShowMenu(false)}
aria-label="关闭菜单"
>
<X className="size-5" aria-hidden="true" />
</button>
</DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
<nav className="w-full flex flex-col px-4 gap-2" aria-label="移动设备主菜单">
{menuItems.slice(1).map((item) => (
<Link
key={item.name}
href={item.path}
onClick={e => handleClick(e, item.path)}
aria-current={pathname === item.path ? "page" : undefined}
>
<Button className="w-full" size='lg'
variant={pathname.startsWith(item.path) ? 'default' : 'outline'}
>{item.name}</Button>
</Link>
))}
</nav>
</DrawerContent>
</Drawer>
</>
)
}

View File

@@ -1,53 +0,0 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { ReactNode, useCallback, useEffect, useState } from "react"
interface HumanVerificationProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
onFail?: (reason?: string) => void;
children: ReactNode;
}
export function HumanVerification({ open, onOpenChange, onSuccess, children }: HumanVerificationProps) {
const [i_open, i_setOpen] = useState(false);
const setOpen = useCallback((o: boolean) => {
i_setOpen(o);
onOpenChange?.(o);
}, [onOpenChange]);
useEffect(() => {
if (i_open) {
setOpen(false);
onSuccess?.();
}
}, [i_open, onSuccess, setOpen]);
return (
<Dialog open={open ?? i_open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-80">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
使
</DialogDescription>
</DialogHeader>
<div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,248 +0,0 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -1,41 +0,0 @@
// import { UserAPI } from "@/lib/api/client";
// import useSWR from "swr";
// export function useUserMe({ onError }: { onError?: (e: any) => void } = {}) {
// const isClientSide = typeof window !== 'undefined';
// const { data: user, isLoading, error } = useSWR(
// '/api/user/me',
// async () => {
// if (isClientSide && !localStorage.getItem('token')) {
// throw Object.assign(new Error('未登录'), { statusCode: -1 });
// }
// return UserAPI.me();
// },
// {
// onError: (error) => {
// if (error.statusCode === 401) {
// if (isClientSide) {
// localStorage.removeItem('token');
// }
// }
// onError?.(error);
// },
// revalidateIfStale: false,
// revalidateOnFocus: false,
// shouldRetryOnError: (err) => {
// if ([-1, 401].includes(err.statusCode)) {
// return false;
// }
// return true;
// },
// }
// );
// return {
// user,
// isLoading,
// error
// }
// }

View File

@@ -1,53 +0,0 @@
import { APIResponse, HttpMethod, normalizeAPIError } from './common';
interface ClientFetchRequestOptions extends RequestInit {
method?: HttpMethod;
body?: string;
}
export async function clientFetch<T = unknown>(
endpoint: string,
options: ClientFetchRequestOptions = {}
): Promise<T> {
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json',
};
try {
const response = await fetch(endpoint, {
method: options.method || 'GET',
headers: {
...defaultHeaders,
...options.headers,
},
body: options.body ?? JSON.stringify(options.body),
credentials: 'include',
...options,
});
if (!response.ok) {
const errorText = await response.text();
throw JSON.parse(errorText);
}
const data: APIResponse<T> = await response.json();
if (!data.success) {
throw data;
}
return data.data as T;
} catch (error) {
normalizeAPIError(error);
}
}
export * as AuthAPI from './endpoints/auth.client'
export * as UserAPI from './endpoints/user.client'
export * as SmsAPI from './endpoints/sms.client'
export * as AdminAPI from './endpoints/admin.client'
export * as OSSAPI from './endpoints/oss.client'
export * as BlogAPI from './endpoints/blog.client'

View File

@@ -1,73 +0,0 @@
import { toast } from "sonner";
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface APIResponse<T = unknown> {
success: boolean;
code: number;
message: string;
data: T;
}
export class APIError extends Error {
constructor(
message: string,
public status: number = 400,
public code: number = -1,
public data: unknown = null
) {
super(message);
}
}
export function normalizeAPIError(error: unknown): never {
if (error instanceof APIError) {
throw error;
}
if (error instanceof Error) {
throw new APIError(
error.message || '未知错误',
400,
)
}
if (typeof error === 'object' && error !== null) {
const { message, status, code, data } = {
message: '未知错误',
status: 400,
code: -1,
data: null,
...error
};
throw new APIError(
message,
status,
code,
data
);
}
throw new APIError((error instanceof Error ? `${error.message}` : '') || '未知错误', 400);
}
export function handleAPIError<T>(error: unknown, handler: (e: APIError) => T): T {
if (error instanceof APIError) {
return handler(error);
}
try {
normalizeAPIError(error)
} catch (error) {
if (error instanceof APIError) {
return handler(error);
}
throw error;
}
}
export function GeneralErrorHandler(e: APIError) {
toast.error(`${e.message}`)
}

View File

@@ -1,258 +0,0 @@
import { Resource } from "@/lib/types/resource";
import { clientFetch } from "../client";
import { Blog } from "@/lib/types/blog";
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
import { Role } from "@/lib/types/role";
import { APIError } from "../common";
export interface UserEntity {
userId: string;
username: string;
nickname: string;
email?: string;
phone?: string;
avatar?: string;
createdAt: string;
deletedAt: string | null;
roles: Role[];
}
// ======== Resource ========
export async function listResources() {
return clientFetch<Resource[]>('/api/admin/web/resource')
}
interface CreateResourceParams {
title: string;
description: string;
imageUrl: string;
link: string;
tags: {
name: string;
type: string;
}[];
}
export async function createResource(data: CreateResourceParams) {
data.title = data.title.trim();
data.description = data.description.trim();
data.imageUrl = data.imageUrl.trim();
data.link = data.link.trim();
for (const tag of data.tags) {
tag.name = tag.name.trim();
}
return clientFetch('/api/admin/web/resource', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function getResource(id: string) {
return clientFetch<Resource>(`/api/admin/web/resource/${id}`)
}
export async function removeResource(id: string) {
return clientFetch<void>(`/api/admin/web/resource/${id}`, {
method: 'DELETE',
})
}
interface UpdateResourceParams {
title: string;
description: string;
imageUrl: string;
link: string;
tags: {
name: string;
type: string;
}[];
}
export async function updateResource(id: string, data: UpdateResourceParams) {
data.title = data.title.trim();
data.description = data.description.trim();
data.imageUrl = data.imageUrl.trim();
data.link = data.link.trim();
for (const tag of data.tags) {
tag.name = tag.name.trim();
}
return clientFetch<Resource>(`/api/admin/web/resource/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
// ======== Blog ========
interface CreateBlogParams {
title: string;
description: string;
slug: string;
contentUrl: string;
permissions: BlogPermission[];
password: string;
}
export async function createBlog(data: CreateBlogParams) {
data.title = data.title.trim()
data.description = data.description.trim()
data.slug = data.slug.trim()
data.contentUrl = data.contentUrl.trim()
data.password = data.password.trim()
if (data.title.length === 0) {
throw new APIError('标题不得为空')
}
if (data.description.length === 0) {
throw new APIError('描述不得为空')
}
if (data.slug.length === 0) {
throw new APIError('Slug不得为空')
}
if (data.contentUrl.length === 0) {
throw new APIError('文章URL不得为空')
}
return clientFetch<Blog>('/api/admin/web/blog', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function getBlog(id: string) {
return clientFetch<Blog>(`/api/admin/web/blog/${id}`)
}
export async function listBlogs() {
return clientFetch<Blog[]>('/api/admin/web/blog')
}
export async function removeBlog(id: string) {
// ? Blog
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
method: 'DELETE',
})
}
interface UpdateBlogParams {
title: string;
description: string;
slug: string;
contentUrl: string;
permissions: BlogPermission[],
}
export async function updateBlog(id: string, data: UpdateBlogParams) {
data.title = data.title.trim();
data.description = data.description.trim();
data.slug = data.slug.trim();
data.contentUrl = data.contentUrl.trim();
if (data.title.length === 0) {
throw new APIError('标题不得为空')
}
if (data.description.length === 0) {
throw new APIError('描述不得为空')
}
if (data.slug.length === 0) {
throw new APIError('Slug不得为空')
}
if (data.contentUrl.length === 0) {
throw new APIError('文章URL不得为空')
}
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function setBlogPassword(id: string, password: string) {
password = password.trim();
return clientFetch<boolean>(`/api/admin/web/blog/${id}/password`, {
method: 'POST',
body: JSON.stringify({
password,
})
})
}
// ======== User ========
interface CreateUserParams {
username: string | null;
nickname: string | null;
email: string | null;
phone: string | null;
password: string | null;
}
export async function createUser(data: CreateUserParams) {
type Keys = keyof CreateUserParams;
for (const key in data) {
data[key as Keys] = data[key as Keys]?.trim() || null;
}
return clientFetch<UserEntity>("/api/admin/user", {
method: "POST",
body: JSON.stringify(data),
});
}
export function getUser(id: string) {
return clientFetch<UserEntity>(`/api/admin/user/${id}`);
}
export interface UserListParams {
page?: number
pageSize?: number
}
export interface UserListResponse {
items: UserEntity[],
total: number
page: number
pageSize: number
}
export function listUsers(params?: UserListParams): Promise<UserListResponse> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.pageSize) searchParams.set('pageSize', params.pageSize.toString())
return clientFetch<UserListResponse>('/api/admin/user')
}
export async function removeUser(userId: string, soft: boolean = true) {
/** 我也不知道后端返回的是个啥...没报错就当成功吧 */
return clientFetch<unknown>(`/api/admin/user/${userId}?soft=${soft}`, {
method: 'DELETE',
})
}
export async function setUserPassword(userId: string, password: string) {
password = password.trim();
return clientFetch<void>(`/api/admin/user/${userId}/password`, {
method: 'POST',
body: JSON.stringify({
password,
}),
})
}
export interface updateUser {
username: string;
nickname: string;
email: string | null;
phone: string | null;
}
export async function updateUser(userId: string, user: updateUser) {
user.username = user.username.trim();
user.nickname = user.nickname.trim();
user.email = user.email?.trim() || null;
user.phone = user.phone?.trim() || null;
return clientFetch<UserEntity>(`/api/admin/user/${userId}`, {
body: JSON.stringify(user),
method: "PUT",
});
}

View File

@@ -1,96 +0,0 @@
import { User } from "@/lib/types/user";
import { clientFetch } from "../client";
import { APIError } from "../common";
import { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from "@simplewebauthn/browser";
export async function loginByPassword(identifier: string, password: string) {
identifier = identifier.trim();
password = password.trim();
if (identifier.length === 0 || password.length === 0) {
throw new APIError('请输入账户和密码')
}
if (identifier.length < 1 || identifier.length > 254) {
throw new APIError('账户长度只能为1~254位')
}
if (password.length < 6 || password.length > 32) {
throw new APIError('密码长度只能为6~32位')
}
return clientFetch<{ user: User }>('/api/auth/login/password', {
method: 'POST',
body: JSON.stringify({
identifier,
password,
})
});
}
export async function loginBySms(phone: string, code: string) {
phone = phone.trim();
code = code.trim();
if (phone.length === 0 || code.length === 0) {
throw new APIError('请输入手机号及短信验证码')
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new APIError('请输入合法的中国大陆手机号');
}
if (! /\d{6}/.test(code)) {
throw new APIError('密码长度只能为6~32位')
}
return clientFetch<{ user: User }>('/api/auth/login/sms', {
method: 'POST',
body: JSON.stringify({
phone,
code,
})
});
}
export async function logout() {
return clientFetch('/api/auth/logout', { method: 'POST' });
}
// ======== PassKey ========
export async function getPasskeyRegisterOptions() {
return clientFetch<PublicKeyCredentialCreationOptionsJSON>('/api/auth/passkey/register/options', {
method: 'POST',
});
}
export async function passkeyRegister(name: string, credentialResponse: RegistrationResponseJSON) {
name = name.trim();
if (name.length === 0) {
throw new APIError('通行证名称不得为空');
}
return clientFetch<{ id: string; name: string; createdAt: string }>('/api/auth/passkey/register', {
method: 'POST',
body: JSON.stringify({
name,
credentialResponse,
})
});
}
export async function getLoginByPasskeyOptions() {
return clientFetch<PublicKeyCredentialRequestOptionsJSON>('/api/auth/passkey/login/options', {
method: 'POST',
})
}
/** @lint-ignore */
export async function loginByPasskey(credentialResponse: AuthenticationResponseJSON) {
return clientFetch<{ user: User }>('/api/auth/passkey/login', {
method: 'POST',
body: JSON.stringify({
credentialResponse,
})
})
}

View File

@@ -1,26 +0,0 @@
import { BlogComment } from "@/lib/types/blogComment";
import { clientFetch } from "../client";
export async function getBlog(id: string, password?: string) {
return clientFetch<{
id: string;
title: string;
description: string;
createdAt: string;
content: string;
}>(`/api/blog/${id}` + (password ? `?p=${password}` : ''));
}
export async function getComments(id: string) {
return clientFetch<BlogComment[]>(`/api/blog/${id}/comments`);
}
export async function createComment(blogId: string, content: string, parentId?: string) {
return clientFetch<BlogComment>(`/api/blog/${blogId}/comment`, {
method: 'POST',
body: JSON.stringify({
content,
parentId: parentId || null,
}),
});
}

View File

@@ -1,19 +0,0 @@
import { Blog } from "@/lib/types/blog";
import { serverFetch } from "../server";
export async function list() {
return serverFetch<Pick<Blog,
'id' | 'title' | 'slug' | 'description' | 'viewCount' | 'createdAt' | 'updatedAt'
>[]>('/api/blog')
}
export async function getBlogBySlug(slug: string, password?: string) {
return serverFetch<{
id: string;
title: string;
description: string;
createdAt: string;
content: string;
}>(`/api/blog/${slug}/slug` + (password ? `?p=${password}` : ''));
}

View File

@@ -1,6 +0,0 @@
import { PublicResource } from "@/lib/types/resource";
import { serverFetch } from "../server";
export async function list() {
return serverFetch<PublicResource[]>('/api/resource')
}

View File

@@ -1,15 +0,0 @@
import { clientFetch } from "../client";
import { APIError } from "../common";
export async function sendLoginSms(phone: string) {
phone = phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new APIError('请输入合法的中国大陆手机号');
}
return clientFetch('/api/sms/send/login', {
method: 'POST',
body: JSON.stringify({ phone })
})
}

View File

@@ -1,20 +0,0 @@
import { User } from "@/lib/types/user";
import { clientFetch } from "../client";
import { APIError } from "../common";
export async function me() {
return clientFetch<User>('/api/user/me');
}
export async function updatePassword(password: string) {
if (! /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*()_+\-=\[\]{};:'",.<>/?]{6,32}$/.test(password)) {
throw new APIError('新密码不符合规范,请重新输入')
}
return clientFetch<null>('/api/user/password', {
method: 'PUT',
body: JSON.stringify({
password,
}),
})
}

View File

@@ -1,6 +0,0 @@
import { User } from "@/lib/types/user";
import { serverFetch } from "../server";
export async function me() {
return serverFetch<User>('/api/user/me');
}

View File

@@ -1,60 +0,0 @@
import { cookies, headers } from 'next/headers';
import { APIResponse, HttpMethod, normalizeAPIError } from './common';
interface ServerFetchRequestOptions extends RequestInit {
method?: HttpMethod;
body?: string;
}
export async function serverFetch<T = unknown>(
endpoint: string,
options: ServerFetchRequestOptions = {}
): Promise<T> {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const reqHeaders = new Headers(await headers());
const forwardedHeaders: Record<string, string> = {};
['user-agent', 'x-forwarded-for', 'x-real-ip'].forEach((key) => {
const value = reqHeaders.get(key);
if (value) forwardedHeaders[key] = value;
});
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json',
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...forwardedHeaders,
};
try {
const response = await fetch(new URL(endpoint, process.env.API_BASE).href, {
method: options.method || 'GET',
headers: {
...defaultHeaders,
...options.headers,
},
body: options.body ?? JSON.stringify(options.body),
...options,
});
if (!response.ok) {
const errorText = await response.text();
throw JSON.parse(errorText);
}
const data: APIResponse<T> = await response.json();
if (!data.success) {
throw data;
}
return data.data as T;
} catch (error) {
normalizeAPIError(error);
}
}
export * as BlogAPI from './endpoints/blog.server';
export * as ResourceAPI from './endpoints/resource.server';
export * as UserAPI from './endpoints/user.server';

View File

@@ -1,77 +0,0 @@
{
"name": "tone-page-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@simplewebauthn/browser": "^13.2.2",
"@tailwindcss/postcss": "^4.1.18",
"@types/ali-oss": "^6.16.13",
"add": "^2.0.6",
"alert": "^6.0.2",
"ali-oss": "^6.23.0",
"badge": "^1.0.3",
"base-x": "^5.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dialog": "^0.3.1",
"drawer": "^0.0.2",
"highlight.js": "^11.11.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.503.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"pagination": "^0.4.6",
"popover": "^2.4.1",
"proxy-agent": "^6.5.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"select": "^1.1.2",
"sonner": "^2.0.7",
"swr": "^2.3.7",
"tailwind-merge": "^3.4.0",
"textarea": "^0.3.0",
"vaul": "^1.1.2",
"zod": "^3.25.76",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@tanstack/react-table": "^8.21.3",
"@types/node": "^20.19.26",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/webappsec-credential-management": "^0.6.9",
"eslint": "^9.39.1",
"eslint-config-next": "15.3.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -1,27 +0,0 @@
// store/useUserStore.ts
import { User } from '@/lib/types/user';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UserState {
user: User | null;
isLoading: boolean;
initialized: boolean;
// Actions
setInitialized: () => void;
setUser: (user: User | null) => void;
clearUser: () => void;
}
export const useUserStore = create<UserState>()(
devtools((set, get) => ({
user: null,
isLoading: false,
initialized: false,
setInitialized: () => set({ initialized: true }),
setUser: (user) => set({ user, isLoading: false }),
clearUser: () => set({ user: null }),
}))
);

View File

@@ -17,33 +17,30 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migration:generate": "typeorm migration:generate -d src/data-source.ts",
"migration:run": "typeorm migration:run -d dist/data-source.js",
"migration:revert": "typeorm migration:revert -d dist/data-source.js"
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@alicloud/credentials": "^2.4.3",
"@alicloud/dm20151123": "1.2.6",
"@alicloud/dypnsapi20170525": "^2.0.0",
"@alicloud/dysmsapi20170525": "4.1.0",
"@alicloud/openapi-client": "^0.4.14",
"@alicloud/tea-util": "^1.4.10",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"@simplewebauthn/server": "^13.2.2",
"@types/ali-oss": "^6.16.11",
"ali-oss": "^6.23.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.15.6",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
@@ -54,10 +51,10 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",

View File

@@ -14,9 +14,6 @@ importers:
'@alicloud/dm20151123':
specifier: 1.2.6
version: 1.2.6
'@alicloud/dypnsapi20170525':
specifier: ^2.0.0
version: 2.0.0
'@alicloud/dysmsapi20170525':
specifier: 4.1.0
version: 4.1.0
@@ -35,9 +32,15 @@ importers:
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.17(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^11.0.0
version: 11.0.0(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/mapped-types':
specifier: '*'
version: 2.1.0(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
'@nestjs/passport':
specifier: ^11.0.5
version: 11.0.5(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/platform-express':
specifier: ^10.0.0
version: 10.4.17(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.17)
@@ -47,9 +50,6 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.0
version: 11.0.0(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.22(pg@8.15.6)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@20.17.31)(typescript@5.8.3)))
'@simplewebauthn/server':
specifier: ^13.2.2
version: 13.2.2
'@types/ali-oss':
specifier: ^6.16.11
version: 6.16.11
@@ -62,15 +62,15 @@ importers:
class-validator:
specifier: ^0.14.2
version: 0.14.2
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
dotenv:
specifier: ^17.2.3
version: 17.2.3
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
passport:
specifier: ^0.7.0
version: 0.7.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
pg:
specifier: ^8.15.6
version: 8.15.6
@@ -96,9 +96,6 @@ importers:
'@nestjs/testing':
specifier: ^10.0.0
version: 10.4.17(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.17)(@nestjs/platform-express@10.4.17)
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.1)
'@types/express':
specifier: ^5.0.0
version: 5.0.1
@@ -108,6 +105,9 @@ importers:
'@types/node':
specifier: ^20.3.1
version: 20.17.31
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
'@types/supertest':
specifier: ^6.0.0
version: 6.0.3
@@ -159,9 +159,6 @@ packages:
'@alicloud/credentials@2.4.3':
resolution: {integrity: sha512-r2thNtthchTz/c8/HryGSey1vY0UZx2FkAvb+vd+j7xhD/v/KUwnp8RJNQKNG3E4kfs4wSx2bgDSkcPAiXHQLQ==}
'@alicloud/credentials@2.4.4':
resolution: {integrity: sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==}
'@alicloud/darabonba-array@0.1.0':
resolution: {integrity: sha512-y4oM4O2uXiroUjfWBLEXRHMm1279rWpkWWNalF7DFQyO5awJ/e0d631prU4i10ytKzo8XJd12eCHmm3IOW85+g==}
@@ -183,9 +180,6 @@ packages:
'@alicloud/dm20151123@1.2.6':
resolution: {integrity: sha512-6pYgy0D5zmUoxfRYwj0ysX4WPw8IfGimaw3ORFj6hF6lTxWpJ3tteOD72i8rw764eZ78TRc4UyET3U9qCaBeaA==}
'@alicloud/dypnsapi20170525@2.0.0':
resolution: {integrity: sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg==}
'@alicloud/dysmsapi20170525@4.1.0':
resolution: {integrity: sha512-oUmRp6DTI6gGNbrSQK4lW7EouHIB4C0DCbSEA121NvxHC9XKe4cqiPP2VDqgDQiIK43oiFaHKY3rj+IteOWekA==}
@@ -431,9 +425,6 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -549,9 +540,6 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@ljharb/through@2.3.14':
resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==}
engines: {node: '>= 0.4'}
@@ -609,6 +597,11 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/jwt@11.0.0':
resolution: {integrity: sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/mapped-types@2.1.0':
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
peerDependencies:
@@ -622,6 +615,12 @@ packages:
class-validator:
optional: true
'@nestjs/passport@11.0.5':
resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
passport: ^0.5.0 || ^0.6.0 || ^0.7.0
'@nestjs/platform-express@10.4.17':
resolution: {integrity: sha512-ovn4Wxney3QGBrqNPv0QLcCuH5QoAi6pb/GNWAz6B/NmBjZbs9/zl4a2beGDA2SaYre9w43YbfmHTm17PneP9w==}
peerDependencies:
@@ -686,43 +685,6 @@ packages:
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
'@peculiar/asn1-android@2.6.0':
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
'@peculiar/asn1-cms@2.6.0':
resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==}
'@peculiar/asn1-csr@2.6.0':
resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==}
'@peculiar/asn1-ecc@2.6.0':
resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==}
'@peculiar/asn1-pfx@2.6.0':
resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==}
'@peculiar/asn1-pkcs8@2.6.0':
resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==}
'@peculiar/asn1-pkcs9@2.6.0':
resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==}
'@peculiar/asn1-rsa@2.6.0':
resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==}
'@peculiar/asn1-schema@2.6.0':
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
'@peculiar/asn1-x509-attr@2.6.0':
resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==}
'@peculiar/asn1-x509@2.6.0':
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
'@peculiar/x509@1.14.2':
resolution: {integrity: sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==}
engines: {node: '>=22.0.0'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -731,10 +693,6 @@ packages:
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@simplewebauthn/server@13.2.2':
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
engines: {node: '>=20.0.0'}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -787,11 +745,6 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@@ -831,6 +784,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.7':
resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@@ -846,6 +802,15 @@ packages:
'@types/node@22.15.14':
resolution: {integrity: sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==}
'@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
'@types/passport-strategy@0.2.38':
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/qs@6.9.18':
resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==}
@@ -1101,10 +1066,6 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1js@3.0.7:
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
engines: {node: '>=12.0.0'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@@ -1345,10 +1306,6 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
@@ -1356,10 +1313,6 @@ packages:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
@@ -1486,10 +1439,6 @@ packages:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -2560,6 +2509,17 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
passport@0.7.0:
resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==}
engines: {node: '>= 0.4.0'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -2592,6 +2552,9 @@ packages:
pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
peek-readable@7.0.0:
resolution: {integrity: sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==}
engines: {node: '>=18'}
@@ -2710,13 +2673,6 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@@ -3170,16 +3126,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3462,15 +3411,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@alicloud/credentials@2.4.4':
dependencies:
'@alicloud/tea-typescript': 1.8.0
httpx: 2.3.3
ini: 1.3.8
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-array@0.1.0':
dependencies:
'@alicloud/tea-typescript': 1.8.0
@@ -3513,13 +3453,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@alicloud/dypnsapi20170525@2.0.0':
dependencies:
'@alicloud/openapi-core': 1.0.4
'@darabonba/typescript': 1.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/dysmsapi20170525@4.1.0':
dependencies:
'@alicloud/openapi-core': 1.0.4
@@ -3536,7 +3469,7 @@ snapshots:
'@alicloud/gateway-pop@0.0.6':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/credentials': 2.4.3
'@alicloud/darabonba-array': 0.1.0
'@alicloud/darabonba-encode-util': 0.0.2
'@alicloud/darabonba-map': 0.0.1
@@ -3570,7 +3503,7 @@ snapshots:
'@alicloud/openapi-core@1.0.4':
dependencies:
'@alicloud/credentials': 2.4.4
'@alicloud/credentials': 2.4.3
'@alicloud/gateway-pop': 0.0.6
'@alicloud/gateway-spi': 0.0.8
'@darabonba/typescript': 1.0.3
@@ -3881,8 +3814,6 @@ snapshots:
'@eslint/js@8.57.1': {}
'@hexagon/base64@1.1.28': {}
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -4103,8 +4034,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@levischuck/tiny-cbor@0.2.11': {}
'@ljharb/through@2.3.14':
dependencies:
call-bind: 1.0.8
@@ -4175,6 +4104,12 @@ snapshots:
transitivePeerDependencies:
- encoding
'@nestjs/jwt@11.0.0(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@types/jsonwebtoken': 9.0.7
jsonwebtoken: 9.0.2
'@nestjs/mapped-types@2.1.0(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -4183,6 +4118,11 @@ snapshots:
class-transformer: 0.5.1
class-validator: 0.14.2
'@nestjs/passport@11.0.5(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)':
dependencies:
'@nestjs/common': 10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
passport: 0.7.0
'@nestjs/platform-express@10.4.17(@nestjs/common@10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.17)':
dependencies:
'@nestjs/common': 10.4.17(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -4265,118 +4205,11 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@peculiar/asn1-android@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-cms@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
'@peculiar/asn1-x509-attr': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-csr@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-ecc@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pfx@2.6.0':
dependencies:
'@peculiar/asn1-cms': 2.6.0
'@peculiar/asn1-pkcs8': 2.6.0
'@peculiar/asn1-rsa': 2.6.0
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.6.0':
dependencies:
'@peculiar/asn1-cms': 2.6.0
'@peculiar/asn1-pfx': 2.6.0
'@peculiar/asn1-pkcs8': 2.6.0
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
'@peculiar/asn1-x509-attr': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-rsa@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-schema@2.6.0':
dependencies:
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-x509@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.2':
dependencies:
'@peculiar/asn1-cms': 2.6.0
'@peculiar/asn1-csr': 2.6.0
'@peculiar/asn1-ecc': 2.6.0
'@peculiar/asn1-pkcs9': 2.6.0
'@peculiar/asn1-rsa': 2.6.0
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.2.4': {}
'@simplewebauthn/server@13.2.2':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.6.0
'@peculiar/asn1-ecc': 2.6.0
'@peculiar/asn1-rsa': 2.6.0
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.0
'@peculiar/x509': 1.14.2
'@sinclair/typebox@0.27.8': {}
'@sinonjs/commons@3.0.1':
@@ -4439,10 +4272,6 @@ snapshots:
dependencies:
'@types/node': 20.17.31
'@types/cookie-parser@1.4.10(@types/express@5.0.1)':
dependencies:
'@types/express': 5.0.1
'@types/cookiejar@2.1.5': {}
'@types/eslint-scope@3.7.7':
@@ -4493,6 +4322,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.7':
dependencies:
'@types/node': 20.17.31
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {}
@@ -4507,6 +4340,20 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/passport-jwt@4.0.1':
dependencies:
'@types/jsonwebtoken': 9.0.7
'@types/passport-strategy': 0.2.38
'@types/passport-strategy@0.2.38':
dependencies:
'@types/express': 5.0.1
'@types/passport': 1.0.17
'@types/passport@1.0.17':
dependencies:
'@types/express': 5.0.1
'@types/qs@6.9.18': {}
'@types/range-parser@1.2.7': {}
@@ -4842,12 +4689,6 @@ snapshots:
asap@2.0.6: {}
asn1js@3.0.7:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
async@3.2.6: {}
asynckit@0.4.0: {}
@@ -5121,17 +4962,10 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie@0.7.1: {}
cookie@0.7.2: {}
cookiejar@2.1.4: {}
copy-to@2.0.1: {}
@@ -5236,8 +5070,6 @@ snapshots:
dotenv@16.4.7: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -6549,6 +6381,19 @@ snapshots:
parseurl@1.3.3: {}
passport-jwt@4.0.1:
dependencies:
jsonwebtoken: 9.0.2
passport-strategy: 1.0.0
passport-strategy@1.0.0: {}
passport@0.7.0:
dependencies:
passport-strategy: 1.0.0
pause: 0.0.1
utils-merge: 1.0.1
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@@ -6572,6 +6417,8 @@ snapshots:
dependencies:
through: 2.3.8
pause@0.0.1: {}
peek-readable@7.0.0: {}
pg-cloudflare@1.2.5:
@@ -6670,12 +6517,6 @@ snapshots:
pure-rand@6.1.0: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
qs@6.13.0:
dependencies:
side-channel: 1.1.0
@@ -7142,14 +6983,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View File

@@ -8,19 +8,13 @@ import { AdminWebResourceController } from './controller/web/admin-web-resource.
import { AdminWebBlogController } from './controller/web/admin-web-blog.controller';
import { ResourceModule } from 'src/resource/resource.module';
import { BlogModule } from 'src/blog/blog.module';
import { AuthModule } from 'src/auth/auth.module';
import { AdminResourceService } from './services/admin.resource.service';
@Module({
providers: [
AdminResourceService,
],
imports: [
TypeOrmModule.forFeature([User]),
UserModule,
ResourceModule,
BlogModule,
AuthModule,
],
controllers: [
AdminController,

View File

@@ -19,13 +19,13 @@ import { RemoveUserDto } from '../dto/admin-user/remove.dto';
import { RolesGuard } from 'src/common/guard/roles.guard';
import { Roles } from 'src/common/decorators/role.decorator';
import { Role } from 'src/auth/role.enum';
import { AuthGuard } from 'src/auth/guards/auth.guard';
import { AuthGuard } from '@nestjs/passport';
@Controller('admin/user')
@UseGuards(AuthGuard, RolesGuard)
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(Role.Admin)
export class AdminUserController {
constructor(private readonly userService: UserService) { }
constructor(private readonly userService: UserService) {}
@Get()
async list(@Query() listDto: ListDto) {
@@ -41,7 +41,7 @@ export class AdminUserController {
@Post()
async create(@Body() createDto: CreateDto) {
return this.userService.register({
return this.userService.create({
...createDto,
...(createDto.password &&
(() => {

Some files were not shown because too many files have changed in this diff Show More