From 726194f012e0c550ec1e1190cb3781bf176dd20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=20=E6=9D=8E?= <2663818483@qq.com> Date: Fri, 12 Sep 2025 18:40:46 +0800 Subject: [PATCH] feat: refactor email service and add password reset functionality --- backend/.local.env.template | 16 ------- backend/src/email.ts | 2 +- backend/src/file.ts | 28 +++++++++++ backend/src/user.ts | 91 ++++++++++++++++++++++++++++++++++++ database/.local.env.template | 2 - tutorials/04-Backend.md | 2 + 6 files changed, 122 insertions(+), 19 deletions(-) delete mode 100644 backend/.local.env.template delete mode 100644 database/.local.env.template diff --git a/backend/.local.env.template b/backend/.local.env.template deleted file mode 100644 index 3c70b36..0000000 --- a/backend/.local.env.template +++ /dev/null @@ -1,16 +0,0 @@ -HASURA_GRAPHQL_ENDPOINT=
:/v1/graphql -HASURA_GRAPHQL_ADMIN_SECRET= - -JWT_SECRET= - -EMAIL_HOST=smtp.163.com -EMAIL_PORT=465 -EMAIL_SECURE=true -EMAIL_ADDRESS= -EMAIL_PASSWORD= - -(以下备注请在生产环境中删除) -注:不同邮箱提供商的配置方法不同,请参考对应的文档或邮箱设置; -- 部分邮箱需要手动开启 SMTP 服务; -- 部分邮箱的密码(包括清华邮箱)填的是设备授权码,而不是账号密码; -- 部分邮箱需要 OAuth2 或其他身份验证,请参考 https://nodemailer.com/smtp/oauth2/ diff --git a/backend/src/email.ts b/backend/src/email.ts index 4c19628..a2fa72d 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -3,7 +3,7 @@ import nodemailer from "nodemailer"; const router = express.Router(); -const sendEmail = async (to: string, subject: string, text: string) => { +export const sendEmail = async (to: string, subject: string, text: string) => { const transporter = nodemailer.createTransport({ host: process.env.EMAIL_HOST!, port: Number(process.env.EMAIL_PORT!), diff --git a/backend/src/file.ts b/backend/src/file.ts index 7e77582..3a16769 100644 --- a/backend/src/file.ts +++ b/backend/src/file.ts @@ -3,6 +3,7 @@ import multer from "multer"; import fs from "fs"; import path from "path"; import authenticate from "./authenticate"; +import { sdk as graphql } from "./index"; const router = express.Router(); @@ -74,4 +75,31 @@ router.get("/download", authenticate, (req, res) => { } }); +router.post("/delete", authenticate, (req, res) => { + const { room, filename } = req.body; + if (!room || !filename) { + return res.status(422).send("422 Unprocessable Entity: Missing room or filename"); + } + const filePath = path.resolve(baseDir, room as string, filename as string); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } else { + return res.status(404).send("404 Not Found: File does not exist"); + } + // Optionally remove DB references if schema tracks files. Attempt safe call. + try { + if ((graphql as any).delete_message) { + // no-op: placeholder for custom deletion logic if needed + } + } catch (err) { + console.error("GraphQL cleanup failed", err); + } + return res.sendStatus(200); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/backend/src/user.ts b/backend/src/user.ts index a93bd91..243771b 100644 --- a/backend/src/user.ts +++ b/backend/src/user.ts @@ -1,6 +1,9 @@ import express from "express"; import jwt from "jsonwebtoken"; import { sdk as graphql } from "./index"; +import { GraphQLClient } from "graphql-request"; +import { sendEmail } from "./email"; +import authenticate from "./authenticate"; interface userJWTPayload { uuid: string; @@ -71,4 +74,92 @@ router.post("/register", async (req, res) => { } }); +router.post("/change-password/request", async (req, res) => { + const { username } = req.body; + if (!username) { + return res.status(422).send("422 Unprocessable Entity: Missing username"); + } + try { + const queryResult = await graphql.getUsersByUsername({ username: username }); + if (queryResult.user.length === 0) { + // Do not reveal whether user exists to avoid user enumeration + return res.sendStatus(200); + } + const user = queryResult.user[0]; + const payload = { uuid: user.uuid, username }; + const token = jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: "1h" }); + const site = process.env.SITE_URL || `http://localhost:8888`; + const resetLink = `${site}/user/change-password?action=token&token=${token}`; + const text = `You (or someone else) requested a password reset. Use the link below to reset your password (valid 1 hour):\n\n${resetLink}`; + try { + await sendEmail(username, "Password reset request", text); + return res.sendStatus(200); + } catch (err) { + console.error("Failed to send reset email", err); + return res.sendStatus(500); + } + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + +router.post("/change-password/action", async (req, res) => { + const { token, newPassword } = req.body; + if (!token || !newPassword) { + return res.status(422).send("422 Unprocessable Entity: Missing token or newPassword"); + } + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET! as string) as any; + if (!decoded || !decoded.uuid) { + return res.status(401).send("401 Unauthorized: Invalid token"); + } + const userUuid = decoded.uuid; + try { + const client = new GraphQLClient(process.env.HASURA_GRAPHQL_ENDPOINT!, { + headers: { + "Content-Type": "application/json", + "x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET || "", + }, + }); + const mutation = `mutation updateUserPassword($uuid: uuid!, $password: String!) {\n update_user_by_pk(pk_columns: {uuid: $uuid}, _set: {password: $password}) { uuid }\n}`; + await client.request(mutation, { uuid: userUuid, password: newPassword }); + return res.sendStatus(200); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } + } catch (err) { + console.error(err); + return res.status(401).send("401 Unauthorized: Token expired or invalid"); + } +}); + +router.get("/delete", authenticate, async (req, res) => { + // Expect Authorization header handled by authenticate middleware + const authHeader = req.get("Authorization")!; + const token = authHeader.substring(7); + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET! as string) as any; + const userUuid = decoded.uuid; + try { + const client = new GraphQLClient(process.env.HASURA_GRAPHQL_ENDPOINT!, { + headers: { + "Content-Type": "application/json", + "x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET || "", + }, + }); + const mutation = `mutation deleteUserByPk($uuid: uuid!) {\n delete_user_by_pk(uuid: $uuid) { uuid }\n}`; + await client.request(mutation, { uuid: userUuid }); + return res.sendStatus(200); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } + } catch (err) { + console.error(err); + return res.status(401).send("401 Unauthorized: Invalid token"); + } +}); + export default router; diff --git a/database/.local.env.template b/database/.local.env.template deleted file mode 100644 index 89074f3..0000000 --- a/database/.local.env.template +++ /dev/null @@ -1,2 +0,0 @@ -HASURA_GRAPHQL_ENDPOINT=
:/v1/graphql -HASURA_GRAPHQL_ADMIN_SECRET= diff --git a/tutorials/04-Backend.md b/tutorials/04-Backend.md index 233ae05..db33b94 100644 --- a/tutorials/04-Backend.md +++ b/tutorials/04-Backend.md @@ -43,6 +43,8 @@ yarn # 是 yarn install 的简写 大家需要根据自己情况填写变量值,删去多余注释,并将`.local.env.template`更名为`.local.env`(或新建文件复制过去),即可被项目正确识别。 +> 注意:切勿将 `.local.env` 提交到 Git。仓库中已提供一个快速检查脚本 `backend/scripts/check_env.ps1`,可在 `backend` 目录运行以验证必需变量已正确设置。 + 此外,有关`JWT_SECRET`的说明详见下方的“注意事项”。 ### 运行方式