diff --git a/package-lock.json b/package-lock.json index 0263788..4a9d01a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,31 @@ { "name": "tether", - "version": "0.3.7", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tether", - "version": "0.3.7", + "version": "0.4.0", "license": "GPL-3.0-only", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/websocket": "^11.2.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "argon2": "^0.44.0", "fastify": "^5.6.2", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.0.3", "@types/pg": "^8.16.0", + "@types/ws": "^8.18.1", "dotenv": "^17.2.3", "prisma": "^7.2.0", "ts-node": "^10.9.2", @@ -266,6 +269,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", @@ -606,6 +630,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -954,6 +988,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -984,6 +1030,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1247,6 +1302,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -1576,6 +1637,15 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1927,6 +1997,20 @@ "react": "^19.2.3" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2183,6 +2267,21 @@ "devOptional": true, "license": "MIT" }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2289,6 +2388,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -2339,6 +2444,33 @@ "node": ">= 8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 1c1560d..0370c3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tether", - "version": "0.3.7", + "version": "0.4.0", "description": "Communication server using the Nexlink protocol", "repository": { "type": "git", @@ -19,6 +19,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.0.3", "@types/pg": "^8.16.0", + "@types/ws": "^8.18.1", "dotenv": "^17.2.3", "prisma": "^7.2.0", "ts-node": "^10.9.2", @@ -27,12 +28,14 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/websocket": "^11.2.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "argon2": "^0.44.0", "fastify": "^5.6.2", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "ws": "^8.19.0" } } diff --git a/prisma/migrations/20260111121740_message/migration.sql b/prisma/migrations/20260111121740_message/migration.sql new file mode 100644 index 0000000..67d090e --- /dev/null +++ b/prisma/migrations/20260111121740_message/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Message" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "editHistory" TEXT[], + "edited" BOOLEAN NOT NULL, + "userId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Message_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Message_id_key" ON "Message"("id"); + +-- AddForeignKey +ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Message" ADD CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260111124811_message_1/migration.sql b/prisma/migrations/20260111124811_message_1/migration.sql new file mode 100644 index 0000000..3b50655 --- /dev/null +++ b/prisma/migrations/20260111124811_message_1/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Message" ALTER COLUMN "editHistory" SET DEFAULT ARRAY[]::TEXT[], +ALTER COLUMN "edited" SET DEFAULT false; diff --git a/prisma/migrations/20260111125054_message_2/migration.sql b/prisma/migrations/20260111125054_message_2/migration.sql new file mode 100644 index 0000000..da85723 --- /dev/null +++ b/prisma/migrations/20260111125054_message_2/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `Message` table. All the data in the column will be lost. + - Added the required column `ownerId` to the `Message` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Message" DROP CONSTRAINT "Message_userId_fkey"; + +-- AlterTable +ALTER TABLE "Message" DROP COLUMN "userId", +ADD COLUMN "ownerId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Message" ADD CONSTRAINT "Message_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c146af8..c6fcf23 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model Channel { community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) communityId String creationDate DateTime @default(now()) + messages Message[] } model Role { @@ -55,6 +56,7 @@ model User { ownedCommunities Community[] @relation(name: "OwnerCommunityToUser") communities Community[] @relation(name: "MembersCommunitiesToUsers") roles Role[] @relation(name: "UsersRolesToUsers") + messages Message[] } model Session { @@ -76,3 +78,15 @@ model Invite { creationDate DateTime @default(now()) expirationDate DateTime? } + +model Message { + id String @id @unique @default(uuid()) + text String + editHistory String[] @default([]) + edited Boolean @default(false) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String + channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) + channelId String + creationDate DateTime @default(now()) +} diff --git a/src/controllers/channel/channel.ts b/src/controllers/channel/channel.ts index 1229415..c53aea3 100644 --- a/src/controllers/channel/channel.ts +++ b/src/controllers/channel/channel.ts @@ -13,12 +13,16 @@ import type { IDeleteChannelParams, IDeleteChannelResponseError, IDeleteChannelResponseSuccess, + IGetMessagesParams, + IGetMessagesResponseError, + IGetMessagesResponseSuccess, } from "./types.js"; import { createChannelAuth, deleteChannelByIdAuth, getChannelByIdAuth, updateChannelByIdAuth, + getChannelMessagesByIdAuth, } from "../../services/channel/channel.js"; import { API_ERROR } from "../errors.js"; @@ -135,4 +139,42 @@ const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => { } as IDeleteChannelResponseSuccess; }; -export { getChannel, postCreateChannel, patchChannel, deleteChannel }; +const getMessages = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetMessagesParams; + const authHeader = request.headers["authorization"]; + + const messages = await getChannelMessagesByIdAuth(id, authHeader); + if (!messages) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IGetMessagesResponseError; + } + if (messages === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetMessagesResponseError; + } + + return { + id: id, + messages: messages.map((message) => ({ + id: message.id, + text: message.text, + edited: message.edited, + ownerId: message.ownerId, + creationDate: message.creationDate.getTime(), + })), + } as IGetMessagesResponseSuccess; +}; + +export { + getChannel, + postCreateChannel, + patchChannel, + deleteChannel, + getMessages, +}; diff --git a/src/controllers/channel/routes.ts b/src/controllers/channel/routes.ts index 9646137..d4533c0 100644 --- a/src/controllers/channel/routes.ts +++ b/src/controllers/channel/routes.ts @@ -6,6 +6,7 @@ const channelRoutes = async (fastify: FastifyInstance) => { fastify.post(`/`, controller.postCreateChannel); fastify.patch(`/:id`, controller.patchChannel); fastify.delete(`/:id`, controller.deleteChannel); + fastify.get(`/:id/messages`, controller.getMessages); }; export { channelRoutes }; diff --git a/src/controllers/channel/types.ts b/src/controllers/channel/types.ts index 65688d3..751a3f9 100644 --- a/src/controllers/channel/types.ts +++ b/src/controllers/channel/types.ts @@ -62,6 +62,28 @@ interface IDeleteChannelResponseSuccess { communityId: string; } +interface IGetMessagesParams { + id: string; +} + +interface IGetMessagesResponseError { + id: string; + error: API_ERROR; +} + +interface IGetMessagesResponseSuccess { + id: string; + messages: IGetMessagesResponseMessage[]; +} + +interface IGetMessagesResponseMessage { + id: string; + text: string; + edited: boolean; + ownerId: string; + creationDate: number; +} + export { type IChannel, type IGetChannelParams, @@ -77,4 +99,8 @@ export { type IDeleteChannelParams, type IDeleteChannelResponseError, type IDeleteChannelResponseSuccess, + type IGetMessagesParams, + type IGetMessagesResponseError, + type IGetMessagesResponseSuccess, + type IGetMessagesResponseMessage, }; diff --git a/src/controllers/message/index.ts b/src/controllers/message/index.ts new file mode 100644 index 0000000..e6bb2d5 --- /dev/null +++ b/src/controllers/message/index.ts @@ -0,0 +1,3 @@ +export * from "./message.js"; +export * from "./routes.js"; +export * from "./types.js"; diff --git a/src/controllers/message/message.ts b/src/controllers/message/message.ts new file mode 100644 index 0000000..03384f7 --- /dev/null +++ b/src/controllers/message/message.ts @@ -0,0 +1,145 @@ +import { type FastifyReply, type FastifyRequest } from "fastify"; +import type { + IGetMessageParams, + IGetMessageResponseError, + IGetMessageResponseSuccess, + IPostCreateMessageRequest, + IPostCreateMessageResponseError, + IPostCreateMessageResponseSuccess, + IPatchMessageParams, + IPatchMessageRequest, + IPatchMessageResponseError, + IPatchMessageResponseSuccess, + IDeleteMessageParams, + IDeleteMessageResponseError, + IDeleteMessageResponseSuccess, +} from "./types.js"; +import { + createMessageAuth, + deleteMessageByIdAuth, + getMessageByIdAuth, + updateMessageByIdAuth, +} from "../../services/message/message.js"; +import { API_ERROR } from "../errors.js"; + +const getMessage = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetMessageParams; + const authHeader = request.headers["authorization"]; + + const message = await getMessageByIdAuth(id, authHeader); + if (!message) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IGetMessageResponseError; + } + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetMessageResponseError; + } + + return { + id: message.id, + text: message.text, + editHistory: message.editHistory, + edited: message.edited, + ownerId: message.ownerId, + channelId: message.channelId, + creationDate: message.creationDate.getTime(), + } as IGetMessageResponseSuccess; +}; + +const postCreateMessage = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const createMessageRequest = request.body as IPostCreateMessageRequest; + const authHeader = request.headers["authorization"]; + + const message = await createMessageAuth(createMessageRequest, authHeader); + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IPostCreateMessageResponseError; + } + + return { + id: message.id, + text: message.text, + editHistory: message.editHistory, + edited: message.edited, + ownerId: message.ownerId, + channelId: message.channelId, + creationDate: message.creationDate.getTime(), + } as IPostCreateMessageResponseSuccess; +}; + +const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IPatchMessageParams; + const patchMessageRequest = request.body as IPatchMessageRequest; + const authHeader = request.headers["authorization"]; + + const message = await updateMessageByIdAuth( + id, + patchMessageRequest, + authHeader, + ); + if (!message) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IPatchMessageResponseError; + } + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IPatchMessageResponseError; + } + + return { + id: message.id, + text: message.text, + editHistory: message.editHistory, + edited: message.edited, + ownerId: message.ownerId, + channelId: message.channelId, + creationDate: message.creationDate.getTime(), + } as IPatchMessageResponseSuccess; +}; + +const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IDeleteMessageParams; + const authHeader = request.headers["authorization"]; + + const message = await deleteMessageByIdAuth(id, authHeader); + if (!message) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IDeleteMessageResponseError; + } + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IDeleteMessageResponseError; + } + + return { + id: message.id, + ownerId: message.ownerId, + channelId: message.channelId, + } as IDeleteMessageResponseSuccess; +}; + +export { getMessage, postCreateMessage, patchMessage, deleteMessage }; diff --git a/src/controllers/message/routes.ts b/src/controllers/message/routes.ts new file mode 100644 index 0000000..bb7bf1a --- /dev/null +++ b/src/controllers/message/routes.ts @@ -0,0 +1,11 @@ +import { type FastifyInstance } from "fastify"; +import * as controller from "./message.js"; + +const messageRoutes = async (fastify: FastifyInstance) => { + fastify.get(`/:id`, controller.getMessage); + fastify.post(`/`, controller.postCreateMessage); + fastify.patch(`/:id`, controller.patchMessage); + fastify.delete(`/:id`, controller.deleteMessage); +}; + +export { messageRoutes }; diff --git a/src/controllers/message/types.ts b/src/controllers/message/types.ts new file mode 100644 index 0000000..0bcb57c --- /dev/null +++ b/src/controllers/message/types.ts @@ -0,0 +1,81 @@ +import type { API_ERROR } from "../errors.js"; + +interface IMessage { + id: string; + text: string; + editHistory: string[]; + edited: boolean; + ownerId: string; + channelId: string; + creationDate: number; +} + +interface IGetMessageParams { + id: string; +} + +interface IGetMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IGetMessageResponseSuccess extends IMessage {} + +interface IPostCreateMessageRequest { + text: string; + channelId: string; +} + +interface IPostCreateMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IPostCreateMessageResponseSuccess extends IMessage {} + +interface IPatchMessageParams { + id: string; +} + +interface IPatchMessageRequest { + text: string; +} + +interface IPatchMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IPatchMessageResponseSuccess extends IMessage {} + +interface IDeleteMessageParams { + id: string; +} + +interface IDeleteMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IDeleteMessageResponseSuccess { + id: string; + ownerId: string; + channelId: string; +} + +export { + type IMessage, + type IGetMessageParams, + type IGetMessageResponseError, + type IGetMessageResponseSuccess, + type IPostCreateMessageRequest, + type IPostCreateMessageResponseError, + type IPostCreateMessageResponseSuccess, + type IPatchMessageParams, + type IPatchMessageRequest, + type IPatchMessageResponseError, + type IPatchMessageResponseSuccess, + type IDeleteMessageParams, + type IDeleteMessageResponseError, + type IDeleteMessageResponseSuccess, +}; diff --git a/src/controllers/websocket/index.ts b/src/controllers/websocket/index.ts new file mode 100644 index 0000000..1b2fe1c --- /dev/null +++ b/src/controllers/websocket/index.ts @@ -0,0 +1,3 @@ +export * from "./websocket.js"; +export * from "./routes.js"; +export * from "./types.js"; diff --git a/src/controllers/websocket/routes.ts b/src/controllers/websocket/routes.ts new file mode 100644 index 0000000..cd6fa0c --- /dev/null +++ b/src/controllers/websocket/routes.ts @@ -0,0 +1,12 @@ +import { type FastifyInstance } from "fastify"; +import * as controller from "./websocket.js"; + +const websocketRoutes = async (fastify: FastifyInstance) => { + fastify.get( + `/`, + { websocket: true, preHandler: controller.handleWebSockets }, + controller.getWebSockets, + ); +}; + +export { websocketRoutes }; diff --git a/src/controllers/websocket/types.ts b/src/controllers/websocket/types.ts new file mode 100644 index 0000000..b250625 --- /dev/null +++ b/src/controllers/websocket/types.ts @@ -0,0 +1,7 @@ +import type { API_ERROR } from "../errors.js"; + +interface IGetWebSocketResponseError { + error: API_ERROR; +} + +export { type IGetWebSocketResponseError }; diff --git a/src/controllers/websocket/websocket.ts b/src/controllers/websocket/websocket.ts new file mode 100644 index 0000000..ed95e2b --- /dev/null +++ b/src/controllers/websocket/websocket.ts @@ -0,0 +1,33 @@ +import { type FastifyReply, type FastifyRequest } from "fastify"; +import type { IGetWebSocketResponseError } from "./types.js"; +import { API_ERROR } from "../errors.js"; +import { getUserFromCookie } from "../../services/auth/helpers.js"; +import { handleNewWebSocket } from "../../services/websocket/websocket.js"; +import type { WebSocket } from "@fastify/websocket"; + +const handleWebSockets = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const cookie = request.cookies["token"]; + const user = await getUserFromCookie(cookie); + if (!user) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IGetWebSocketResponseError; + } +}; + +const getWebSockets = async (socket: WebSocket, request: FastifyRequest) => { + const cookie = request.cookies["token"]; + const user = await getUserFromCookie(cookie); + if (!user) { + socket.close(); + return; + } + + handleNewWebSocket(socket, user.id); +}; + +export { handleWebSockets, getWebSockets }; diff --git a/src/index.ts b/src/index.ts index 06c2997..98af533 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import Fastify from "fastify"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; +import websocket from "@fastify/websocket"; import { config } from "./config.js"; @@ -14,6 +15,8 @@ import { communityRoutes } from "./controllers/community/routes.js"; import { channelRoutes } from "./controllers/channel/routes.js"; import { roleRoutes } from "./controllers/role/routes.js"; import { inviteRoutes } from "./controllers/invite/routes.js"; +import { messageRoutes } from "./controllers/message/routes.js"; +import { websocketRoutes } from "./controllers/websocket/routes.js"; const app = Fastify({ logger: true, @@ -26,6 +29,8 @@ app.register(cors, { app.register(cookie, { secret: getCookieSecret() }); +app.register(websocket); + app.register(testRoutes); app.register(authRoutes, { prefix: "/api/v1/auth" }); app.register(userRoutes, { prefix: "/api/v1/user" }); @@ -34,6 +39,8 @@ app.register(communityRoutes, { prefix: "/api/v1/community" }); app.register(channelRoutes, { prefix: "/api/v1/channel" }); app.register(roleRoutes, { prefix: "/api/v1/role" }); app.register(inviteRoutes, { prefix: "/api/v1/invite" }); +app.register(messageRoutes, { prefix: "/api/v1/message" }); +app.register(websocketRoutes, { prefix: "/ws" }); app.listen({ port: config.port }, (err, address) => { if (err) throw err; diff --git a/src/services/auth/helpers.ts b/src/services/auth/helpers.ts index aa0379c..9fb3851 100644 --- a/src/services/auth/helpers.ts +++ b/src/services/auth/helpers.ts @@ -47,6 +47,31 @@ const verifyPassword = async ( return await argon2.verify(passwordHash, passwordToCheck); }; +const getUserFromCookie = async ( + cookie: string | undefined, +): Promise => { + if (!cookie) { + return null; + } + + const session = await getDB().session.findFirst({ + where: { + cookie: cookie, + }, + }); + + if (!session) { + return null; + } + + const user = await getUserBySessionId(session.id); + if (!user) { + return null; + } + + return user; +}; + const getUserBySessionId = async (sessionId: string): Promise => { return await getDB().user.findFirst({ where: { @@ -116,6 +141,12 @@ const isUserOwnerOrAdmin = async ( if (ownerCheck.role !== undefined) { return false; } + if ( + ownerCheck.message !== undefined && + ownerCheck.message?.ownerId !== user.id + ) { + return false; + } return true; }; @@ -214,6 +245,7 @@ export { verifyToken, hashPassword, verifyPassword, + getUserFromCookie, getUserBySessionId, getUserFromAuth, isUserOwnerOrAdmin, diff --git a/src/services/auth/permission.ts b/src/services/auth/permission.ts index de6fa1c..5bd3d6b 100644 --- a/src/services/auth/permission.ts +++ b/src/services/auth/permission.ts @@ -7,8 +7,12 @@ enum PERMISSION { MEMBERS_READ = "MEMBERS_READ", MEMBERS_KICK = "MEMBERS_KICK", MEMBERS_BAN = "MEMBERS_BAN", + INVITES_READ = "INVITES_READ", INVITES_CREATE = "INVITES_CREATE", INVITES_DELETE = "INVITES_DELETE", + MESSAGES_READ = "MESSAGES_READ", + MESSAGES_CREATE = "MESSAGES_CREATE", + MESSAGES_DELETE = "MESSAGES_DELETE", } export { PERMISSION }; diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index b9d2f4e..c9878b4 100644 --- a/src/services/auth/types.ts +++ b/src/services/auth/types.ts @@ -6,6 +6,7 @@ import type { Role, Session, User, + Message, } from "../../generated/prisma/client.js"; interface AccessTokenPayload extends JwtPayload { @@ -30,6 +31,7 @@ interface IOwnerCheck { invite?: Invite | null; channel?: Channel | null; role?: Role | null; + message?: Message | null; } export { diff --git a/src/services/channel/channel.ts b/src/services/channel/channel.ts index 9505beb..d94fcbc 100644 --- a/src/services/channel/channel.ts +++ b/src/services/channel/channel.ts @@ -1,5 +1,5 @@ import { API_ERROR } from "../../controllers/errors.js"; -import type { Channel } from "../../generated/prisma/client.js"; +import type { Channel, Message } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; import { PERMISSION } from "../auth/permission.js"; @@ -136,6 +136,40 @@ const deleteChannelByIdAuth = async ( return await deleteChannelById(id); }; +const getChannelMessagesById = async ( + id: string, +): Promise => { + return await getDB().message.findMany({ + where: { + channelId: id, + }, + }); +}; + +const getChannelMessagesByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const channel = await getChannelById(id); + const community = await getCommunityById(channel?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await getChannelMessagesById(id); +}; + export { getChannelById, getChannelByIdAuth, @@ -145,4 +179,6 @@ export { updateChannelByIdAuth, deleteChannelById, deleteChannelByIdAuth, + getChannelMessagesById, + getChannelMessagesByIdAuth, }; diff --git a/src/services/community/community.ts b/src/services/community/community.ts index cf14ea1..32a304e 100644 --- a/src/services/community/community.ts +++ b/src/services/community/community.ts @@ -255,7 +255,7 @@ const getCommunityInvitesByIdAuth = async ( community: community, }, community, - [PERMISSION.INVITES_CREATE], + [PERMISSION.INVITES_READ], )) ) { return API_ERROR.ACCESS_DENIED; diff --git a/src/services/message/index.ts b/src/services/message/index.ts new file mode 100644 index 0000000..2226067 --- /dev/null +++ b/src/services/message/index.ts @@ -0,0 +1,2 @@ +export * from "./message.js"; +export * from "./types.js"; diff --git a/src/services/message/message.ts b/src/services/message/message.ts new file mode 100644 index 0000000..21c3693 --- /dev/null +++ b/src/services/message/message.ts @@ -0,0 +1,202 @@ +import { API_ERROR } from "../../controllers/errors.js"; +import type { Message } from "../../generated/prisma/client.js"; +import { getDB } from "../../store/store.js"; +import { + getUserFromAuth, + isUserAllowed, + isUserInCommunity, + isUserOwnerOrAdmin, +} from "../auth/helpers.js"; +import { PERMISSION } from "../auth/permission.js"; +import { getChannelById } from "../channel/channel.js"; +import { getCommunityById } from "../community/community.js"; +import { SocketMessageTypes } from "../websocket/types.js"; +import { sendMessageToUsers } from "../websocket/websocket.js"; +import type { ICreateMessage, IUpdateMessage } from "./types.js"; + +const getMessageById = async (id: string): Promise => { + return await getDB().message.findUnique({ + where: { id: id }, + }); +}; + +const getMessageByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const message = await getMessageById(id); + const channel = await getChannelById(message?.channelId ?? ""); + const community = await getCommunityById(channel?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return message; +}; + +const createMessage = async ( + ownerId: string, + communityId: string, + create: ICreateMessage, +): Promise => { + const message = await getDB().message.create({ + data: { + ownerId: ownerId, + ...create, + }, + }); + + const usersInCommunity = await getDB().user.findMany({ + select: { + id: true, + }, + where: { + communities: { + some: { + id: communityId, + }, + }, + }, + }); + const userIds = usersInCommunity.map((user) => user.id); + + sendMessageToUsers(userIds, { + type: SocketMessageTypes.NEW_MESSAGE, + payload: { + channelId: message.channelId, + message: { + id: message.id, + text: message.text, + edited: message.edited, + ownerId: message.ownerId, + creationDate: message.creationDate.getTime(), + }, + }, + }); + + return message; +}; + +const createMessageAuth = async ( + create: ICreateMessage, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const channel = await getChannelById(create.channelId); + const community = await getCommunityById(channel?.communityId ?? ""); + + if ( + !authUser || + !community || + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_CREATE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await createMessage(authUser.id, community.id, create); +}; + +const updateMessageById = async ( + id: string, + update: IUpdateMessage, +): Promise => { + const message = await getMessageById(id); + if (!message) { + return null; + } + + const newEditHistory = [...message.editHistory, message.text]; + + return await getDB().message.update({ + where: { + id: id, + }, + data: { + ...update, + editHistory: newEditHistory, + edited: true, + }, + }); +}; + +const updateMessageByIdAuth = async ( + id: string, + update: IUpdateMessage, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const message = await getMessageById(id); + const channel = await getChannelById(message?.channelId ?? ""); + const community = await getCommunityById(channel?.communityId ?? ""); + + if ( + !(await isUserOwnerOrAdmin(authUser, { + message: message, + })) || + !(await isUserInCommunity(authUser, community)) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await updateMessageById(id, update); +}; + +const deleteMessageById = async (id: string): Promise => { + return await getDB().message.delete({ + where: { id: id }, + }); +}; + +const deleteMessageByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const message = await getMessageById(id); + const channel = await getChannelById(message?.channelId ?? ""); + const community = await getCommunityById(channel?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_DELETE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await deleteMessageById(id); +}; + +export { + getMessageById, + getMessageByIdAuth, + createMessage, + createMessageAuth, + updateMessageById, + updateMessageByIdAuth, + deleteMessageById, + deleteMessageByIdAuth, +}; diff --git a/src/services/message/types.ts b/src/services/message/types.ts new file mode 100644 index 0000000..3d22722 --- /dev/null +++ b/src/services/message/types.ts @@ -0,0 +1,10 @@ +interface ICreateMessage { + text: string; + channelId: string; +} + +interface IUpdateMessage { + text: string; +} + +export { type ICreateMessage, type IUpdateMessage }; diff --git a/src/services/websocket/index.ts b/src/services/websocket/index.ts new file mode 100644 index 0000000..2b3020a --- /dev/null +++ b/src/services/websocket/index.ts @@ -0,0 +1,2 @@ +export * from "./websocket.js"; +export * from "./types.js"; diff --git a/src/services/websocket/types.ts b/src/services/websocket/types.ts new file mode 100644 index 0000000..6cf6695 --- /dev/null +++ b/src/services/websocket/types.ts @@ -0,0 +1,53 @@ +import type { WebSocket } from "@fastify/websocket"; +import type { IGetMessagesResponseMessage } from "../../controllers/channel/types.js"; + +interface ISocketConnection { + socket: WebSocket; + userId: string; +} + +enum SocketRequestTypes { + PING = "PING", +} + +enum SocketMessageTypes { + NEW_ANNOUNCEMENT = "NEW_ANNOUNCEMENT", + NEW_MESSAGE = "NEW_MESSAGE", + NEW_CHANNEL = "NEW_CHANNEL", +} + +type SocketRequest = { + type: SocketRequestTypes.PING; +}; + +type SocketMessage = + | { + type: SocketMessageTypes.NEW_ANNOUNCEMENT; + payload: { + title: string; + description: string; + }; + } + | { + type: SocketMessageTypes.NEW_MESSAGE; + payload: { + channelId: string; + message: IGetMessagesResponseMessage; + }; + } + | { + type: SocketMessageTypes.NEW_CHANNEL; + payload: { + id: string; + communityId: string; + name: string; + }; + }; + +export { + type ISocketConnection, + SocketRequestTypes, + SocketMessageTypes, + type SocketRequest, + type SocketMessage, +}; diff --git a/src/services/websocket/websocket.ts b/src/services/websocket/websocket.ts new file mode 100644 index 0000000..9df1d26 --- /dev/null +++ b/src/services/websocket/websocket.ts @@ -0,0 +1,75 @@ +import type { CloseEvent, MessageEvent } from "ws"; +import type { WebSocket } from "@fastify/websocket"; +import type { + ISocketConnection, + SocketRequest, + SocketMessage, +} from "./types.js"; + +const userConnections = new Map>(); + +const handleNewWebSocket = (socket: WebSocket, userId: string) => { + const connection = { + socket: socket, + userId: userId, + }; + + if (!userConnections.has(userId)) { + userConnections.set(userId, new Set()); + } + userConnections.get(userId)?.add(connection); + + onCloseWsHandler(connection); + onMessageWsHandler(connection); +}; + +const handleRequest = (data: string): SocketRequest | null => { + const request = JSON.parse(data) as SocketRequest; + + if (!request.type) { + return null; + } + + return request; +}; + +const onCloseWsHandler = (connection: ISocketConnection) => { + connection.socket.onclose = (event: CloseEvent) => { + const connections = userConnections.get(connection.userId); + + connections?.delete(connection); + if (connections?.size === 0) { + userConnections.delete(connection.userId); + } + }; +}; + +const onMessageWsHandler = (connection: ISocketConnection) => { + connection.socket.onmessage = (event: MessageEvent) => { + const request = handleRequest(event.data.toString()); + if (!request) { + return; + } + }; +}; + +const sendMessageToUser = (userId: string, message: SocketMessage) => { + const connections = userConnections.get(userId); + + connections?.forEach((connection) => { + connection.socket.send(JSON.stringify(message)); + }); +}; + +const sendMessageToUsers = (userIds: string[], message: SocketMessage) => { + userIds?.forEach((userId) => { + sendMessageToUser(userId, message); + }); +}; + +export { + userConnections, + handleNewWebSocket, + sendMessageToUser, + sendMessageToUsers, +};