Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions backend/src/authenticate.ts
Original file line number Diff line number Diff line change
@@ -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;
57 changes: 57 additions & 0 deletions backend/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
17 changes: 17 additions & 0 deletions backend/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -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 = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;

Expand Down Expand Up @@ -1658,6 +1672,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
},
getUsersByUsername(variables: GetUsersByUsernameQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetUsersByUsernameQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<GetUsersByUsernameQuery>(GetUsersByUsernameDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getUsersByUsername', 'query', variables);
},
deleteUser(variables: DeleteUserMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<DeleteUserMutation> {
return withWrapper((wrappedRequestHeaders) => client.request<DeleteUserMutation>(DeleteUserDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'deleteUser', 'mutation', variables);
}
};
}
Expand Down
16 changes: 16 additions & 0 deletions backend/src/user.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
6 changes: 6 additions & 0 deletions database/graphql/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ query getUsersByUsername($username: String!) {
password
}
}

mutation deleteUser($uuid: uuid!) {
delete_user_by_pk(uuid: $uuid) {
uuid
}
}
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/ChatBox.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -94,7 +95,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ user, room, handleClose }) => {
<Input
placeholder="输入消息"
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
style={{ fontSize: "18px", height: "40px" }}
/>
<Button
Expand Down
75 changes: 70 additions & 5 deletions frontend/src/FileShare.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { Button, List, message, Spin, Upload } from "antd";
import { Button, List, message, Popconfirm, Spin, Upload } from "antd";
import type { UploadProps } from "antd";
import {
DeleteOutlined,
InboxOutlined,
DownloadOutlined,
ReloadOutlined,
Expand Down Expand Up @@ -84,6 +86,20 @@ const FileShare: React.FC<FileShareProps> = ({ room, handleClose }) => {
setTimeout(() => setRefreshing(false), 1000);
};

const deleteFile = async (filename: string) => {
try {
await axios.post("/file/delete", {
room: room?.uuid,
filename,
});
setFileList((current) => current.filter((file) => file !== filename));
message.success("删除文件成功!");
} catch (error) {
console.error(error);
message.error("删除文件失败,请刷新列表后重试!");
}
};

const Refresh = () => (
<Button
type="link"
Expand Down Expand Up @@ -135,13 +151,21 @@ const FileShare: React.FC<FileShareProps> = ({ room, handleClose }) => {
文件共享空间
</Text>
</Container>
<FileList roomUUID={room.uuid} filelist={fileList} />
<FileList
roomUUID={room.uuid}
filelist={fileList}
onDelete={deleteFile}
/>
<div
className="need-interaction"
style={{ marginTop: "12px", width: "100%" }}
>
<Dragger
customRequest={({ file, onSuccess, onError }) => {
customRequest={({
file,
onSuccess,
onError,
}: Parameters<NonNullable<UploadProps["customRequest"]>>[0]) => {
uploadFile(file as File, onSuccess, onError);
}}
showUploadList={false}
Expand All @@ -160,25 +184,66 @@ const FileShare: React.FC<FileShareProps> = ({ room, handleClose }) => {
interface FileListProps {
roomUUID: string;
filelist: string[];
onDelete: (filename: string) => Promise<void>;
}

const FileList: React.FC<FileListProps> = ({ roomUUID, filelist }) => {
const FileList: React.FC<FileListProps> = ({ roomUUID, filelist, onDelete }) => {
const [deletingFile, setDeletingFile] = useState<string | null>(null);

const handleDelete = async (filename: string) => {
setDeletingFile(filename);
try {
await onDelete(filename);
} finally {
setDeletingFile(null);
}
};

const Download = (filename: string) => (
<Button
key={`download-${filename}`}
type="link"
style={{ fontSize: "18px", width: "18px", height: "18px", padding: 0 }}
onClick={async () => await downloadFile(roomUUID, filename)}
aria-label={`下载 ${filename}`}
>
<DownloadOutlined />
</Button>
);

const Delete = (filename: string) => (
<Popconfirm
key={`delete-${filename}`}
title="确定删除这个文件吗?"
description={filename}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(filename)}
>
<Button
type="link"
danger
loading={deletingFile === filename}
disabled={deletingFile !== null && deletingFile !== filename}
style={{ fontSize: "18px", width: "18px", height: "18px", padding: 0 }}
aria-label={`删除 ${filename}`}
>
<DeleteOutlined />
</Button>
</Popconfirm>
);

return (
<Scroll>
<List
size="small"
dataSource={filelist}
renderItem={(filename) => (
<List.Item style={{ padding: "8px" }} actions={[Download(filename)]}>
<List.Item
style={{ padding: "8px" }}
actions={[Download(filename), Delete(filename)]}
>
<Text style={{ wordBreak: "break-all" }}>{filename}</Text>
</List.Item>
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading