03ebea1d92
- A group whose creator left or was deleted passes to its longest-standing member (ensureOwner), so it can still be managed. - Group changes (added / removed / renamed / left) are sent live as chat_message and never count as unread, here or on the server. - A disabled account is still named in old chats (people carries `disabled`) and never offered for a new one; a deleted one reads 'Deleted user' instead of '?'. - Load older keeps the message being read in place. - With several tabs open only the first to see a message plays its sound / system notification (agenthub.chat.notified). - The people list is read again on New chat and on every room change. - Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are renamed into place — never held in memory; a refused one is removed. - With the pet hidden, a chat button in the bottom-right corner (ChatDock, server mode) opens the same window, unread count and new-message card included.
216 lines
7.0 KiB
JavaScript
216 lines
7.0 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`, written to disk (never held whole 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.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
|
|
// A name of ours; the browser's goes only into the database.
|
|
filename: (_req, _file, cb) =>
|
|
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
|
|
}),
|
|
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, lines } = chat.updateGroup(me, req.params.id, {
|
|
name: b.name,
|
|
add: b.add,
|
|
remove: b.remove,
|
|
});
|
|
tell("chat_room", { roomId: room.id }, notify);
|
|
// The "X added Y" lines, live in every open view.
|
|
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
|
|
res.json({ room });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
"/rooms/:id/leave",
|
|
sameOriginGuard,
|
|
handle((me, req, res) => {
|
|
const { notify, lines } = chat.leaveGroup(me, req.params.id);
|
|
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
|
const stay = notify.filter((id) => id !== me);
|
|
for (const message of lines)
|
|
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
|
|
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,
|
|
path: f.path,
|
|
}));
|
|
let posted;
|
|
try {
|
|
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
|
|
} finally {
|
|
// Moved into place on success; anything left over is a refused upload.
|
|
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
|
|
}
|
|
const { message, notify } = posted;
|
|
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;
|