Add messaging

This commit is contained in:
Aslan 2026-01-11 14:17:13 -05:00
parent 23128f25e1
commit 5733975aa0
29 changed files with 986 additions and 8 deletions

138
package-lock.json generated
View file

@ -1,28 +1,31 @@
{ {
"name": "tether", "name": "tether",
"version": "0.3.7", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tether", "name": "tether",
"version": "0.3.7", "version": "0.4.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/websocket": "^11.2.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"uuid": "^13.0.0" "uuid": "^13.0.0",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/ws": "^8.18.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"prisma": "^7.2.0", "prisma": "^7.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@ -266,6 +269,27 @@
"ipaddr.js": "^2.1.0" "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": { "node_modules/@hono/node-server": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz",
@ -606,6 +630,16 @@
"csstype": "^3.2.2" "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": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -954,6 +988,18 @@
"url": "https://dotenvx.com" "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": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -984,6 +1030,15 @@
"node": ">=14" "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": { "node_modules/exsolve": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@ -1247,6 +1302,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ipaddr.js": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
@ -1576,6 +1637,15 @@
"node": ">=14.0.0" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -1927,6 +1997,20 @@
"react": "^19.2.3" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -2183,6 +2267,21 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/thread-stream": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
@ -2289,6 +2388,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/uuid": {
"version": "13.0.0", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
@ -2339,6 +2444,33 @@
"node": ">= 8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "tether", "name": "tether",
"version": "0.3.7", "version": "0.4.0",
"description": "Communication server using the Nexlink protocol", "description": "Communication server using the Nexlink protocol",
"repository": { "repository": {
"type": "git", "type": "git",
@ -19,6 +19,7 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/ws": "^8.18.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"prisma": "^7.2.0", "prisma": "^7.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@ -27,12 +28,14 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/websocket": "^11.2.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"uuid": "^13.0.0" "uuid": "^13.0.0",
"ws": "^8.19.0"
} }
} }

View file

@ -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;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Message" ALTER COLUMN "editHistory" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "edited" SET DEFAULT false;

View file

@ -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;

View file

@ -28,6 +28,7 @@ model Channel {
community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) community Community @relation(fields: [communityId], references: [id], onDelete: Cascade)
communityId String communityId String
creationDate DateTime @default(now()) creationDate DateTime @default(now())
messages Message[]
} }
model Role { model Role {
@ -55,6 +56,7 @@ model User {
ownedCommunities Community[] @relation(name: "OwnerCommunityToUser") ownedCommunities Community[] @relation(name: "OwnerCommunityToUser")
communities Community[] @relation(name: "MembersCommunitiesToUsers") communities Community[] @relation(name: "MembersCommunitiesToUsers")
roles Role[] @relation(name: "UsersRolesToUsers") roles Role[] @relation(name: "UsersRolesToUsers")
messages Message[]
} }
model Session { model Session {
@ -76,3 +78,15 @@ model Invite {
creationDate DateTime @default(now()) creationDate DateTime @default(now())
expirationDate DateTime? 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())
}

View file

@ -13,12 +13,16 @@ import type {
IDeleteChannelParams, IDeleteChannelParams,
IDeleteChannelResponseError, IDeleteChannelResponseError,
IDeleteChannelResponseSuccess, IDeleteChannelResponseSuccess,
IGetMessagesParams,
IGetMessagesResponseError,
IGetMessagesResponseSuccess,
} from "./types.js"; } from "./types.js";
import { import {
createChannelAuth, createChannelAuth,
deleteChannelByIdAuth, deleteChannelByIdAuth,
getChannelByIdAuth, getChannelByIdAuth,
updateChannelByIdAuth, updateChannelByIdAuth,
getChannelMessagesByIdAuth,
} from "../../services/channel/channel.js"; } from "../../services/channel/channel.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
@ -135,4 +139,42 @@ const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => {
} as IDeleteChannelResponseSuccess; } 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,
};

View file

