c797e9868e
Delete chat is the caller's alone: the history so far vanishes for them (chat_members.cleared_id) and a direct chat leaves their list (hidden) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real (purge): messages up to the lowest cleared_id and their files on disk, and a direct chat both people deleted, whole. Purge also runs when a member leaves or is removed. A group's creator disbands it for everyone (DELETE /api/chat/rooms/:id). Seen: rooms carry every member's read mark (reads); a mark that moves is told to the members (chat_read, exactAudience) and never passes the newest message. Under your newest message: Seen / Seen by … / Seen by everyone. A room open on a tab you come back to is marked read then.
199 lines
6.2 KiB
JavaScript
199 lines
6.2 KiB
JavaScript
/**
|
|
* @file chat.js (routes)
|
|
* @description `/api/chat` — chat between users in server mode (the pet's
|
|
* window, `client/src/components/Sagi/ChatHub.tsx`): people to chat with,
|
|
* the caller's rooms, a direct chat or a group, messages (text + files, a
|
|
* multipart `POST`), read marks and a room's files. Everything acts on the
|
|
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
|
|
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
|
|
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
|
|
* users concerned (`to`, `scope.exactAudience`).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const express = require("express");
|
|
const chat = require("../lib/chat");
|
|
const { isServerMode } = require("../lib/auth");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
|
|
const router = express.Router();
|
|
|
|
function send(res, e) {
|
|
const status = e.status || (e.code === "ENOUSER" || e.code === "EBADFIELD" ? 400 : 500);
|
|
if (status === 500) console.error("[chat]", e);
|
|
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
|
}
|
|
|
|
/** Run `fn(userId)`; refuses local mode and an anonymous request. */
|
|
const handle = (fn) => (req, res) => {
|
|
if (!isServerMode() || !req.user) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "ELOCALMODE", message: "chat is for a shared server" } });
|
|
}
|
|
try {
|
|
fn(req.user.id, req, res);
|
|
} catch (e) {
|
|
send(res, e);
|
|
}
|
|
};
|
|
|
|
function tell(type, data, to) {
|
|
try {
|
|
require("../websocket").broadcast(type, { ...data, to });
|
|
} catch {
|
|
// no socket server (tests): nothing to tell
|
|
}
|
|
}
|
|
|
|
/** Multipart `files` + `body`, in memory, within the chat's limits. */
|
|
function upload(req, res, next) {
|
|
if (!req.is("multipart/form-data")) return next();
|
|
const multer = require("multer");
|
|
multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
|
|
}).array("files")(req, res, (e) => {
|
|
if (!e) return next();
|
|
const big = e.code === "LIMIT_FILE_SIZE" || e.code === "LIMIT_FILE_COUNT";
|
|
return res.status(big ? 413 : 400).json({
|
|
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: e.message },
|
|
});
|
|
});
|
|
}
|
|
|
|
router.get(
|
|
"/people",
|
|
handle((me, _req, res) => res.json({ people: chat.people(me) }))
|
|
);
|
|
|
|
router.get(
|
|
"/rooms",
|
|
handle((me, _req, res) => res.json({ rooms: chat.listRooms(me) }))
|
|
);
|
|
|
|
router.post(
|
|
"/rooms",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const b = req.body || {};
|
|
if (b.kind === "dm") {
|
|
const { room, created } = chat.openDm(me, b.userId);
|
|
if (created) tell("chat_room", { roomId: room.id }, room.members);
|
|
return res.status(created ? 201 : 200).json({ room });
|
|
}
|
|
if (b.kind === "group") {
|
|
const room = chat.createGroup(me, { name: b.name, userIds: b.userIds });
|
|
tell("chat_room", { roomId: room.id }, room.members);
|
|
return res.status(201).json({ room });
|
|
}
|
|
throw Object.assign(new Error("kind: dm or group"), { code: "EBADFIELD" });
|
|
})
|
|
);
|
|
|
|
router.patch(
|
|
"/rooms/:id",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const b = req.body || {};
|
|
const { room, notify } = chat.updateGroup(me, req.params.id, {
|
|
name: b.name,
|
|
add: b.add,
|
|
remove: b.remove,
|
|
});
|
|
tell("chat_room", { roomId: room.id }, notify);
|
|
res.json({ room });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
"/rooms/:id/leave",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const { notify } = chat.leaveGroup(me, req.params.id);
|
|
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
|
res.json({ ok: true });
|
|
})
|
|
);
|
|
|
|
// Delete the chat for the caller only (their history; a direct chat leaves their list).
|
|
router.post(
|
|
"/rooms/:id/clear",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
|
|
);
|
|
|
|
// The group's creator deletes it for everyone.
|
|
router.delete(
|
|
"/rooms/:id",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const { notify } = chat.disbandGroup(me, req.params.id);
|
|
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
|
res.json({ ok: true });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
"/rooms/:id/messages",
|
|
handle((me, req, res) =>
|
|
res.json(chat.messages(me, req.params.id, { before: req.query.before, limit: req.query.limit }))
|
|
)
|
|
);
|
|
|
|
router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
|
|
handle((me) => {
|
|
const files = (req.files || []).map((f) => ({
|
|
// multer reads names as latin1; browsers send UTF-8.
|
|
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
|
|
type: f.mimetype,
|
|
buffer: f.buffer,
|
|
}));
|
|
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
|
|
tell("chat_message", { roomId: message.roomId, message }, notify);
|
|
res.status(201).json({ message });
|
|
})(req, res)
|
|
);
|
|
|
|
router.delete(
|
|
"/rooms/:id/messages/:messageId",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
|
|
const roomId = Number(req.params.id);
|
|
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
|
|
res.json({ ok: true });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
"/rooms/:id/read",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
|
|
// "Seen": the other members' views move their read marks.
|
|
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
|
|
res.json({ lastReadId });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
"/rooms/:id/files/:fileId",
|
|
handle((me, req, res) => {
|
|
const f = chat.file(me, req.params.id, req.params.fileId);
|
|
const ascii = f.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
res.set({
|
|
"Content-Type": f.type,
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Content-Security-Policy": "sandbox; default-src 'none'",
|
|
"Cache-Control": "private, max-age=86400",
|
|
"Content-Disposition": `${f.inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`,
|
|
});
|
|
require("../lib/send-file").sendFile(res, f.path);
|
|
})
|
|
);
|
|
|
|
module.exports = router;
|