From d17f37749de0eca1dd4afb381e8d752d6cf18e57b518d350e263153d12c1c4fa Mon Sep 17 00:00:00 2001 From: aslan Date: Fri, 26 Dec 2025 19:33:43 +0100 Subject: [PATCH] New controllers and services; Auth --- .../20251224125500_relations/migration.sql | 50 +++++ .../20251224231937_creationdate/migration.sql | 11 + .../20251225000930_permissions/migration.sql | 2 + .../20251225001552_roles/migration.sql | 16 ++ .../20251225001619_roles2/migration.sql | 31 +++ prisma/schema.prisma | 94 +++++---- src/controllers/auth/auth.ts | 2 +- src/controllers/auth/types.ts | 2 +- src/controllers/channel/channel.ts | 31 ++- src/controllers/channel/types.ts | 13 +- src/controllers/community/community.ts | 1 + src/controllers/community/types.ts | 1 + src/controllers/errors.ts | 6 + src/controllers/invite/index.ts | 3 + src/controllers/invite/invite.ts | 110 ++++++++++ src/controllers/invite/routes.ts | 10 + src/controllers/invite/types.ts | 70 ++++++ src/controllers/role/role.ts | 31 ++- src/controllers/role/types.ts | 13 +- src/controllers/session/routes.ts | 1 + src/controllers/session/session.ts | 65 +++++- src/controllers/session/types.ts | 30 ++- src/controllers/test/test.ts | 40 ++++ src/helpers.ts | 59 ------ src/index.ts | 2 + src/services/auth/auth.ts | 27 ++- src/services/auth/helpers.ts | 199 ++++++++++++++++++ src/services/auth/permission.ts | 13 ++ src/services/auth/types.ts | 21 +- src/services/channel/channel.ts | 30 ++- src/services/invite/index.ts | 1 + src/services/invite/invite.ts | 128 +++++++++++ src/services/role/role.ts | 30 ++- src/services/session/session.ts | 51 ++++- src/services/user/user.ts | 10 +- 35 files changed, 1040 insertions(+), 164 deletions(-) create mode 100644 prisma/migrations/20251224125500_relations/migration.sql create mode 100644 prisma/migrations/20251224231937_creationdate/migration.sql create mode 100644 prisma/migrations/20251225000930_permissions/migration.sql create mode 100644 prisma/migrations/20251225001552_roles/migration.sql create mode 100644 prisma/migrations/20251225001619_roles2/migration.sql create mode 100644 src/controllers/errors.ts create mode 100644 src/controllers/invite/index.ts create mode 100644 src/controllers/invite/invite.ts create mode 100644 src/controllers/invite/routes.ts create mode 100644 src/controllers/invite/types.ts create mode 100644 src/services/auth/helpers.ts create mode 100644 src/services/auth/permission.ts create mode 100644 src/services/invite/index.ts create mode 100644 src/services/invite/invite.ts diff --git a/prisma/migrations/20251224125500_relations/migration.sql b/prisma/migrations/20251224125500_relations/migration.sql new file mode 100644 index 0000000..67ca2dd --- /dev/null +++ b/prisma/migrations/20251224125500_relations/migration.sql @@ -0,0 +1,50 @@ +/* + Warnings: + + - You are about to drop the column `communityId` on the `User` table. All the data in the column will be lost. + - Added the required column `ownerId` to the `Community` table without a default value. This is not possible if the table is not empty. + - Added the required column `creatorId` to the `Invite` table without a default value. This is not possible if the table is not empty. + - Made the column `communityId` on table `Invite` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "Invite" DROP CONSTRAINT "Invite_communityId_fkey"; + +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_communityId_fkey"; + +-- AlterTable +ALTER TABLE "Community" ADD COLUMN "ownerId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Invite" ADD COLUMN "creatorId" TEXT NOT NULL, +ALTER COLUMN "communityId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "communityId"; + +-- CreateTable +CREATE TABLE "_MembersCommunitiesToUsers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_MembersCommunitiesToUsers_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_MembersCommunitiesToUsers_B_index" ON "_MembersCommunitiesToUsers"("B"); + +-- AddForeignKey +ALTER TABLE "Community" ADD CONSTRAINT "Community_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MembersCommunitiesToUsers" ADD CONSTRAINT "_MembersCommunitiesToUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MembersCommunitiesToUsers" ADD CONSTRAINT "_MembersCommunitiesToUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251224231937_creationdate/migration.sql b/prisma/migrations/20251224231937_creationdate/migration.sql new file mode 100644 index 0000000..e329eda --- /dev/null +++ b/prisma/migrations/20251224231937_creationdate/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Channel" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Community" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Role" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20251225000930_permissions/migration.sql b/prisma/migrations/20251225000930_permissions/migration.sql new file mode 100644 index 0000000..6fb7d29 --- /dev/null +++ b/prisma/migrations/20251225000930_permissions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Role" ADD COLUMN "permissions" TEXT[]; diff --git a/prisma/migrations/20251225001552_roles/migration.sql b/prisma/migrations/20251225001552_roles/migration.sql new file mode 100644 index 0000000..f7e5b8a --- /dev/null +++ b/prisma/migrations/20251225001552_roles/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_UsersRoleToUsers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_UsersRoleToUsers_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_UsersRoleToUsers_B_index" ON "_UsersRoleToUsers"("B"); + +-- AddForeignKey +ALTER TABLE "_UsersRoleToUsers" ADD CONSTRAINT "_UsersRoleToUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UsersRoleToUsers" ADD CONSTRAINT "_UsersRoleToUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251225001619_roles2/migration.sql b/prisma/migrations/20251225001619_roles2/migration.sql new file mode 100644 index 0000000..a8be551 --- /dev/null +++ b/prisma/migrations/20251225001619_roles2/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - You are about to drop the `_UsersRoleToUsers` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_UsersRoleToUsers" DROP CONSTRAINT "_UsersRoleToUsers_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_UsersRoleToUsers" DROP CONSTRAINT "_UsersRoleToUsers_B_fkey"; + +-- DropTable +DROP TABLE "_UsersRoleToUsers"; + +-- CreateTable +CREATE TABLE "_UsersRolesToUsers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_UsersRolesToUsers_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_UsersRolesToUsers_B_index" ON "_UsersRolesToUsers"("B"); + +-- AddForeignKey +ALTER TABLE "_UsersRolesToUsers" ADD CONSTRAINT "_UsersRolesToUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UsersRolesToUsers" ADD CONSTRAINT "_UsersRolesToUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 06673fb..34c45c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,64 +1,76 @@ generator client { - provider = "prisma-client" - output = "../src/generated/prisma" - engineType = "client" + provider = "prisma-client" + output = "../src/generated/prisma" + engineType = "client" } datasource db { - provider = "postgresql" + provider = "postgresql" } model Community { - id String @id @unique @default(uuid()) - name String @unique - description String? - members User[] - Channel Channel[] - Role Role[] - invites Invite[] + id String @id @unique @default(uuid()) + name String @unique + description String? + creationDate DateTime @default(now()) + User User @relation(name: "OwnerCommunityToUser", fields: [ownerId], references: [id]) + ownerId String + members User[] @relation(name: "MembersCommunitiesToUsers") + channels Channel[] + roles Role[] + invites Invite[] } model Channel { - id String @id @unique @default(uuid()) - name String? - community Community? @relation(fields: [communityId], references: [id]) - communityId String? + id String @id @unique @default(uuid()) + name String? + community Community? @relation(fields: [communityId], references: [id]) + communityId String? + creationDate DateTime @default(now()) } model Role { - id String @id @unique @default(uuid()) - name String? - community Community @relation(fields: [communityId], references: [id]) - communityId String + id String @id @unique @default(uuid()) + name String? + community Community @relation(fields: [communityId], references: [id]) + communityId String + users User[] @relation(name: "UsersRolesToUsers") + permissions String[] + creationDate DateTime @default(now()) } model User { - id String @id @unique @default(uuid()) - username String @unique - email String? @unique - passwordHash String? - description String? - admin Boolean @default(false) - registerDate DateTime @default(now()) - lastLogin DateTime? - Community Community? @relation(fields: [communityId], references: [id]) - communityId String? - Session Session[] + id String @id @unique @default(uuid()) + username String @unique + email String? @unique + passwordHash String? + description String? + admin Boolean @default(false) + registerDate DateTime @default(now()) + lastLogin DateTime? + Session Session[] + ownedInvites Invite[] + ownedCommunities Community[] @relation(name: "OwnerCommunityToUser") + communities Community[] @relation(name: "MembersCommunitiesToUsers") + roles Role[] @relation(name: "UsersRolesToUsers") } model Session { - id String @id @unique @default(uuid()) - owner User @relation(fields: [userId], references: [id]) - token String - userId String + id String @id @unique @default(uuid()) + owner User @relation(fields: [userId], references: [id]) + userId String + token String + creationDate DateTime @default(now()) } model Invite { - id String @id @unique @default(uuid()) - Community Community? @relation(fields: [communityId], references: [id]) - communityId String? - totalInvites Int @default(0) - remainingInvites Int @default(0) - creationDate DateTime @default(now()) - expirationDate DateTime? + id String @id @unique @default(uuid()) + Community Community @relation(fields: [communityId], references: [id]) + communityId String + User User @relation(fields: [creatorId], references: [id]) + creatorId String + totalInvites Int @default(0) + remainingInvites Int @default(0) + creationDate DateTime @default(now()) + expirationDate DateTime? } diff --git a/src/controllers/auth/auth.ts b/src/controllers/auth/auth.ts index 500f3a9..7a3b921 100644 --- a/src/controllers/auth/auth.ts +++ b/src/controllers/auth/auth.ts @@ -41,7 +41,7 @@ const postLogin = async (request: FastifyRequest, _reply: FastifyReply) => { if (!session) { return { - ownerId: "", + username: username, error: "incorrect credentials", } as ILoginResponseError; } diff --git a/src/controllers/auth/types.ts b/src/controllers/auth/types.ts index d5211f2..b812f2b 100644 --- a/src/controllers/auth/types.ts +++ b/src/controllers/auth/types.ts @@ -26,7 +26,7 @@ interface ILoginResponseSuccess { } interface ILoginResponseError { - ownerId: string; + username: string; error: string; } diff --git a/src/controllers/channel/channel.ts b/src/controllers/channel/channel.ts index 4de076c..97bbe94 100644 --- a/src/controllers/channel/channel.ts +++ b/src/controllers/channel/channel.ts @@ -1,27 +1,38 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; import type { - IChannelParams, - IChannelResponseError, - IChannelResponseSuccess, + IGetChannelParams, + IGetChannelResponseError, + IGetChannelResponseSuccess, } from "./types.js"; -import { getChannelById } from "../../services/channel/channel.js"; +import { getChannelByIdAuth } from "../../services/channel/channel.js"; +import { API_ERROR } from "../errors.js"; -const getChannel = async (request: FastifyRequest, _reply: FastifyReply) => { - const { id } = request.params as IChannelParams; +const getChannel = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetChannelParams; + const authHeader = request.headers["authorization"]; - const channel = await getChannelById(id); + const channel = await getChannelByIdAuth(id, authHeader); if (!channel) { + reply.status(404); return { id: id, - error: "channel does not exist", - } as IChannelResponseError; + error: API_ERROR.NOT_FOUND, + } as IGetChannelResponseError; + } + if (channel === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetChannelResponseError; } return { id: channel.id, name: channel.name, communityId: channel.communityId, - } as IChannelResponseSuccess; + creationDate: channel.creationDate.getTime(), + } as IGetChannelResponseSuccess; }; export { getChannel }; diff --git a/src/controllers/channel/types.ts b/src/controllers/channel/types.ts index c74e1d9..2ed4e27 100644 --- a/src/controllers/channel/types.ts +++ b/src/controllers/channel/types.ts @@ -1,20 +1,21 @@ -interface IChannelParams { +interface IGetChannelParams { id: string; } -interface IChannelResponseError { +interface IGetChannelResponseError { id: string; error: string; } -interface IChannelResponseSuccess { +interface IGetChannelResponseSuccess { id: string; name: string; communityId: string; + creationDate: number; } export { - type IChannelParams, - type IChannelResponseError, - type IChannelResponseSuccess, + type IGetChannelParams, + type IGetChannelResponseError, + type IGetChannelResponseSuccess, }; diff --git a/src/controllers/community/community.ts b/src/controllers/community/community.ts index 4233ae5..8f1188d 100644 --- a/src/controllers/community/community.ts +++ b/src/controllers/community/community.ts @@ -21,6 +21,7 @@ const getCommunity = async (request: FastifyRequest, _reply: FastifyReply) => { id: community.id, name: community.name, description: community.description, + creationDate: community.creationDate.getTime(), } as ICommunityResponseSuccess; }; diff --git a/src/controllers/community/types.ts b/src/controllers/community/types.ts index 362fb30..2fda38a 100644 --- a/src/controllers/community/types.ts +++ b/src/controllers/community/types.ts @@ -11,6 +11,7 @@ interface ICommunityResponseSuccess { id: string; name: string; description: string; + creationDate: number; } export { diff --git a/src/controllers/errors.ts b/src/controllers/errors.ts new file mode 100644 index 0000000..389792b --- /dev/null +++ b/src/controllers/errors.ts @@ -0,0 +1,6 @@ +enum API_ERROR { + NOT_FOUND = "NOT_FOUND", + ACCESS_DENIED = "ACCESS_DENIED", +} + +export { API_ERROR }; diff --git a/src/controllers/invite/index.ts b/src/controllers/invite/index.ts new file mode 100644 index 0000000..365b9bc --- /dev/null +++ b/src/controllers/invite/index.ts @@ -0,0 +1,3 @@ +export * from "./invite.js"; +export * from "./routes.js"; +export * from "./types.js"; diff --git a/src/controllers/invite/invite.ts b/src/controllers/invite/invite.ts new file mode 100644 index 0000000..f6c6f12 --- /dev/null +++ b/src/controllers/invite/invite.ts @@ -0,0 +1,110 @@ +import { type FastifyReply, type FastifyRequest } from "fastify"; +import type { + IDeleteInviteParams, + IDeleteInviteResponseError, + IDeleteInviteResponseSuccess, + IGetInviteParams, + IGetInviteResponseError, + IGetInviteResponseSuccess, + IPostAcceptInviteParams, + IPostAcceptInviteRequest, + IPostAcceptDeleteInviteResponseError, + IPostAcceptDeleteInviteResponseSuccess, +} from "./types.js"; +import { + getInviteById, + deleteInviteByIdAuth, + acceptInviteByIdAuth, + hasUnlimitedInvites, + isInviteValid, +} from "../../services/invite/invite.js"; +import { API_ERROR } from "../errors.js"; +import { getUserById } from "../../services/user/user.js"; + +const getInvite = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetInviteParams; + + const invite = await getInviteById(id); + if (!invite) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IGetInviteResponseError; + } + + return { + id: invite.id, + communityId: invite.communityId, + valid: isInviteValid(invite), + unlimitedInvites: hasUnlimitedInvites(invite), + hasExpiration: invite.expirationDate != null, + remainingInvites: invite.remainingInvites, + creationDate: invite.creationDate.getTime(), + expirationDate: invite.expirationDate?.getTime() ?? 0, + } as IGetInviteResponseSuccess; +}; + +const deleteInvite = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IDeleteInviteParams; + const authHeader = request.headers["authorization"]; + + const invite = await deleteInviteByIdAuth(id, authHeader); + if (!invite) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IDeleteInviteResponseError; + } + if (invite === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IDeleteInviteResponseError; + } + + return { + id: invite.id, + communityId: invite.communityId, + } as IDeleteInviteResponseSuccess; +}; + +const postAcceptInvite = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { id } = request.params as IPostAcceptInviteParams; + const { userId } = request.body as IPostAcceptInviteRequest; + const authHeader = request.headers["authorization"]; + + const community = await acceptInviteByIdAuth(id, authHeader); + const user = await getUserById(id); + if (!community || !user) { + reply.status(404); + return { + id: id, + userId: userId, + error: API_ERROR.NOT_FOUND, + } as IPostAcceptDeleteInviteResponseError; + } + if (community === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + userId: userId, + error: API_ERROR.ACCESS_DENIED, + } as IPostAcceptDeleteInviteResponseError; + } + + return { + id: id, + userId: user.id, + userName: user.username, + communityId: community.id, + communityName: community.name, + } as IPostAcceptDeleteInviteResponseSuccess; +}; + +export { getInvite, deleteInvite, postAcceptInvite }; diff --git a/src/controllers/invite/routes.ts b/src/controllers/invite/routes.ts new file mode 100644 index 0000000..712a105 --- /dev/null +++ b/src/controllers/invite/routes.ts @@ -0,0 +1,10 @@ +import { type FastifyInstance } from "fastify"; +import * as controller from "./index.js"; + +const inviteRoutes = async (fastify: FastifyInstance) => { + fastify.get(`/:id`, controller.getInvite); + fastify.delete(`/:id`, controller.deleteInvite); + fastify.post(`/:id/accept`, controller.postAcceptInvite); +}; + +export { inviteRoutes }; diff --git a/src/controllers/invite/types.ts b/src/controllers/invite/types.ts new file mode 100644 index 0000000..e05e56f --- /dev/null +++ b/src/controllers/invite/types.ts @@ -0,0 +1,70 @@ +import type { API_ERROR } from "../errors.js"; + +interface IGetInviteParams { + id: string; +} + +interface IGetInviteResponseError { + id: string; + error: API_ERROR; +} + +interface IGetInviteResponseSuccess { + id: string; + communityId: string; + valid: boolean; + unlimitedInvites: boolean; + hasExpiration: boolean; + remainingInvites: number; + creationDate: number; + expirationDate: number; +} + +interface IDeleteInviteParams { + id: string; +} + +interface IDeleteInviteResponseError { + id: string; + error: API_ERROR; +} + +interface IDeleteInviteResponseSuccess { + id: string; + communityId: string; +} + +interface IPostAcceptInviteParams { + id: string; +} + +interface IPostAcceptInviteRequest { + userId: string; +} + +interface IPostAcceptDeleteInviteResponseError { + id: string; + userId: string; + error: API_ERROR; +} + +interface IPostAcceptDeleteInviteResponseSuccess { + id: string; + userId: string; + userName: string; + communityId: string; + communityName: string; +} + +export { + type IGetInviteParams, + type IGetInviteResponseError, + type IGetInviteResponseSuccess, + type IDeleteInviteParams, + type IDeleteInviteResponseError, + type IDeleteInviteResponseSuccess, + type IPostAcceptInviteParams, + type IPostAcceptInviteRequest, + type IPostAcceptDeleteInviteResponseError, + type IPostAcceptDeleteInviteResponseSuccess, +}; diff --git a/src/controllers/role/role.ts b/src/controllers/role/role.ts index 7572886..4f93412 100644 --- a/src/controllers/role/role.ts +++ b/src/controllers/role/role.ts @@ -1,27 +1,38 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; import type { - IRoleParams, - IRoleResponseError, - IRoleResponseSuccess, + IGetRoleParams, + IGetRoleResponseError, + IGetRoleResponseSuccess, } from "./types.js"; -import { getRoleById } from "../../services/role/role.js"; +import { getRoleByIdAuth } from "../../services/role/role.js"; +import { API_ERROR } from "../errors.js"; -const getRole = async (request: FastifyRequest, _reply: FastifyReply) => { - const { id } = request.params as IRoleParams; +const getRole = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetRoleParams; + const authHeader = request.headers["authorization"]; - const role = await getRoleById(id); + const role = await getRoleByIdAuth(id, authHeader); if (!role) { + reply.status(404); return { id: id, - error: "role does not exist", - } as IRoleResponseError; + error: API_ERROR.NOT_FOUND, + } as IGetRoleResponseError; + } + if (role === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetRoleResponseError; } return { id: role.id, name: role.name, communityId: role.communityId, - } as IRoleResponseSuccess; + creationDate: role.creationDate.getTime(), + } as IGetRoleResponseSuccess; }; export { getRole }; diff --git a/src/controllers/role/types.ts b/src/controllers/role/types.ts index edf5d02..00fefc7 100644 --- a/src/controllers/role/types.ts +++ b/src/controllers/role/types.ts @@ -1,16 +1,21 @@ -interface IRoleParams { +interface IGetRoleParams { id: string; } -interface IRoleResponseError { +interface IGetRoleResponseError { id: string; error: string; } -interface IRoleResponseSuccess { +interface IGetRoleResponseSuccess { id: string; name: string; communityId: string; + creationDate: number; } -export { type IRoleParams, type IRoleResponseError, type IRoleResponseSuccess }; +export { + type IGetRoleParams, + type IGetRoleResponseError, + type IGetRoleResponseSuccess, +}; diff --git a/src/controllers/session/routes.ts b/src/controllers/session/routes.ts index f43e748..0c04801 100644 --- a/src/controllers/session/routes.ts +++ b/src/controllers/session/routes.ts @@ -3,6 +3,7 @@ import * as controller from "./session.js"; const sessionRoutes = async (fastify: FastifyInstance) => { fastify.get(`/:id`, controller.getSession); + fastify.delete(`/:id`, controller.deleteSession); }; export { sessionRoutes }; diff --git a/src/controllers/session/session.ts b/src/controllers/session/session.ts index e4860c9..8cacd0e 100644 --- a/src/controllers/session/session.ts +++ b/src/controllers/session/session.ts @@ -1,26 +1,69 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; import type { - ISessionParams, - ISessionResponseError, - ISessionResponseSuccess, + IGetSessionParams, + IGetSessionResponseError, + IGetSessionResponseSuccess, + IDeleteSessionParams, + IDeleteSessionResponseError, + IDeleteSessionResponseSuccess, } from "./types.js"; -import { getSessionById } from "../../services/session/session.js"; +import { + deleteSessionByIdAuth, + getSessionByIdAuth, +} from "../../services/session/session.js"; +import { API_ERROR } from "../errors.js"; -const getSession = async (request: FastifyRequest, _reply: FastifyReply) => { - const { id } = request.params as ISessionParams; +const getSession = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetSessionParams; + const authHeader = request.headers["authorization"]; - const session = await getSessionById(id); + const session = await getSessionByIdAuth(id, authHeader); if (!session) { + reply.status(404); return { id: id, - error: "session does not exist", - } as ISessionResponseError; + error: API_ERROR.NOT_FOUND, + } as IGetSessionResponseError; + } + if (session === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetSessionResponseError; } return { id: session.id, userId: session.userId, - } as ISessionResponseSuccess; + creationDate: session.creationDate.getTime(), + } as IGetSessionResponseSuccess; }; -export { getSession }; +const deleteSession = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IDeleteSessionParams; + const authHeader = request.headers["authorization"]; + + const session = await deleteSessionByIdAuth(id, authHeader); + if (!session) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IDeleteSessionResponseError; + } + if (session === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IDeleteSessionResponseError; + } + + return { + id: session.id, + userId: session.userId, + } as IDeleteSessionResponseSuccess; +}; + +export { getSession, deleteSession }; diff --git a/src/controllers/session/types.ts b/src/controllers/session/types.ts index 06cd158..3d1aa5f 100644 --- a/src/controllers/session/types.ts +++ b/src/controllers/session/types.ts @@ -1,19 +1,37 @@ -interface ISessionParams { +interface IGetSessionParams { id: string; } -interface ISessionResponseError { +interface IGetSessionResponseError { id: string; error: string; } -interface ISessionResponseSuccess { +interface IGetSessionResponseSuccess { + id: string; + userId: string; + creationDate: number; +} + +interface IDeleteSessionParams { + id: string; +} + +interface IDeleteSessionResponseError { + id: string; + error: string; +} + +interface IDeleteSessionResponseSuccess { id: string; userId: string; } export { - type ISessionParams, - type ISessionResponseError, - type ISessionResponseSuccess, + type IGetSessionParams, + type IGetSessionResponseError, + type IGetSessionResponseSuccess, + type IDeleteSessionParams, + type IDeleteSessionResponseError, + type IDeleteSessionResponseSuccess, }; diff --git a/src/controllers/test/test.ts b/src/controllers/test/test.ts index 773dc4b..f56a90a 100644 --- a/src/controllers/test/test.ts +++ b/src/controllers/test/test.ts @@ -1,10 +1,50 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; +import { getDB } from "../../store/store.js"; +import { hashPassword } from "../../services/auth/auth.js"; +import { getUserFromAuth } from "../../services/auth/helpers.js"; const getPing = async (_request: FastifyRequest, _reply: FastifyReply) => { return [{ message: "pong" }]; }; const getTest = async (_request: FastifyRequest, _reply: FastifyReply) => { + const authHeader = _request.headers["authorization"]; + return [{ user: await getUserFromAuth(authHeader) }]; + + const owner = await getDB().user.create({ + data: { + username: "TestUser", + passwordHash: await hashPassword("29144"), + email: "testuser@aslan2142.space", + admin: true, + }, + }); + + await getDB().community.create({ + data: { + name: "TestCommunity", + ownerId: owner.id, + members: { + connect: { + id: owner.id, + }, + create: [ + { + username: "Aslo", + passwordHash: await hashPassword("pass"), + admin: false, + }, + { + username: "Aslan", + passwordHash: await hashPassword("8556"), + email: "aslan@aslan2142.space", + admin: false, + }, + ], + }, + }, + }); + return [{ message: "ok" }]; }; diff --git a/src/helpers.ts b/src/helpers.ts index c4b7933..473a0f4 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,59 +0,0 @@ -import jwt from "jsonwebtoken"; -import type { Session, User } from "./generated/prisma/client.js"; -import { getDB } from "./store/store.js"; - -const getJwtSecret = () => { - return process.env.JWT_SECRET || ""; -}; - -const verifyToken = (token: string): string | jwt.JwtPayload | null => { - try { - return jwt.verify(token, getJwtSecret()); - } catch { - return null; - } -}; - -const getSessionFromToken = async (token: string): Promise => { - return await getDB().session.findFirst({ - where: { - token: token, - }, - }); -}; - -const getUserFromToken = async (token: string): Promise => { - const session = await getSessionFromToken(token); - - return await getDB().user.findFirst({ - where: { - id: session?.userId ?? "invalid", - }, - }); -}; - -const getUserFromAuth = async ( - authHeader: string | undefined, -): Promise => { - const token = authHeader?.replace("Bearer ", ""); - - const verified = verifyToken(token ?? "") !== null; - if (!verified || !token) { - return null; - } - - const user = await getUserFromToken(token); - if (!user) { - return null; - } - - return user; -}; - -export { - getJwtSecret, - verifyToken, - getSessionFromToken, - getUserFromToken, - getUserFromAuth, -}; diff --git a/src/index.ts b/src/index.ts index 0c268cf..f203476 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { sessionRoutes } from "./controllers/session/routes.js"; 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"; const app = Fastify({ logger: true, @@ -20,6 +21,7 @@ app.register(sessionRoutes, { prefix: "/api/v1/session" }); 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.listen({ port: config.port }, (err, address) => { if (err) throw err; diff --git a/src/services/auth/auth.ts b/src/services/auth/auth.ts index b496aba..68e64d6 100644 --- a/src/services/auth/auth.ts +++ b/src/services/auth/auth.ts @@ -4,7 +4,7 @@ import jwt from "jsonwebtoken"; import type { User, Session } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; import type { IUserLogin, IUserRegistration } from "./types.js"; -import { getJwtSecret } from "../../helpers.js"; +import { getJwtSecret } from "./helpers.js"; const registerUser = async ( registration: IUserRegistration, @@ -38,16 +38,27 @@ const loginUser = async (login: IUserLogin): Promise => { const user = await getDB().user.findUnique({ where: { username: login.username }, }); - - const passwordCorrect = await argon2.verify( - user?.passwordHash ?? "", - login.password, - ); - - if (!user || !passwordCorrect) { + if (!user || !user.passwordHash) { return null; } + const passwordCorrect = await argon2.verify( + user.passwordHash, + login.password, + ); + if (!passwordCorrect) { + return null; + } + + await getDB().user.update({ + data: { + lastLogin: new Date(), + }, + where: { + id: user.id, + }, + }); + return await getDB().session.create({ data: { token: createToken(user.id), diff --git a/src/services/auth/helpers.ts b/src/services/auth/helpers.ts new file mode 100644 index 0000000..9c667c1 --- /dev/null +++ b/src/services/auth/helpers.ts @@ -0,0 +1,199 @@ +import jwt from "jsonwebtoken"; +import type { + Community, + Session, + User, +} from "../../generated/prisma/client.js"; +import { getDB } from "../../store/store.js"; +import type { IOwnerCheck } from "./types.js"; +import type { PERMISSION } from "./permission.js"; + +const getJwtSecret = () => { + return process.env.JWT_SECRET || ""; +}; + +const verifyToken = (token: string): string | jwt.JwtPayload | null => { + try { + return jwt.verify(token, getJwtSecret()); + } catch { + return null; + } +}; + +const getSessionFromToken = async (token: string): Promise => { + return await getDB().session.findFirst({ + where: { + token: token, + }, + }); +}; + +const getUserFromToken = async (token: string): Promise => { + const session = await getSessionFromToken(token); + + return await getDB().user.findFirst({ + where: { + id: session?.userId ?? "invalid", + }, + }); +}; + +const getUserFromAuth = async ( + authHeader: string | undefined, +): Promise => { + const token = authHeader?.replace("Bearer ", ""); + + const verified = verifyToken(token ?? "") !== null; + if (!verified || !token) { + return null; + } + + const user = await getUserFromToken(token); + if (!user) { + return null; + } + + return user; +}; + +const isUserOwnerOrAdmin = async ( + user: User | null, + ownerCheck: IOwnerCheck, +): Promise => { + if (!user) { + return false; + } + + if (user.admin) { + return true; + } + + if (ownerCheck.user !== undefined && ownerCheck?.user?.id !== user.id) { + return false; + } + if ( + ownerCheck.session !== undefined && + ownerCheck.session?.userId !== user.id + ) { + return false; + } + if ( + ownerCheck.community !== undefined && + ownerCheck.community?.ownerId !== user.id + ) { + return false; + } + if ( + ownerCheck.invite !== undefined && + ownerCheck.invite?.creatorId !== user.id + ) { + return false; + } + if (ownerCheck.channel !== undefined) { + return false; + } + if (ownerCheck.role !== undefined) { + return false; + } + + return true; +}; + +const getUserPermissions = async ( + user: User | null, + community: Community | null, +): Promise => { + if (!user || !community) { + return []; + } + + const roles = await getDB().role.findMany({ + where: { + communityId: community.id, + users: { + some: { + id: user.id, + }, + }, + }, + }); + if (!roles || roles.length < 1) { + return []; + } + + const permissions = new Set(); + roles.forEach((role) => { + role.permissions.forEach((permission) => { + permissions.add(permission as PERMISSION); + }); + }); + + return [...permissions]; +}; + +const userHasPermissions = async ( + user: User | null, + community: Community | null, + requiredPermissions: PERMISSION[], +): Promise => { + if (!user || !community) { + return false; + } + + const userPermissions = await getUserPermissions(user, community); + + return requiredPermissions.every((requiredPermission) => + userPermissions.includes(requiredPermission), + ); +}; + +const isUserAllowed = async ( + user: User | null, + ownerCheck: IOwnerCheck, + community: Community | null, + requiredPermissions: PERMISSION[], +): Promise => { + if (await isUserOwnerOrAdmin(user, ownerCheck)) { + return true; + } + if (await userHasPermissions(user, community, requiredPermissions)) { + return true; + } + + return false; +}; + +const isUserInCommunity = async ( + user: User | null, + community: Community | null, +): Promise => { + if (!user || !community) { + return false; + } + + return ( + (await getDB().community.findFirst({ + where: { + id: community.id, + members: { + some: { + id: user.id, + }, + }, + }, + })) !== null + ); +}; + +export { + getJwtSecret, + verifyToken, + getSessionFromToken, + getUserFromToken, + getUserFromAuth, + isUserOwnerOrAdmin, + getUserPermissions, + userHasPermissions, + isUserAllowed, + isUserInCommunity, +}; diff --git a/src/services/auth/permission.ts b/src/services/auth/permission.ts new file mode 100644 index 0000000..fb1f896 --- /dev/null +++ b/src/services/auth/permission.ts @@ -0,0 +1,13 @@ +enum PERMISSION { + COMMUNITY_MANAGE = "COMMUNITY_MANAGE", + CHANNELS_MANAGE = "CHANNELS_MANAGE", + CHANNELS_READ = "CHANNELS_READ", + ROLES_READ = "ROLES_READ", + INVITES_CREATE = "INVITES_CREATE", + INVITES_DELETE = "INVITES_DELETE", + ROLES_MANAGE = "ROLES_MANAGE", + USERS_KICK = "USERS_KICK", + USERS_BAN = "USERS_BAN", +} + +export { PERMISSION }; diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index 10580ea..4265074 100644 --- a/src/services/auth/types.ts +++ b/src/services/auth/types.ts @@ -1,3 +1,13 @@ +import type { + Channel, + Community, + Invite, + Role, + Session, + User, +} from "../../generated/prisma/client.js"; +import type { PERMISSION } from "./permission.js"; + interface IUserRegistration { username: string; password: string; @@ -9,4 +19,13 @@ interface IUserLogin { password: string; } -export { type IUserRegistration, type IUserLogin }; +interface IOwnerCheck { + user?: User | null; + session?: Session | null; + community?: Community | null; + invite?: Invite | null; + channel?: Channel | null; + role?: Role | null; +} + +export { type IUserRegistration, type IUserLogin, type IOwnerCheck }; diff --git a/src/services/channel/channel.ts b/src/services/channel/channel.ts index 5763fe6..b924a5e 100644 --- a/src/services/channel/channel.ts +++ b/src/services/channel/channel.ts @@ -1,5 +1,9 @@ +import { API_ERROR } from "../../controllers/errors.js"; import type { Channel } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; +import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; +import { PERMISSION } from "../auth/permission.js"; +import { getCommunityById } from "../community/community.js"; const getChannelById = async (id: string): Promise => { return await getDB().channel.findUnique({ @@ -7,4 +11,28 @@ const getChannelById = async (id: string): Promise => { }); }; -export { getChannelById }; +const getChannelByIdAuth = 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, + { + channel: channel, + }, + community, + [PERMISSION.CHANNELS_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return channel; +}; + +export { getChannelById, getChannelByIdAuth }; diff --git a/src/services/invite/index.ts b/src/services/invite/index.ts new file mode 100644 index 0000000..57f0b4c --- /dev/null +++ b/src/services/invite/index.ts @@ -0,0 +1 @@ +export * from "./invite.js"; diff --git a/src/services/invite/invite.ts b/src/services/invite/invite.ts new file mode 100644 index 0000000..f20b8e8 --- /dev/null +++ b/src/services/invite/invite.ts @@ -0,0 +1,128 @@ +import type { Community, Invite } from "../../generated/prisma/client.js"; +import { + getUserFromAuth, + isUserAllowed, + isUserInCommunity, + isUserOwnerOrAdmin, + userHasPermissions, +} from "../auth/helpers.js"; +import { getDB } from "../../store/store.js"; +import { getCommunityById } from "../community/community.js"; +import { PERMISSION } from "../auth/permission.js"; +import { API_ERROR } from "../../controllers/errors.js"; + +const getInviteById = async (id: string): Promise => { + return await getDB().invite.findUnique({ + where: { id: id }, + }); +}; + +const deleteInviteById = async (id: string): Promise => { + return await getDB().invite.delete({ + where: { id: id }, + }); +}; + +const deleteInviteByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const invite = await deleteInviteById(id); + const community = await getCommunityById(invite?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + invite: invite, + }, + community, + [PERMISSION.INVITES_DELETE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return invite; +}; + +const acceptInviteById = async ( + id: string, + userId: string, +): Promise => { + const invite = await getInviteById(id); + if (!invite) { + return null; + } + + await getDB().invite.update({ + where: { + id: id, + }, + data: { + remainingInvites: invite?.remainingInvites - 1, + }, + }); + + return await getDB().community.update({ + where: { + id: invite.communityId, + }, + data: { + members: { + connect: { + id: userId, + }, + }, + }, + }); +}; + +const acceptInviteByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const invite = await getInviteById(id); + const community = await getCommunityById(invite?.communityId ?? ""); + if (!authUser || !invite || !community) { + return API_ERROR.ACCESS_DENIED; + } + + if (await isUserInCommunity(authUser, community)) { + return API_ERROR.ACCESS_DENIED; + } + + return await acceptInviteById(id, authUser.id); +}; + +const isInviteValid = (invite: Invite): boolean => { + if (!hasUnlimitedInvites(invite) && invite.remainingInvites < 1) { + return false; + } + + const currentDate = Date.now(); + if ( + invite.expirationDate && + invite.expirationDate.getTime() <= currentDate + ) { + return false; + } + + return true; +}; + +const hasUnlimitedInvites = (invite: Invite): boolean => { + return invite.totalInvites === 0; +}; + +export { + getInviteById, + deleteInviteById, + deleteInviteByIdAuth, + acceptInviteById, + acceptInviteByIdAuth, + isInviteValid, + hasUnlimitedInvites, +}; diff --git a/src/services/role/role.ts b/src/services/role/role.ts index 3183234..a91adb8 100644 --- a/src/services/role/role.ts +++ b/src/services/role/role.ts @@ -1,5 +1,9 @@ +import { API_ERROR } from "../../controllers/errors.js"; import type { Role } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; +import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; +import { PERMISSION } from "../auth/permission.js"; +import { getCommunityById } from "../community/community.js"; const getRoleById = async (id: string): Promise => { return await getDB().role.findUnique({ @@ -7,4 +11,28 @@ const getRoleById = async (id: string): Promise => { }); }; -export { getRoleById }; +const getRoleByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const role = await getRoleById(id); + const community = await getCommunityById(role?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + role: role, + }, + community, + [PERMISSION.ROLES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return role; +}; + +export { getRoleById, getRoleByIdAuth }; diff --git a/src/services/session/session.ts b/src/services/session/session.ts index 0d7baa6..d594df2 100644 --- a/src/services/session/session.ts +++ b/src/services/session/session.ts @@ -1,5 +1,7 @@ +import { API_ERROR } from "../../controllers/errors.js"; import type { Session } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; +import { getUserFromAuth, isUserOwnerOrAdmin } from "../auth/helpers.js"; const getSessionById = async (id: string): Promise => { return await getDB().session.findUnique({ @@ -7,4 +9,51 @@ const getSessionById = async (id: string): Promise => { }); }; -export { getSessionById }; +const getSessionByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const session = await getSessionById(id); + + if ( + !(await isUserOwnerOrAdmin(authUser, { + session: session, + })) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return session; +}; + +const deleteSessionById = async (id: string): Promise => { + return await getDB().session.delete({ + where: { id: id }, + }); +}; + +const deleteSessionByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const session = await deleteSessionById(id); + + if ( + !(await isUserOwnerOrAdmin(authUser, { + session: session, + })) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return session; +}; + +export { + getSessionById, + getSessionByIdAuth, + deleteSessionById, + deleteSessionByIdAuth, +}; diff --git a/src/services/user/user.ts b/src/services/user/user.ts index 58a8079..e3e57fd 100644 --- a/src/services/user/user.ts +++ b/src/services/user/user.ts @@ -1,5 +1,5 @@ import type { User, Session } from "../../generated/prisma/client.js"; -import { getUserFromAuth } from "../../helpers.js"; +import { getUserFromAuth, isUserOwnerOrAdmin } from "../auth/helpers.js"; import { getDB } from "../../store/store.js"; const getUserById = async (id: string): Promise => { @@ -12,8 +12,12 @@ const getUserSessionsById = async ( id: string, authHeader: string | undefined, ): Promise => { - const user = await getUserFromAuth(authHeader); - if (!user || user.id !== id) { + const authUser = await getUserFromAuth(authHeader); + if ( + !(await isUserOwnerOrAdmin(authUser, { + user: await getUserById(id), + })) + ) { return null; }