/** * @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ĩ */ "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;