diff --git a/backend/src/authenticate.ts b/backend/src/authenticate.ts index 676643c..531a6e0 100644 --- a/backend/src/authenticate.ts +++ b/backend/src/authenticate.ts @@ -1,19 +1,42 @@ import { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const authenticate: (req: Request, res: Response, next: NextFunction) => Response | void = (req, res, next) => { const authHeader = req.get("Authorization"); if (!authHeader) { return res.status(401).send("401 Unauthorized: Missing Token"); } - const token = authHeader.substring(7); - return jwt.verify(token, process.env.JWT_SECRET!, async (err, decoded) => { - if (err || !decoded) { - return res.status(401).send("401 Unauthorized: Token expired or invalid"); + + const match = /^Bearer\s+(\S+)$/i.exec(authHeader); + if (!match) { + return res.status(401).send("401 Unauthorized: Malformed Authorization header"); + } + + const secret = process.env.JWT_SECRET; + if (!secret) { + console.error("JWT_SECRET is not configured"); + return res.sendStatus(500); + } + + try { + const decoded = jwt.verify(match[1], secret); + if ( + typeof decoded === "string" || + typeof decoded.uuid !== "string" || + !UUID_PATTERN.test(decoded.uuid) + ) { + return res.status(401).send("401 Unauthorized: Token payload is invalid"); } + + res.locals.userUuid = decoded.uuid; return next(); - }); + } catch (_err) { + return res.status(401).send("401 Unauthorized: Token expired or invalid"); + } }; export default authenticate; diff --git a/backend/src/file.ts b/backend/src/file.ts index 7e77582..52d4695 100644 --- a/backend/src/file.ts +++ b/backend/src/file.ts @@ -3,11 +3,24 @@ 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(); const baseDir = process.env.FILE_DIR || path.resolve(process.cwd(), "upload"); +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const isSafeFilename = (filename: string) => + filename.length > 0 && + filename.trim().length > 0 && + filename !== "." && + filename !== ".." && + filename === path.basename(filename) && + filename === path.win32.basename(filename) && + !filename.includes("\0"); + const limits = { parts: 2, // 1 file and 0 fields fileSize: 10 * 1024 * 1024, // 10 MB @@ -74,4 +87,48 @@ router.get("/download", authenticate, (req, res) => { } }); +router.post("/delete", authenticate, async (req, res) => { + const { room, filename } = req.body ?? {}; + if (typeof room !== "string" || typeof filename !== "string") { + return res.status(422).send("422 Unprocessable Entity: Missing room or filename"); + } + if (!UUID_PATTERN.test(room)) { + return res.status(400).send("400 Bad Request: Invalid room UUID"); + } + if (!isSafeFilename(filename)) { + return res.status(400).send("400 Bad Request: Invalid filename"); + } + + try { + const joinedRooms = await graphql.getJoinedRooms({ + user_uuid: res.locals.userUuid as string, + }); + const isMember = joinedRooms.user_room.some( + (userRoom) => userRoom.room.uuid === room, + ); + if (!isMember) { + return res.status(403).send("403 Forbidden: User is not a member of this room"); + } + + const roomDir = path.resolve(baseDir, room); + const filePath = path.resolve(roomDir, filename); + if (!filePath.startsWith(`${roomDir}${path.sep}`)) { + return res.status(400).send("400 Bad Request: Invalid filename"); + } + + const stat = await fs.promises.lstat(filePath); + if (!stat.isFile()) { + return res.status(400).send("400 Bad Request: Target is not a regular file"); + } + await fs.promises.unlink(filePath); + return res.status(200).send("File deleted successfully"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return res.status(404).send("404 Not Found: File does not exist"); + } + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/backend/src/graphql.ts b/backend/src/graphql.ts index 8e65cb6..4d1a34c 100644 --- a/backend/src/graphql.ts +++ b/backend/src/graphql.ts @@ -1554,6 +1554,13 @@ export type GetUsersByUsernameQueryVariables = Exact<{ export type GetUsersByUsernameQuery = { __typename?: 'query_root', user: Array<{ __typename?: 'user', uuid: any, password: string }> }; +export type DeleteUserMutationVariables = Exact<{ + uuid: Scalars['uuid']['input']; +}>; + + +export type DeleteUserMutation = { __typename?: 'mutation_root', delete_user_by_pk?: { __typename?: 'user', uuid: any } | null }; + export const AddMessageDocument = gql` mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { @@ -1627,6 +1634,13 @@ export const GetUsersByUsernameDocument = gql` } } `; +export const DeleteUserDocument = gql` + mutation deleteUser($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} + `; export type SdkFunctionWrapper = (action: (requestHeaders?:Record) => Promise, operationName: string, operationType?: string, variables?: any) => Promise; @@ -1658,6 +1672,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = }, getUsersByUsername(variables: GetUsersByUsernameQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { return withWrapper((wrappedRequestHeaders) => client.request(GetUsersByUsernameDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getUsersByUsername', 'query', variables); + }, + deleteUser(variables: DeleteUserMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(DeleteUserDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'deleteUser', 'mutation', variables); } }; } diff --git a/backend/src/user.ts b/backend/src/user.ts index a93bd91..637da02 100644 --- a/backend/src/user.ts +++ b/backend/src/user.ts @@ -1,6 +1,7 @@ import express from "express"; import jwt from "jsonwebtoken"; import { sdk as graphql } from "./index"; +import authenticate from "./authenticate"; interface userJWTPayload { uuid: string; @@ -71,4 +72,19 @@ router.post("/register", async (req, res) => { } }); +router.get("/delete", authenticate, async (_req, res) => { + const uuid = res.locals.userUuid as string; + + try { + const mutationResult = await graphql.deleteUser({ uuid }); + if (!mutationResult.delete_user_by_pk) { + return res.status(404).send("404 Not Found: User does not exist"); + } + return res.status(200).send("User deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/database/graphql/user.graphql b/database/graphql/user.graphql index d7780cd..6123b4c 100644 --- a/database/graphql/user.graphql +++ b/database/graphql/user.graphql @@ -10,3 +10,9 @@ query getUsersByUsername($username: String!) { password } } + +mutation deleteUser($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} diff --git a/frontend/package.json b/frontend/package.json index b832b0d..28fb362 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,7 @@ { "homepage": "./", "dependencies": { + "@ant-design/pro-form": "2.29.0", "@ant-design/pro-components": "2.7.15", "@apollo/client": "3.11.4", "antd": "5.20.2", diff --git a/frontend/src/ChatBox.tsx b/frontend/src/ChatBox.tsx index b2619b7..cd3d6cf 100644 --- a/frontend/src/ChatBox.tsx +++ b/frontend/src/ChatBox.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; import { Button, Input, message, Spin } from "antd"; import { user } from "./getUser"; import * as graphql from "./graphql"; @@ -94,7 +95,7 @@ const ChatBox: React.FC = ({ user, room, handleClose }) => { setText(e.target.value)} + onChange={(e: ChangeEvent) => setText(e.target.value)} style={{ fontSize: "18px", height: "40px" }} /> ); + + const Delete = (filename: string) => ( + handleDelete(filename)} + > + + + ); + return ( ( - + {filename} )} diff --git a/frontend/src/LoginPage.tsx b/frontend/src/LoginPage.tsx index 5430efc..2576885 100644 --- a/frontend/src/LoginPage.tsx +++ b/frontend/src/LoginPage.tsx @@ -6,7 +6,7 @@ import { LoginFormPage, ProFormCheckbox, ProFormText, -} from "@ant-design/pro-components"; +} from "@ant-design/pro-form"; import { UserOutlined, LockOutlined } from "@ant-design/icons"; const { Link } = Typography; diff --git a/frontend/src/MainPanel.tsx b/frontend/src/MainPanel.tsx index fa05e44..7a097cc 100644 --- a/frontend/src/MainPanel.tsx +++ b/frontend/src/MainPanel.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; +import type { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import { Button, Form, Input, List, message, Modal } from "antd"; +import { Button, Form, Input, List, message, Modal, Popconfirm } from "antd"; import { + DeleteOutlined, UserOutlined, LoginOutlined, LogoutOutlined, PlusOutlined, } from "@ant-design/icons"; +import axios from "axios"; import * as graphql from "./graphql"; import { Bubble, Card, Link, Scroll, Text } from "./Components"; import { user } from "./getUser"; @@ -31,13 +34,33 @@ const MainPanel: React.FC = (props) => { const User: React.FC = ({ user }) => { const navigate = useNavigate(); + const [deleting, setDeleting] = useState(false); - const handleClick = () => { - if (user) { - localStorage.removeItem("token"); - navigate(0); - } else { - navigate("/login"); + const clearLoginState = () => { + localStorage.removeItem("token"); + localStorage.removeItem("username"); + }; + + const handleLoginOrLogout = () => { + if (!user) { + return navigate("/login"); + } + clearLoginState(); + navigate(0); + }; + + const handleDeleteUser = async () => { + setDeleting(true); + try { + await axios.get("/user/delete"); + clearLoginState(); + message.success("账号及相关记录已删除"); + navigate("/login", { replace: true }); + } catch (error) { + console.error(error); + message.error("删除账号失败,请稍后重试"); + } finally { + setDeleting(false); } }; @@ -57,6 +80,8 @@ const User: React.FC = ({ user }) => { = ({ user }) => { style={{ width: "36px", height: "36px", - fontSize: "36px", - marginLeft: "12px", + fontSize: "24px", + marginLeft: "6px", }} - onClick={handleClick} + onClick={handleLoginOrLogout} type="link" danger={user ? true : false} + aria-label={user ? "退出登录" : "登录"} > {user ? : } + {user && ( + + + + )} ); }; @@ -261,7 +312,7 @@ const RoomList: React.FC = ({ onCancel={() => setOpen(false)} cancelText="取消" destroyOnClose - modalRender={(children) => ( + modalRender={(children: ReactNode) => (
{children}
diff --git a/frontend/src/Timer.tsx b/frontend/src/Timer.tsx index 0063292..65934eb 100644 --- a/frontend/src/Timer.tsx +++ b/frontend/src/Timer.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; import { Button, Card, Container, fontFamilies, Text } from "./Components"; import { Input } from "antd"; @@ -30,7 +31,9 @@ const Countdown: React.FC = ({ setCountdownTime(parseInt(e.target.value))} + onChange={(e: ChangeEvent) => + setCountdownTime(parseInt(e.target.value)) + } style={{ marginLeft: "12px", marginRight: "12px", diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 3abb96e..d70087d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -4,7 +4,7 @@ import { createHashRouter, RouterProvider } from "react-router-dom"; import axios from "axios"; import { ApolloProvider } from "@apollo/client"; import { message } from "antd"; -import Draggable from "react-draggable"; +import Draggable, { type DraggableProps } from "react-draggable"; import "./index.css"; import { client } from "./apollo"; import * as graphql from "./graphql"; @@ -17,6 +17,12 @@ const LoginPage = React.lazy(() => import("./LoginPage")); const ChatBox = React.lazy(() => import("./ChatBox")); const FileShare = React.lazy(() => import("./FileShare")); +// react-draggable 4.x ships a legacy class declaration that TypeScript 5.5 +// does not recognize as a JSX component when used with React 18 types. +const DraggableComponent = Draggable as unknown as React.ComponentType< + Partial +>; + axios.defaults.baseURL = process.env.REACT_APP_BACKEND_URL!; axios.interceptors.request.use((config) => { const token = localStorage.getItem("token"); @@ -41,7 +47,7 @@ const MyDraggable: React.FC> = ({ style, }) => { return ( - setCurrentDrag(oid)} @@ -57,7 +63,7 @@ const MyDraggable: React.FC> = ({ > {children} - + ); };