@ -6,6 +6,7 @@ const channelRoutes = async (fastify: FastifyInstance) => {
fastify.post(`/`, controller.postCreateChannel); fastify.post(`/`, controller.postCreateChannel);
fastify.patch(`/:id`, controller.patchChannel); fastify.patch(`/:id`, controller.patchChannel);
fastify.delete(`/:id`, controller.deleteChannel); fastify.delete(`/:id`, controller.deleteChannel);
fastify.get(`/:id/messages`, controller.getMessages);
}; };
export { channelRoutes }; export { channelRoutes };

View file

@ -62,6 +62,28 @@ interface IDeleteChannelResponseSuccess {
communityId: string; 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 { export {
type IChannel, type IChannel,
type IGetChannelParams, type IGetChannelParams,
@ -77,4 +99,8 @@ export {
type IDeleteChannelParams, type IDeleteChannelParams,
type IDeleteChannelResponseError, type IDeleteChannelResponseError,
type IDeleteChannelResponseSuccess, type IDeleteChannelResponseSuccess,
type IGetMessagesParams,
type IGetMessagesResponseError,
type IGetMessagesResponseSuccess,
type IGetMessagesResponseMessage,
}; };

View file

@ -0,0 +1,3 @@
export * from "./message.js";
export * from "./routes.js";
export * from "./types.js";

View file

@ -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 };

View file

@ -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 };

View file

@ -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,
};

View file

@ -0,0 +1,3 @@
export * from "./websocket.js";
export * from "./routes.js";
export * from "./types.js";

View file

@ -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 };

View file

@ -0,0 +1,7 @@
import type { API_ERROR } from "../errors.js";
interface IGetWebSocketResponseError {
error: API_ERROR;
}
export { type IGetWebSocketResponseError };

View file

@ -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 };

View file

