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`的说明详见下方的“注意事项”。
### 运行方式