@ -1,6 +1,7 @@
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import websocket from "@fastify/websocket";
import { config } from "./config.js"; import { config } from "./config.js";
@ -14,6 +15,8 @@ import { communityRoutes } from "./controllers/community/routes.js";
import { channelRoutes } from "./controllers/channel/routes.js"; import { channelRoutes } from "./controllers/channel/routes.js";
import { roleRoutes } from "./controllers/role/routes.js"; import { roleRoutes } from "./controllers/role/routes.js";
import { inviteRoutes } from "./controllers/invite/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({ const app = Fastify({
logger: true, logger: true,
@ -26,6 +29,8 @@ app.register(cors, {
app.register(cookie, { secret: getCookieSecret() }); app.register(cookie, { secret: getCookieSecret() });
app.register(websocket);
app.register(testRoutes); app.register(testRoutes);
app.register(authRoutes, { prefix: "/api/v1/auth" }); app.register(authRoutes, { prefix: "/api/v1/auth" });
app.register(userRoutes, { prefix: "/api/v1/user" }); 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(channelRoutes, { prefix: "/api/v1/channel" });
app.register(roleRoutes, { prefix: "/api/v1/role" }); app.register(roleRoutes, { prefix: "/api/v1/role" });
app.register(inviteRoutes, { prefix: "/api/v1/invite" }); 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) => { app.listen({ port: config.port }, (err, address) => {
if (err) throw err; if (err) throw err;

View file

@ -47,6 +47,31 @@ const verifyPassword = async (
return await argon2.verify(passwordHash, passwordToCheck); return await argon2.verify(passwordHash, passwordToCheck);
}; };
const getUserFromCookie = async (
cookie: string | undefined,
): Promise<User | null> => {
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<User | null> => { const getUserBySessionId = async (sessionId: string): Promise<User | null> => {
return await getDB().user.findFirst({ return await getDB().user.findFirst({
where: { where: {
@ -116,6 +141,12 @@ const isUserOwnerOrAdmin = async (
if (ownerCheck.role !== undefined) { if (ownerCheck.role !== undefined) {
return false; return false;
} }
if (
ownerCheck.message !== undefined &&
ownerCheck.message?.ownerId !== user.id
) {
return false;
}
return true; return true;
}; };
@ -214,6 +245,7 @@ export {
verifyToken, verifyToken,
hashPassword, hashPassword,
verifyPassword, verifyPassword,
getUserFromCookie,
getUserBySessionId, getUserBySessionId,
getUserFromAuth, getUserFromAuth,
isUserOwnerOrAdmin, isUserOwnerOrAdmin,

View file

@ -7,8 +7,12 @@ enum PERMISSION {
MEMBERS_READ = "MEMBERS_READ", MEMBERS_READ = "MEMBERS_READ",
MEMBERS_KICK = "MEMBERS_KICK", MEMBERS_KICK = "MEMBERS_KICK",
MEMBERS_BAN = "MEMBERS_BAN", MEMBERS_BAN = "MEMBERS_BAN",
INVITES_READ = "INVITES_READ",
INVITES_CREATE = "INVITES_CREATE", INVITES_CREATE = "INVITES_CREATE",
INVITES_DELETE = "INVITES_DELETE", INVITES_DELETE = "INVITES_DELETE",
MESSAGES_READ = "MESSAGES_READ",
MESSAGES_CREATE = "MESSAGES_CREATE",
MESSAGES_DELETE = "MESSAGES_DELETE",
} }
export { PERMISSION }; export { PERMISSION };

View file

@ -6,6 +6,7 @@ import type {
Role, Role,
Session, Session,
User, User,
Message,
} from "../../generated/prisma/client.js"; } from "../../generated/prisma/client.js";
interface AccessTokenPayload extends JwtPayload { interface AccessTokenPayload extends JwtPayload {
@ -30,6 +31,7 @@ interface IOwnerCheck {
invite?: Invite | null; invite?: Invite | null;
channel?: Channel | null; channel?: Channel | null;
role?: Role | null; role?: Role | null;
message?: Message | null;
} }
export { export {

View file

@ -1,5 +1,5 @@
import { API_ERROR } from "../../controllers/errors.js"; 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 { getDB } from "../../store/store.js";
import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js";
import { PERMISSION } from "../auth/permission.js"; import { PERMISSION } from "../auth/permission.js";
@ -136,6 +136,40 @@ const deleteChannelByIdAuth = async (
return await deleteChannelById(id); return await deleteChannelById(id);
}; };
const getChannelMessagesById = async (
id: string,
): Promise<Message[] | null> => {
return await getDB().message.findMany({
where: {
channelId: id,
},
});
};
const getChannelMessagesByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message[] | null | API_ERROR.ACCESS_DENIED> => {
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 { export {
getChannelById, getChannelById,
getChannelByIdAuth, getChannelByIdAuth,
@ -145,4 +179,6 @@ export {
updateChannelByIdAuth, updateChannelByIdAuth,
deleteChannelById, deleteChannelById,
deleteChannelByIdAuth, deleteChannelByIdAuth,
getChannelMessagesById,
getChannelMessagesByIdAuth,
}; };

View file

@ -255,7 +255,7 @@ const getCommunityInvitesByIdAuth = async (
community: community, community: community,
}, },
community, community,
[PERMISSION.INVITES_CREATE], [PERMISSION.INVITES_READ],
)) ))
) { ) {
return API_ERROR.ACCESS_DENIED; return API_ERROR.ACCESS_DENIED;

View file

@ -0,0 +1,2 @@
export * from "./message.js";
export * from "./types.js";

View file

@ -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<Message | null> => {
return await getDB().message.findUnique({
where: { id: id },
});
};
const getMessageByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => {
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<Message> => {
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<Message | API_ERROR.ACCESS_DENIED> => {
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<Message | null> => {
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<Message | null | API_ERROR.ACCESS_DENIED> => {
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<Message | null> => {
return await getDB().message.delete({
where: { id: id },
});
};
const deleteMessageByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => {
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,
};

View file

@ -0,0 +1,10 @@
interface ICreateMessage {
text: string;
channelId: string;
}
interface IUpdateMessage {
text: string;
}
export { type ICreateMessage, type IUpdateMessage };

View file

@ -0,0 +1,2 @@
export * from "./websocket.js";
export * from "./types.js";

View file

@ -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,
};

View file

@ -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<string, Set<ISocketConnection>>();
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,
};