diff --git a/.gitignore b/.gitignore index 6563128..b09a84a 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dist /src/generated/prisma +files diff --git a/package-lock.json b/package-lock.json index a42f127..b5d265d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,24 @@ { "name": "tether", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tether", - "version": "0.5.2", + "version": "0.6.0", "license": "GPL-3.0-only", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/multipart": "^9.3.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", + "mime-types": "^3.0.2", "pg": "^8.16.3", "ua-parser-js": "^2.0.8", "uuid": "^13.0.0", @@ -24,6 +26,7 @@ }, "devDependencies": { "@types/jsonwebtoken": "^9.0.10", + "@types/mime-types": "^3.0.1", "@types/node": "^25.0.3", "@types/pg": "^8.16.0", "@types/ws": "^8.18.1", @@ -140,6 +143,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", @@ -180,6 +189,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.1.0.tgz", + "integrity": "sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -250,6 +275,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.3.0.tgz", + "integrity": "sha512-NpeKipTOjjL1dA7SSlRMrOWWtrE8/0yKOmeudkdQoEaz4sVDJw5MVdZIahsWhvpc3YTN7f04f9ep/Y65RKoOWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", @@ -591,6 +639,13 @@ "@types/node": "*" } }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1575,6 +1630,31 @@ "dev": true, "license": "ISC" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 08ab1bc..aa9e191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tether", - "version": "0.5.2", + "version": "0.6.0", "description": "Communication server using the Nexlink protocol", "repository": { "type": "git", @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/jsonwebtoken": "^9.0.10", + "@types/mime-types": "^3.0.1", "@types/node": "^25.0.3", "@types/pg": "^8.16.0", "@types/ws": "^8.18.1", @@ -28,12 +29,14 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", + "@fastify/multipart": "^9.3.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", + "mime-types": "^3.0.2", "pg": "^8.16.3", "ua-parser-js": "^2.0.8", "uuid": "^13.0.0", diff --git a/prisma/migrations/20260115105100_message_extras/migration.sql b/prisma/migrations/20260115105100_message_extras/migration.sql new file mode 100644 index 0000000..3a061b8 --- /dev/null +++ b/prisma/migrations/20260115105100_message_extras/migration.sql @@ -0,0 +1,61 @@ +/* + Warnings: + + - You are about to drop the column `edited` on the `Message` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Community" ADD COLUMN "avatar" TEXT; + +-- AlterTable +ALTER TABLE "Message" DROP COLUMN "edited", +ADD COLUMN "replyToId" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatar" TEXT; + +-- CreateTable +CREATE TABLE "Reaction" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + + CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Attachment" ( + "id" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + + CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Chunk" ( + "id" TEXT NOT NULL, + "iv" TEXT NOT NULL, + "attachmentId" TEXT, + + CONSTRAINT "Chunk_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Reaction_id_key" ON "Reaction"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Attachment_id_key" ON "Attachment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Chunk_id_key" ON "Chunk"("id"); + +-- AddForeignKey +ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Chunk" ADD CONSTRAINT "Chunk_attachmentId_fkey" FOREIGN KEY ("attachmentId") REFERENCES "Attachment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115105707_message_extras_2/migration.sql b/prisma/migrations/20260115105707_message_extras_2/migration.sql new file mode 100644 index 0000000..701a4de --- /dev/null +++ b/prisma/migrations/20260115105707_message_extras_2/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `Reaction` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Reaction" DROP COLUMN "userId"; + +-- CreateTable +CREATE TABLE "_ReactionToUser" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ReactionToUser_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_ReactionToUser_B_index" ON "_ReactionToUser"("B"); + +-- AddForeignKey +ALTER TABLE "_ReactionToUser" ADD CONSTRAINT "_ReactionToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Reaction"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ReactionToUser" ADD CONSTRAINT "_ReactionToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115105814_messages_edited/migration.sql b/prisma/migrations/20260115105814_messages_edited/migration.sql new file mode 100644 index 0000000..9f2cd0f --- /dev/null +++ b/prisma/migrations/20260115105814_messages_edited/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Message" ADD COLUMN "edited" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260115114337_message_extras_2/migration.sql b/prisma/migrations/20260115114337_message_extras_2/migration.sql new file mode 100644 index 0000000..a47fdb8 --- /dev/null +++ b/prisma/migrations/20260115114337_message_extras_2/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Attachment" DROP CONSTRAINT "Attachment_messageId_fkey"; + +-- AlterTable +ALTER TABLE "Attachment" ALTER COLUMN "messageId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Message" ADD COLUMN "attachments" TEXT[]; diff --git a/prisma/migrations/20260115114509_message_extra_3/migration.sql b/prisma/migrations/20260115114509_message_extra_3/migration.sql new file mode 100644 index 0000000..e7b36f6 --- /dev/null +++ b/prisma/migrations/20260115114509_message_extra_3/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Attachment" ADD COLUMN "communityId" TEXT; + +-- AddForeignKey +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115115729_message_extras_4/migration.sql b/prisma/migrations/20260115115729_message_extras_4/migration.sql new file mode 100644 index 0000000..dde8f0f --- /dev/null +++ b/prisma/migrations/20260115115729_message_extras_4/migration.sql @@ -0,0 +1,17 @@ +-- DropForeignKey +ALTER TABLE "Attachment" DROP CONSTRAINT "Attachment_communityId_fkey"; + +-- DropForeignKey +ALTER TABLE "Chunk" DROP CONSTRAINT "Chunk_attachmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "Reaction" DROP CONSTRAINT "Reaction_messageId_fkey"; + +-- AddForeignKey +ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Chunk" ADD CONSTRAINT "Chunk_attachmentId_fkey" FOREIGN KEY ("attachmentId") REFERENCES "Attachment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115122552_message_extras_5/migration.sql b/prisma/migrations/20260115122552_message_extras_5/migration.sql new file mode 100644 index 0000000..ce37209 --- /dev/null +++ b/prisma/migrations/20260115122552_message_extras_5/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - The primary key for the `Reaction` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Reaction` table. All the data in the column will be lost. + - A unique constraint covering the columns `[content]` on the table `Reaction` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "_ReactionToUser" DROP CONSTRAINT "_ReactionToUser_A_fkey"; + +-- DropIndex +DROP INDEX "Reaction_id_key"; + +-- AlterTable +ALTER TABLE "Reaction" DROP CONSTRAINT "Reaction_pkey", +DROP COLUMN "id", +ADD CONSTRAINT "Reaction_pkey" PRIMARY KEY ("content"); + +-- CreateIndex +CREATE UNIQUE INDEX "Reaction_content_key" ON "Reaction"("content"); + +-- AddForeignKey +ALTER TABLE "_ReactionToUser" ADD CONSTRAINT "_ReactionToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Reaction"("content") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115122709_message_extra_5_1/migration.sql b/prisma/migrations/20260115122709_message_extra_5_1/migration.sql new file mode 100644 index 0000000..6fd4d75 --- /dev/null +++ b/prisma/migrations/20260115122709_message_extra_5_1/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - The primary key for the `Reaction` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[id]` on the table `Reaction` will be added. If there are existing duplicate values, this will fail. + - The required column `id` was added to the `Reaction` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- DropForeignKey +ALTER TABLE "_ReactionToUser" DROP CONSTRAINT "_ReactionToUser_A_fkey"; + +-- DropIndex +DROP INDEX "Reaction_content_key"; + +-- AlterTable +ALTER TABLE "Reaction" DROP CONSTRAINT "Reaction_pkey", +ADD COLUMN "id" TEXT NOT NULL, +ADD CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Reaction_id_key" ON "Reaction"("id"); + +-- AddForeignKey +ALTER TABLE "_ReactionToUser" ADD CONSTRAINT "_ReactionToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Reaction"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115123550_attachment_name/migration.sql b/prisma/migrations/20260115123550_attachment_name/migration.sql new file mode 100644 index 0000000..9febe0b --- /dev/null +++ b/prisma/migrations/20260115123550_attachment_name/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `fileName` to the `Attachment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Attachment" ADD COLUMN "fileName" TEXT NOT NULL; diff --git a/prisma/migrations/20260115123636_attachment_date/migration.sql b/prisma/migrations/20260115123636_attachment_date/migration.sql new file mode 100644 index 0000000..fc7fedf --- /dev/null +++ b/prisma/migrations/20260115123636_attachment_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Attachment" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260115123915_messages_attachments/migration.sql b/prisma/migrations/20260115123915_messages_attachments/migration.sql new file mode 100644 index 0000000..8c24367 --- /dev/null +++ b/prisma/migrations/20260115123915_messages_attachments/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `attachments` on the `Message` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Message" DROP COLUMN "attachments"; + +-- AddForeignKey +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260115124028_message_attachment_2/migration.sql b/prisma/migrations/20260115124028_message_attachment_2/migration.sql new file mode 100644 index 0000000..cc4bda8 --- /dev/null +++ b/prisma/migrations/20260115124028_message_attachment_2/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `communityId` on table `Attachment` required. This step will fail if there are existing NULL values in that column. + - Made the column `attachmentId` on table `Chunk` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Attachment" ALTER COLUMN "communityId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Chunk" ALTER COLUMN "attachmentId" SET NOT NULL; diff --git a/prisma/migrations/20260115135751_nickname/migration.sql b/prisma/migrations/20260115135751_nickname/migration.sql new file mode 100644 index 0000000..2037fec --- /dev/null +++ b/prisma/migrations/20260115135751_nickname/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "nickname" TEXT; diff --git a/prisma/migrations/20260115140047_filename/migration.sql b/prisma/migrations/20260115140047_filename/migration.sql new file mode 100644 index 0000000..cdd5f71 --- /dev/null +++ b/prisma/migrations/20260115140047_filename/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `fileName` on the `Attachment` table. All the data in the column will be lost. + - Added the required column `filename` to the `Attachment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Attachment" DROP COLUMN "fileName", +ADD COLUMN "filename" TEXT NOT NULL; diff --git a/prisma/migrations/20260116133849_attachment_size/migration.sql b/prisma/migrations/20260116133849_attachment_size/migration.sql new file mode 100644 index 0000000..482838b --- /dev/null +++ b/prisma/migrations/20260116133849_attachment_size/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `size` to the `Attachment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Attachment" ADD COLUMN "size" BIGINT NOT NULL; diff --git a/prisma/migrations/20260116134610_chunks/migration.sql b/prisma/migrations/20260116134610_chunks/migration.sql new file mode 100644 index 0000000..6a2c9ae --- /dev/null +++ b/prisma/migrations/20260116134610_chunks/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `finishedUploading` to the `Attachment` table without a default value. This is not possible if the table is not empty. + - Added the required column `index` to the `Chunk` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Attachment" ADD COLUMN "finishedUploading" BOOLEAN NOT NULL; + +-- AlterTable +ALTER TABLE "Chunk" ADD COLUMN "index" INTEGER NOT NULL; diff --git a/prisma/migrations/20260116141809_chunk_iv_binary/migration.sql b/prisma/migrations/20260116141809_chunk_iv_binary/migration.sql new file mode 100644 index 0000000..52a968f --- /dev/null +++ b/prisma/migrations/20260116141809_chunk_iv_binary/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Changed the type of `iv` on the `Chunk` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "Chunk" DROP COLUMN "iv", +ADD COLUMN "iv" BYTEA NOT NULL; diff --git a/prisma/migrations/20260116142142_mimetype/migration.sql b/prisma/migrations/20260116142142_mimetype/migration.sql new file mode 100644 index 0000000..b44b29e --- /dev/null +++ b/prisma/migrations/20260116142142_mimetype/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `mimeType` on the `Attachment` table. All the data in the column will be lost. + - Added the required column `mimetype` to the `Attachment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Attachment" DROP COLUMN "mimeType", +ADD COLUMN "mimetype" TEXT NOT NULL; diff --git a/prisma/migrations/20260116142745_chunks_community/migration.sql b/prisma/migrations/20260116142745_chunks_community/migration.sql new file mode 100644 index 0000000..d5c71c6 --- /dev/null +++ b/prisma/migrations/20260116142745_chunks_community/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `communityId` to the `Chunk` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Chunk" ADD COLUMN "communityId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Chunk" ADD CONSTRAINT "Chunk_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260116143226_attachment_msg/migration.sql b/prisma/migrations/20260116143226_attachment_msg/migration.sql new file mode 100644 index 0000000..628e888 --- /dev/null +++ b/prisma/migrations/20260116143226_attachment_msg/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `messageId` on table `Attachment` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Attachment" ALTER COLUMN "messageId" SET NOT NULL; diff --git a/prisma/migrations/20260116143544_optional_msg/migration.sql b/prisma/migrations/20260116143544_optional_msg/migration.sql new file mode 100644 index 0000000..12c25bf --- /dev/null +++ b/prisma/migrations/20260116143544_optional_msg/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Attachment" ALTER COLUMN "messageId" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ccd1db..d8751a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,16 +9,19 @@ datasource db { } model Community { - id String @id @unique @default(uuid()) - name String @unique + id String @id @unique @default(uuid()) + name String @unique description String? - creationDate DateTime @default(now()) - User User @relation(name: "OwnerCommunityToUser", fields: [ownerId], references: [id]) + avatar String? + creationDate DateTime @default(now()) + User User @relation(name: "OwnerCommunityToUser", fields: [ownerId], references: [id]) ownerId String - members User[] @relation(name: "MembersCommunitiesToUsers") + members User[] @relation(name: "MembersCommunitiesToUsers") channels Channel[] roles Role[] invites Invite[] + attachments Attachment[] + chunks Chunk[] } model Channel { @@ -45,9 +48,11 @@ model Role { model User { id String @id @unique @default(uuid()) username String @unique + nickname String? email String? @unique passwordHash String? description String? + avatar String? admin Boolean @default(false) registerDate DateTime @default(now()) lastLogin DateTime? @@ -57,6 +62,7 @@ model User { communities Community[] @relation(name: "MembersCommunitiesToUsers") roles Role[] @relation(name: "UsersRolesToUsers") messages Message[] + reactions Reaction[] } model Session { @@ -84,14 +90,49 @@ model Invite { } model Message { - id String @id @unique @default(uuid()) + id String @id @unique @default(uuid()) text String iv String? - editHistory String[] @default([]) - edited Boolean @default(false) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + replyToId String? + edited Boolean @default(false) + editHistory String[] @default([]) + reactions Reaction[] + attachments Attachment[] + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String - channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) + channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) channelId String - creationDate DateTime @default(now()) + creationDate DateTime @default(now()) +} + +model Reaction { + id String @id @unique @default(uuid()) + users User[] + content String + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + messageId String +} + +model Attachment { + id String @id @unique @default(uuid()) + filename String + mimetype String + size BigInt + chunks Chunk[] + finishedUploading Boolean + message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade) + messageId String? + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + communityId String + creationDate DateTime @default(now()) +} + +model Chunk { + id String @id @unique @default(uuid()) + iv Bytes + index Int + attachment Attachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade) + attachmentId String + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + communityId String } diff --git a/src/controllers/channel/channel.ts b/src/controllers/channel/channel.ts index d95e2cb..1609e95 100644 --- a/src/controllers/channel/channel.ts +++ b/src/controllers/channel/channel.ts @@ -25,6 +25,7 @@ import { getChannelMessagesByIdAuth, } from "../../services/channel/channel.js"; import { API_ERROR } from "../errors.js"; +import { responseFullChannel } from "./helpers.js"; const getChannel = async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as IGetChannelParams; @@ -46,13 +47,7 @@ const getChannel = async (request: FastifyRequest, reply: FastifyReply) => { } as IGetChannelResponseError; } - return { - id: channel.id, - name: channel.name, - description: channel.description, - communityId: channel.communityId, - creationDate: channel.creationDate.getTime(), - } as IGetChannelResponseSuccess; + return responseFullChannel(channel) as IGetChannelResponseSuccess; }; const postCreateChannel = async ( @@ -70,13 +65,7 @@ const postCreateChannel = async ( } as IPostCreateChannelResponseError; } - return { - id: channel.id, - name: channel.name, - description: channel.description, - communityId: channel.communityId, - creationDate: channel.creationDate.getTime(), - } as IPostCreateChannelResponseSuccess; + return responseFullChannel(channel) as IPostCreateChannelResponseSuccess; }; const patchChannel = async (request: FastifyRequest, reply: FastifyReply) => { @@ -104,13 +93,7 @@ const patchChannel = async (request: FastifyRequest, reply: FastifyReply) => { } as IPatchChannelResponseError; } - return { - id: channel.id, - name: channel.name, - description: channel.description, - communityId: channel.communityId, - creationDate: channel.creationDate.getTime(), - } as IPatchChannelResponseSuccess; + return responseFullChannel(channel) as IPatchChannelResponseSuccess; }; const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => { @@ -165,7 +148,10 @@ const getMessages = async (request: FastifyRequest, reply: FastifyReply) => { id: message.id, text: message.text, iv: message.iv, + replyToId: message.replyToId, edited: message.edited, + reactions: message.reactions.map((reaction) => reaction.id), + attachments: message.attachments.map((attachment) => attachment.id), ownerId: message.ownerId, creationDate: message.creationDate.getTime(), })), diff --git a/src/controllers/channel/helpers.ts b/src/controllers/channel/helpers.ts new file mode 100644 index 0000000..02c7693 --- /dev/null +++ b/src/controllers/channel/helpers.ts @@ -0,0 +1,13 @@ +import type { Channel } from "../../generated/prisma/client.js"; +import type { IChannel } from "./types.js"; + +const responseFullChannel = (channel: Channel) => + ({ + id: channel.id, + name: channel.name, + description: channel.description, + communityId: channel.communityId, + creationDate: channel.creationDate.getTime(), + }) as IChannel; + +export { responseFullChannel }; diff --git a/src/controllers/channel/index.ts b/src/controllers/channel/index.ts index 4383173..8ffd5e9 100644 --- a/src/controllers/channel/index.ts +++ b/src/controllers/channel/index.ts @@ -1,3 +1,4 @@ export * from "./channel.js"; export * from "./routes.js"; export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/channel/types.ts b/src/controllers/channel/types.ts index 754eef9..49aae7d 100644 --- a/src/controllers/channel/types.ts +++ b/src/controllers/channel/types.ts @@ -80,7 +80,10 @@ interface IGetMessagesResponseMessage { id: string; text: string; iv: string; + replyToId?: string; edited: boolean; + reactions: string[]; + attachments: string[]; ownerId: string; creationDate: number; } diff --git a/src/controllers/community/community.ts b/src/controllers/community/community.ts index edb488d..bc69bc9 100644 --- a/src/controllers/community/community.ts +++ b/src/controllers/community/community.ts @@ -47,6 +47,7 @@ import { } from "../../services/community/community.js"; import { API_ERROR } from "../errors.js"; import type { ICreateInvite } from "../../services/community/types.js"; +import { responseFullCommunity } from "./helpers.js"; const getCommunity = async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as IGetCommunityParams; @@ -60,13 +61,7 @@ const getCommunity = async (request: FastifyRequest, reply: FastifyReply) => { } as IGetCommunityResponseError; } - return { - id: community.id, - name: community.name, - description: community.description, - ownerId: community.ownerId, - creationDate: community.creationDate.getTime(), - } as IGetCommunityResponseSuccess; + return responseFullCommunity(community) as IGetCommunityResponseSuccess; }; const postCreateCommunity = async ( @@ -87,13 +82,9 @@ const postCreateCommunity = async ( } as IPostCreateCommunityResponseError; } - return { - id: community.id, - name: community.name, - description: community.description, - ownerId: community.ownerId, - creationDate: community.creationDate.getTime(), - } as IPostCreateCommunityResponseSuccess; + return responseFullCommunity( + community, + ) as IPostCreateCommunityResponseSuccess; }; const patchCommunity = async (request: FastifyRequest, reply: FastifyReply) => { @@ -121,13 +112,7 @@ const patchCommunity = async (request: FastifyRequest, reply: FastifyReply) => { } as IPatchCommunityResponseError; } - return { - id: community.id, - name: community.name, - description: community.description, - ownerId: community.ownerId, - creationDate: community.creationDate.getTime(), - } as IPatchCommunityResponseSuccess; + return responseFullCommunity(community) as IPatchCommunityResponseSuccess; }; const deleteCommunity = async ( @@ -185,6 +170,8 @@ const getMembers = async (request: FastifyRequest, reply: FastifyReply) => { members: members.map((member) => ({ id: member.id, username: member.username, + nickname: member.nickname ?? member.username, + avatar: member.avatar, })), } as IGetMembersResponseSuccess; }; diff --git a/src/controllers/community/helpers.ts b/src/controllers/community/helpers.ts new file mode 100644 index 0000000..42269a8 --- /dev/null +++ b/src/controllers/community/helpers.ts @@ -0,0 +1,14 @@ +import type { Community } from "../../generated/prisma/client.js"; +import type { ICommunity } from "./types.js"; + +const responseFullCommunity = (community: Community) => + ({ + id: community.id, + name: community.name, + description: community.description, + avatar: community.avatar, + ownerId: community.ownerId, + creationDate: community.creationDate.getTime(), + }) as ICommunity; + +export { responseFullCommunity }; diff --git a/src/controllers/community/index.ts b/src/controllers/community/index.ts index c481dc0..0395431 100644 --- a/src/controllers/community/index.ts +++ b/src/controllers/community/index.ts @@ -1,3 +1,4 @@ export * from "./community.js"; export * from "./routes.js"; export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/community/types.ts b/src/controllers/community/types.ts index 05dc778..15884b3 100644 --- a/src/controllers/community/types.ts +++ b/src/controllers/community/types.ts @@ -4,6 +4,7 @@ interface ICommunity { id: string; name: string; description: string; + avatar?: string; ownerId: string; creationDate: number; } @@ -78,6 +79,8 @@ interface IGetMembersResponseSuccess { interface IGetMembersResponseMember { id: string; username: string; + nickname?: string; + avatar?: string; } interface IGetChannelsParams { diff --git a/src/controllers/errors.ts b/src/controllers/errors.ts index 82b2f90..52bc50e 100644 --- a/src/controllers/errors.ts +++ b/src/controllers/errors.ts @@ -1,7 +1,8 @@ enum API_ERROR { - USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS", NOT_FOUND = "NOT_FOUND", ACCESS_DENIED = "ACCESS_DENIED", + USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS", + FILE_INVALID = "FILE_INVALID", } export { API_ERROR }; diff --git a/src/controllers/file/file.ts b/src/controllers/file/file.ts new file mode 100644 index 0000000..77bce0c --- /dev/null +++ b/src/controllers/file/file.ts @@ -0,0 +1,379 @@ +import { type FastifyReply, type FastifyRequest } from "fastify"; +import type { + IGetUserAvatarParams, + IGetUserAvatarResponseError, + IPostUploadUserAvatarRequest, + IPostUploadUserAvatarResponseError, + IPostUploadUserAvatarResponseSuccess, + IGetCommunityAvatarParams, + IGetCommunityAvatarResponseError, + IPostUploadCommunityAvatarRequest, + IPostUploadCommunityAvatarResponseError, + IPostUploadCommunityAvatarResponseSuccess, + IGetAttachmentParams, + IGetAttachmentResponseError, + IGetAttachmentResponseSuccess, + IPostCreateAttachmentRequest, + IPostCreateAttachmentResponseError, + IPostCreateAttachmentResponseSuccess, + IGetFinishAttachmentParams, + IGetFinishAttachmentError, + IGetFinishAttachmentSuccess, + IGetChunkParams, + IGetChunkResponseError, + IPostUploadChunkParams, + IPostUploadChunkRequest, + IPostUploadChunkResponseError, + IPostUploadChunkResponseSuccess, +} from "./types.js"; +import { API_ERROR } from "../errors.js"; +import { + createUserAvatarAuth, + createCommunityAvatarAuth, + getAttachmentByIdAuth, + createAttachmentAuth, + getChunkByAttachmentIndexAuth, + createChunkAuth, + finishAttachmentByIdAuth, +} from "../../services/file/file.js"; +import { join } from "path"; +import { existsSync, createReadStream } from "fs"; +import { lookup } from "mime-types"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { responseFullAttachment } from "./helpers.js"; + +const getUserAvatar = async (request: FastifyRequest, reply: FastifyReply) => { + const { filename } = request.params as IGetUserAvatarParams; + + const filePath = join(process.cwd(), "files", "user", "avatars", filename); + if (!existsSync(filePath)) { + reply.status(404); + return { + filename: filename, + error: API_ERROR.NOT_FOUND, + } as IGetUserAvatarResponseError; + } + + const mimeType = lookup(filename) || "application/octet-stream"; + + reply + .header("Content-Type", mimeType) + .header("Content-Disposition", `attachment; filename="${filename}"`); + return createReadStream(filePath); +}; + +const postUploadUserAvatar = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { file } = request.body as IPostUploadUserAvatarRequest; + const authHeader = request.headers["authorization"]; + + if (!file) { + reply.status(400); + return { + error: API_ERROR.FILE_INVALID, + } as IPostUploadUserAvatarResponseError; + } + + const user = await createUserAvatarAuth(file.filename, authHeader); + if (!user) { + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IPostUploadUserAvatarResponseError; + } + if (user === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IPostUploadUserAvatarResponseError; + } + + const uploadPath = join( + process.cwd(), + "files", + "user", + "avatars", + file.filename, + ); + const uploadPathFolder = join(process.cwd(), "files", "user", "avatars"); + await mkdir(uploadPathFolder, { recursive: true }); + + const fileBuffer = await file.toBuffer(); + await writeFile(uploadPath, fileBuffer); + + return { + filename: file.filename, + userId: user.id, + } as IPostUploadUserAvatarResponseSuccess; +}; + +const getCommunityAvatar = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { filename } = request.params as IGetCommunityAvatarParams; + + const filePath = join( + process.cwd(), + "files", + "community", + "avatars", + filename, + ); + if (!existsSync(filePath)) { + reply.status(404); + return { + filename: filename, + error: API_ERROR.NOT_FOUND, + } as IGetCommunityAvatarResponseError; + } + + const mimeType = lookup(filename) || "application/octet-stream"; + + reply + .header("Content-Type", mimeType) + .header("Content-Disposition", `attachment; filename="${filename}"`); + return createReadStream(filePath); +}; + +const postUploadCommunityAvatar = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { communityId, file } = + request.body as IPostUploadCommunityAvatarRequest; + const authHeader = request.headers["authorization"]; + + if (!file) { + reply.status(400); + return { + error: API_ERROR.FILE_INVALID, + } as IPostUploadUserAvatarResponseError; + } + + const community = await createCommunityAvatarAuth( + file.filename, + communityId.value, + authHeader, + ); + if (!community) { + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IPostUploadCommunityAvatarResponseError; + } + if (community === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IPostUploadCommunityAvatarResponseError; + } + + const uploadPathFolder = join( + process.cwd(), + "files", + "community", + "avatars", + ); + await mkdir(uploadPathFolder, { recursive: true }); + + const uploadPath = join(uploadPathFolder, file.filename); + const fileBuffer = await file.toBuffer(); + await writeFile(uploadPath, fileBuffer); + + return { + filename: file.filename, + userId: community.id, + } as IPostUploadCommunityAvatarResponseSuccess; +}; + +const getAttachment = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IGetAttachmentParams; + const authHeader = request.headers["authorization"]; + + const attachment = await getAttachmentByIdAuth(id, authHeader); + if (!attachment) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IGetAttachmentResponseError; + } + if (attachment === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetAttachmentResponseError; + } + + return responseFullAttachment(attachment) as IGetAttachmentResponseSuccess; +}; + +const postCreateAttachment = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { filename, mimetype, size, communityId } = + request.body as IPostCreateAttachmentRequest; + const authHeader = request.headers["authorization"]; + + const attachment = await createAttachmentAuth( + { + filename: filename, + mimetype: mimetype, + size: size, + communityId: communityId, + }, + authHeader, + ); + if (!attachment) { + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IPostCreateAttachmentResponseError; + } + if (attachment === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IPostCreateAttachmentResponseError; + } + + return responseFullAttachment({ + ...attachment, + chunks: [], + }) as IPostCreateAttachmentResponseSuccess; +}; + +const getFinishAttachment = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { id } = request.params as IGetFinishAttachmentParams; + const authHeader = request.headers["authorization"]; + + const attachment = await finishAttachmentByIdAuth(id, authHeader); + if (!attachment) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IGetFinishAttachmentError; + } + if (attachment === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IGetFinishAttachmentError; + } + + return { + id: attachment.id, + } as IGetFinishAttachmentSuccess; +}; + +const getChunk = async (request: FastifyRequest, reply: FastifyReply) => { + const { attachmentId, index } = request.params as IGetChunkParams; + const authHeader = request.headers["authorization"]; + + const chunk = await getChunkByAttachmentIndexAuth( + attachmentId, + Number(index), + authHeader, + ); + if (!chunk) { + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IGetChunkResponseError; + } + if (chunk === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IGetChunkResponseError; + } + + const filePathFolder = join(process.cwd(), "files", "attachments"); + const filePath = join(filePathFolder, `${chunk.id}.bin`); + if (!existsSync(filePath)) { + console.log(filePath); + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IGetChunkResponseError; + } + + const file = await readFile(filePath); + const response = Buffer.concat([chunk.iv, file]); + + reply.header("Content-Type", "application/octet-stream").send(response); +}; + +const postUploadChunk = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { attachmentId, index } = request.params as IPostUploadChunkParams; + const { iv, file } = request.body as IPostUploadChunkRequest; + const authHeader = request.headers["authorization"]; + + const ivBuffer = Buffer.from(iv.value, "base64"); + + if (!file) { + reply.status(400); + return { + error: API_ERROR.FILE_INVALID, + } as IPostUploadChunkResponseError; + } + + const chunk = await createChunkAuth( + { + attachmentId: attachmentId, + index: Number(index), + iv: ivBuffer, + }, + authHeader, + ); + if (!chunk) { + reply.status(404); + return { + error: API_ERROR.NOT_FOUND, + } as IPostUploadChunkResponseError; + } + if (chunk === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IPostUploadChunkResponseError; + } + + const uploadPathFolder = join(process.cwd(), "files", "attachments"); + await mkdir(uploadPathFolder, { recursive: true }); + + const uploadPath = join(uploadPathFolder, `${chunk.id}.bin`); + const fileBuffer = await file.toBuffer(); + await writeFile(uploadPath, fileBuffer); + + return { + id: chunk.id, + index: chunk.index, + attachmentId: chunk.attachmentId, + } as IPostUploadChunkResponseSuccess; +}; + +export { + getUserAvatar, + postUploadUserAvatar, + getCommunityAvatar, + postUploadCommunityAvatar, + getAttachment, + postCreateAttachment, + getFinishAttachment, + getChunk, + postUploadChunk, +}; diff --git a/src/controllers/file/helpers.ts b/src/controllers/file/helpers.ts new file mode 100644 index 0000000..ba194e3 --- /dev/null +++ b/src/controllers/file/helpers.ts @@ -0,0 +1,16 @@ +import type { AttachmentWithChunks } from "../../services/file/types.js"; +import type { IAttachment } from "./types.js"; + +const responseFullAttachment = (attachment: AttachmentWithChunks) => + ({ + id: attachment.id, + filename: attachment.filename, + mimetype: attachment.mimetype, + size: Number(attachment.size), + messageId: attachment.messageId, + chunks: attachment.chunks.map((chunk) => chunk.id), + finishedUploading: attachment.finishedUploading, + creationDate: attachment.creationDate.getTime(), + }) as IAttachment; + +export { responseFullAttachment }; diff --git a/src/controllers/file/index.ts b/src/controllers/file/index.ts new file mode 100644 index 0000000..8191af4 --- /dev/null +++ b/src/controllers/file/index.ts @@ -0,0 +1,4 @@ +export * from "./file.js"; +export * from "./routes.js"; +export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/file/routes.ts b/src/controllers/file/routes.ts new file mode 100644 index 0000000..9b6ddf0 --- /dev/null +++ b/src/controllers/file/routes.ts @@ -0,0 +1,22 @@ +import { type FastifyInstance } from "fastify"; +import * as controller from "./file.js"; + +const fileRoutes = async (fastify: FastifyInstance) => { + fastify.get(`/avatar/user/:filename`, controller.getUserAvatar); + fastify.post(`/avatar/user/upload`, controller.postUploadUserAvatar); + fastify.get(`/avatar/community/:filename`, controller.getCommunityAvatar); + fastify.post( + `/avatar/community/upload`, + controller.postUploadCommunityAvatar, + ); + fastify.get(`/attachment/:id`, controller.getAttachment); + fastify.post(`/attachment`, controller.postCreateAttachment); + fastify.get(`/attachment/:id/finish`, controller.getFinishAttachment); + fastify.get(`/attachment/:attachmentId/chunk/:index`, controller.getChunk); + fastify.post( + `/attachment/:attachmentId/chunk/:index`, + controller.postUploadChunk, + ); +}; + +export { fileRoutes }; diff --git a/src/controllers/file/types.ts b/src/controllers/file/types.ts new file mode 100644 index 0000000..40101b7 --- /dev/null +++ b/src/controllers/file/types.ts @@ -0,0 +1,156 @@ +import type { MultipartFile, MultipartValue } from "@fastify/multipart"; +import type { API_ERROR } from "../errors.js"; + +interface IGetUserAvatarParams { + filename: string; +} + +interface IGetUserAvatarResponseError { + filename: string; + error: API_ERROR; +} + +interface IPostUploadUserAvatarRequest { + file: MultipartFile; +} + +interface IPostUploadUserAvatarResponseError { + error: API_ERROR; +} + +interface IPostUploadUserAvatarResponseSuccess { + filename: string; + userId: string; +} + +interface IGetCommunityAvatarParams { + filename: string; +} + +interface IGetCommunityAvatarResponseError { + filename: string; + error: API_ERROR; +} + +interface IPostUploadCommunityAvatarRequest { + communityId: MultipartValue; + file: MultipartFile; +} + +interface IPostUploadCommunityAvatarResponseError { + error: API_ERROR; +} + +interface IPostUploadCommunityAvatarResponseSuccess { + filename: string; + userId: string; +} + +interface IAttachment { + id: string; + filename: string; + mimetype: string; + size: number; + messageId: string; + chunks: string[]; + finishedUploading: boolean; + creationDate: number; +} + +interface IGetAttachmentParams { + id: string; +} + +interface IGetAttachmentResponseError { + id: string; + error: API_ERROR; +} + +interface IGetAttachmentResponseSuccess extends IAttachment {} + +interface IPostCreateAttachmentRequest { + filename: string; + mimetype: string; + size: number; + communityId: string; +} + +interface IPostCreateAttachmentResponseError { + error: API_ERROR; +} + +interface IPostCreateAttachmentResponseSuccess extends IAttachment {} + +interface IGetFinishAttachmentParams { + id: string; +} + +interface IGetFinishAttachmentError { + id: string; + error: API_ERROR; +} + +interface IGetFinishAttachmentSuccess { + id: string; +} + +interface IChunk { + id: string; + index: number; + attachmentId: string; +} + +interface IGetChunkParams { + attachmentId: string; + index: number; +} + +interface IGetChunkResponseError { + error: API_ERROR; +} + +interface IPostUploadChunkParams { + attachmentId: string; + index: number; +} + +interface IPostUploadChunkRequest { + iv: MultipartValue; + file: MultipartFile; +} + +interface IPostUploadChunkResponseError { + error: API_ERROR; +} + +interface IPostUploadChunkResponseSuccess extends IChunk {} + +export { + type IGetUserAvatarParams, + type IGetUserAvatarResponseError, + type IPostUploadUserAvatarRequest, + type IPostUploadUserAvatarResponseError, + type IPostUploadUserAvatarResponseSuccess, + type IGetCommunityAvatarParams, + type IGetCommunityAvatarResponseError, + type IPostUploadCommunityAvatarRequest, + type IPostUploadCommunityAvatarResponseError, + type IPostUploadCommunityAvatarResponseSuccess, + type IAttachment, + type IGetAttachmentParams, + type IGetAttachmentResponseError, + type IGetAttachmentResponseSuccess, + type IPostCreateAttachmentRequest, + type IPostCreateAttachmentResponseError, + type IPostCreateAttachmentResponseSuccess, + type IGetFinishAttachmentParams, + type IGetFinishAttachmentError, + type IGetFinishAttachmentSuccess, + type IChunk, + type IGetChunkParams, + type IGetChunkResponseError, + type IPostUploadChunkParams, + type IPostUploadChunkRequest, + type IPostUploadChunkResponseError, + type IPostUploadChunkResponseSuccess, +}; diff --git a/src/controllers/message/helpers.ts b/src/controllers/message/helpers.ts new file mode 100644 index 0000000..3d63190 --- /dev/null +++ b/src/controllers/message/helpers.ts @@ -0,0 +1,19 @@ +import type { FullMessage } from "../../services/message/types.js"; +import type { IMessage } from "./types.js"; + +const responseFullMessage = (message: FullMessage) => + ({ + id: message.id, + text: message.text, + iv: message.iv, + replyToId: message.replyToId, + edited: message.edited, + editHistory: message.editHistory, + reactions: message.reactions.map((reaction) => reaction.id), + attachments: message.attachments.map((attachment) => attachment.id), + ownerId: message.ownerId, + channelId: message.channelId, + creationDate: message.creationDate.getTime(), + }) as IMessage; + +export { responseFullMessage }; diff --git a/src/controllers/message/index.ts b/src/controllers/message/index.ts index e6bb2d5..f35a1aa 100644 --- a/src/controllers/message/index.ts +++ b/src/controllers/message/index.ts @@ -1,3 +1,4 @@ export * from "./message.js"; export * from "./routes.js"; export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/message/message.ts b/src/controllers/message/message.ts index 90d3bf4..72e1a18 100644 --- a/src/controllers/message/message.ts +++ b/src/controllers/message/message.ts @@ -13,14 +13,25 @@ import type { IDeleteMessageParams, IDeleteMessageResponseError, IDeleteMessageResponseSuccess, + IReactMessageParams, + IReactMessageRequest, + IReactMessageResponseError, + IReactMessageResponseSuccess, + IUnreactMessageParams, + IUnreactMessageRequest, + IUnreactMessageResponseError, + IUnreactMessageResponseSuccess, } from "./types.js"; import { createMessageAuth, deleteMessageByIdAuth, getMessageByIdAuth, updateMessageByIdAuth, + reactMessageByIdAuth, + unreactMessageByIdAuth, } from "../../services/message/message.js"; import { API_ERROR } from "../errors.js"; +import { responseFullMessage } from "./helpers.js"; const getMessage = async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as IGetMessageParams; @@ -42,16 +53,7 @@ const getMessage = async (request: FastifyRequest, reply: FastifyReply) => { } as IGetMessageResponseError; } - return { - id: message.id, - text: message.text, - iv: message.iv, - editHistory: message.editHistory, - edited: message.edited, - ownerId: message.ownerId, - channelId: message.channelId, - creationDate: message.creationDate.getTime(), - } as IGetMessageResponseSuccess; + return responseFullMessage(message) as IGetMessageResponseSuccess; }; const postCreateMessage = async ( @@ -69,16 +71,7 @@ const postCreateMessage = async ( } as IPostCreateMessageResponseError; } - return { - id: message.id, - text: message.text, - iv: message.iv, - editHistory: message.editHistory, - edited: message.edited, - ownerId: message.ownerId, - channelId: message.channelId, - creationDate: message.creationDate.getTime(), - } as IPostCreateMessageResponseSuccess; + return responseFullMessage(message) as IPostCreateMessageResponseSuccess; }; const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => { @@ -106,15 +99,7 @@ const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => { } 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; + return responseFullMessage(message) as IPatchMessageResponseSuccess; }; const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => { @@ -144,4 +129,59 @@ const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => { } as IDeleteMessageResponseSuccess; }; -export { getMessage, postCreateMessage, patchMessage, deleteMessage }; +const reactMessage = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IReactMessageParams; + const { content } = request.body as IReactMessageRequest; + const authHeader = request.headers["authorization"]; + + const message = await reactMessageByIdAuth(id, content, authHeader); + if (!message) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IReactMessageResponseError; + } + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IReactMessageResponseError; + } + + return responseFullMessage(message) as IReactMessageResponseSuccess; +}; + +const unreactMessage = async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as IUnreactMessageParams; + const { content } = request.body as IUnreactMessageRequest; + const authHeader = request.headers["authorization"]; + + const message = await unreactMessageByIdAuth(id, content, authHeader); + if (!message) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IReactMessageResponseError; + } + if (message === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IReactMessageResponseError; + } + + return responseFullMessage(message) as IUnreactMessageResponseSuccess; +}; + +export { + getMessage, + postCreateMessage, + patchMessage, + deleteMessage, + reactMessage, + unreactMessage, +}; diff --git a/src/controllers/message/routes.ts b/src/controllers/message/routes.ts index bb7bf1a..b33fc90 100644 --- a/src/controllers/message/routes.ts +++ b/src/controllers/message/routes.ts @@ -6,6 +6,8 @@ const messageRoutes = async (fastify: FastifyInstance) => { fastify.post(`/`, controller.postCreateMessage); fastify.patch(`/:id`, controller.patchMessage); fastify.delete(`/:id`, controller.deleteMessage); + fastify.patch(`/:id/react`, controller.reactMessage); + fastify.patch(`/:id/unreact`, controller.unreactMessage); }; export { messageRoutes }; diff --git a/src/controllers/message/types.ts b/src/controllers/message/types.ts index a607677..6afa5ce 100644 --- a/src/controllers/message/types.ts +++ b/src/controllers/message/types.ts @@ -4,8 +4,11 @@ interface IMessage { id: string; text: string; iv: string; - editHistory: string[]; + replyToId?: string; edited: boolean; + editHistory: string[]; + reactions: string[]; + attachments: string[]; ownerId: string; channelId: string; creationDate: number; @@ -26,6 +29,8 @@ interface IPostCreateMessageRequest { text: string; iv: string; channelId: string; + replyToId?: string; + attachments: string[]; } interface IPostCreateMessageResponseError { @@ -65,6 +70,36 @@ interface IDeleteMessageResponseSuccess { channelId: string; } +interface IReactMessageParams { + id: string; +} + +interface IReactMessageRequest { + content: string; +} + +interface IReactMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IReactMessageResponseSuccess extends IMessage {} + +interface IUnreactMessageParams { + id: string; +} + +interface IUnreactMessageRequest { + content: string; +} + +interface IUnreactMessageResponseError { + id: string; + error: API_ERROR; +} + +interface IUnreactMessageResponseSuccess extends IMessage {} + export { type IMessage, type IGetMessageParams, @@ -80,4 +115,12 @@ export { type IDeleteMessageParams, type IDeleteMessageResponseError, type IDeleteMessageResponseSuccess, + type IReactMessageParams, + type IReactMessageRequest, + type IReactMessageResponseError, + type IReactMessageResponseSuccess, + type IUnreactMessageParams, + type IUnreactMessageRequest, + type IUnreactMessageResponseError, + type IUnreactMessageResponseSuccess, }; diff --git a/src/controllers/role/helpers.ts b/src/controllers/role/helpers.ts new file mode 100644 index 0000000..81603a9 --- /dev/null +++ b/src/controllers/role/helpers.ts @@ -0,0 +1,14 @@ +import type { Role } from "../../generated/prisma/client.js"; +import type { IRole } from "./types.js"; + +const responseFullRole = (role: Role) => + ({ + id: role.id, + name: role.name, + description: role.description, + communityId: role.communityId, + permissions: role.permissions, + creationDate: role.creationDate.getTime(), + }) as IRole; + +export { responseFullRole }; diff --git a/src/controllers/role/index.ts b/src/controllers/role/index.ts index 5231437..0da706b 100644 --- a/src/controllers/role/index.ts +++ b/src/controllers/role/index.ts @@ -1,3 +1,4 @@ export * from "./role.js"; export * from "./routes.js"; export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/role/role.ts b/src/controllers/role/role.ts index 33492ff..f0de71d 100644 --- a/src/controllers/role/role.ts +++ b/src/controllers/role/role.ts @@ -31,6 +31,7 @@ import { updateRoleByIdAuth, } from "../../services/role/role.js"; import { API_ERROR } from "../errors.js"; +import { responseFullRole } from "./helpers.js"; const getRole = async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as IGetRoleParams; @@ -52,14 +53,7 @@ const getRole = async (request: FastifyRequest, reply: FastifyReply) => { } as IGetRoleResponseError; } - return { - id: role.id, - name: role.name, - description: role.description, - communityId: role.communityId, - permissions: role.permissions, - creationDate: role.creationDate.getTime(), - } as IGetRoleResponseSuccess; + return responseFullRole(role) as IGetRoleResponseSuccess; }; const postCreateRole = async (request: FastifyRequest, reply: FastifyReply) => { @@ -74,14 +68,7 @@ const postCreateRole = async (request: FastifyRequest, reply: FastifyReply) => { } as IPostCreateRoleResponseError; } - return { - id: role.id, - name: role.name, - description: role.description, - communityId: role.communityId, - permissions: role.permissions, - creationDate: role.creationDate.getTime(), - } as IPostCreateRoleResponseSuccess; + return responseFullRole(role) as IPostCreateRoleResponseSuccess; }; const patchRole = async (request: FastifyRequest, reply: FastifyReply) => { @@ -105,14 +92,7 @@ const patchRole = async (request: FastifyRequest, reply: FastifyReply) => { } as IPatchRoleResponseError; } - return { - id: role.id, - name: role.name, - description: role.description, - communityId: role.communityId, - permissions: role.permissions, - creationDate: role.creationDate.getTime(), - } as IPatchRoleResponseSuccess; + return responseFullRole(role) as IPatchRoleResponseSuccess; }; const deleteRole = async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/controllers/role/types.ts b/src/controllers/role/types.ts index 7931ca5..2664d1a 100644 --- a/src/controllers/role/types.ts +++ b/src/controllers/role/types.ts @@ -6,7 +6,7 @@ interface IRole { name: string; description: string; communityId: string; - permissions: PERMISSION[]; + permissions: string[]; creationDate: number; } diff --git a/src/controllers/user/helpers.ts b/src/controllers/user/helpers.ts new file mode 100644 index 0000000..4c9fce4 --- /dev/null +++ b/src/controllers/user/helpers.ts @@ -0,0 +1,17 @@ +import type { User } from "../../generated/prisma/client.js"; +import type { IUser } from "./types.js"; + +const responseFullUser = (user: User) => + ({ + id: user.id, + username: user.username, + nickname: user.nickname ?? undefined, + email: user.email, + description: user.description, + avatar: user.avatar, + admin: user.admin ?? false, + registerDate: user.registerDate.getTime(), + lastLogin: user.lastLogin?.getTime() ?? 0, + }) as IUser; + +export { responseFullUser }; diff --git a/src/controllers/user/index.ts b/src/controllers/user/index.ts index e2c377b..fb6a570 100644 --- a/src/controllers/user/index.ts +++ b/src/controllers/user/index.ts @@ -1,3 +1,4 @@ export * from "./user.js"; export * from "./routes.js"; export * from "./types.js"; +export * from "./helpers.js"; diff --git a/src/controllers/user/types.ts b/src/controllers/user/types.ts index 8243424..57cba21 100644 --- a/src/controllers/user/types.ts +++ b/src/controllers/user/types.ts @@ -3,8 +3,10 @@ import type { API_ERROR } from "../errors.js"; interface IUser { id: string; username: string; - email: string; - description: string; + nickname?: string; + email?: string; + description?: string; + avatar?: string; admin: boolean; registerDate: number; lastLogin: number; @@ -49,6 +51,7 @@ interface IPatchUserParams { } interface IPatchUserRequest { + nickname?: string; email?: string; description?: string; } @@ -114,6 +117,7 @@ interface IGetCommunitiesResponseCommunity { id: string; name: string; description: string; + avatar?: string; } export { diff --git a/src/controllers/user/user.ts b/src/controllers/user/user.ts index 84cb732..65e516c 100644 --- a/src/controllers/user/user.ts +++ b/src/controllers/user/user.ts @@ -32,6 +32,7 @@ import { getLoggedUserAuth, } from "../../services/user/user.js"; import { API_ERROR } from "../errors.js"; +import { responseFullUser } from "./helpers.js"; const getUserLogged = async (request: FastifyRequest, reply: FastifyReply) => { const authHeader = request.headers["authorization"]; @@ -69,15 +70,7 @@ const getUser = async (request: FastifyRequest, reply: FastifyReply) => { } as IGetUserResponseError; } - return { - id: user.id, - username: user.username, - email: user.email, - description: user.description, - admin: user.admin, - registerDate: user.registerDate.getTime(), - lastLogin: user.lastLogin?.getTime() ?? 0, - } as IGetUserResponseSuccess; + return responseFullUser(user) as IGetUserResponseSuccess; }; const postCreateUser = async (request: FastifyRequest, reply: FastifyReply) => { @@ -92,15 +85,7 @@ const postCreateUser = async (request: FastifyRequest, reply: FastifyReply) => { } as IPostCreateUserResponseError; } - return { - id: user.id, - username: user.username, - email: user.email, - description: user.description, - admin: user.admin, - registerDate: user.registerDate.getTime(), - lastLogin: user.lastLogin?.getTime() ?? 0, - } as IPostCreateUserResponseSuccess; + return responseFullUser(user) as IPostCreateUserResponseSuccess; }; const patchUser = async (request: FastifyRequest, reply: FastifyReply) => { @@ -124,15 +109,7 @@ const patchUser = async (request: FastifyRequest, reply: FastifyReply) => { } as IPatchUserResponseError; } - return { - id: user.id, - username: user.username, - email: user.email, - description: user.description, - admin: user.admin, - registerDate: user.registerDate.getTime(), - lastLogin: user.lastLogin?.getTime() ?? 0, - } as IPatchUserResponseSuccess; + return responseFullUser(user) as IPatchUserResponseSuccess; }; const deleteUser = async (request: FastifyRequest, reply: FastifyReply) => { @@ -219,6 +196,7 @@ const getCommunities = async (request: FastifyRequest, reply: FastifyReply) => { id: community.id, name: community.name, description: community.description, + avatar: community.avatar, })), } as IGetCommunitiesResponseSuccess; }; diff --git a/src/index.ts b/src/index.ts index 7e9713a..b8ac9c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import Fastify from "fastify"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; import websocket from "@fastify/websocket"; +import multipart from "@fastify/multipart"; import { config } from "./config.js"; @@ -16,7 +17,9 @@ 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 { fileRoutes } from "./controllers/file/routes.js"; import { websocketRoutes } from "./controllers/websocket/routes.js"; + import { initializeCommunitiesWithReadableUsersCache } from "./services/user/user.js"; const app = Fastify({ @@ -28,10 +31,18 @@ app.register(cors, { credentials: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], }); - app.register(cookie, { secret: getCookieSecret() }); - app.register(websocket); +app.register(multipart, { + attachFieldsToBody: true, + sharedSchemaId: "MultipartFileType", + limits: { + fileSize: 10 * 1024 * 1024, + files: 1, + fields: 10, + fieldSize: 1024 * 1024, + }, +}); app.register(testRoutes); app.register(authRoutes, { prefix: "/api/v1/auth" }); @@ -42,6 +53,7 @@ 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(fileRoutes, { prefix: "/api/v1/file" }); app.register(websocketRoutes, { prefix: "/ws" }); app.listen({ port: config.port }, (err, address) => { diff --git a/src/services/auth/auth.ts b/src/services/auth/auth.ts index f30995e..47fa0c0 100644 --- a/src/services/auth/auth.ts +++ b/src/services/auth/auth.ts @@ -35,6 +35,7 @@ const registerUser = async ( newUser = await getDB().user.create({ data: { username: registration.username, + nickname: registration.username, passwordHash: passwordHash, email: registration.email ?? null, }, diff --git a/src/services/channel/channel.ts b/src/services/channel/channel.ts index b49ec8a..c5eb31b 100644 --- a/src/services/channel/channel.ts +++ b/src/services/channel/channel.ts @@ -1,9 +1,10 @@ import { API_ERROR } from "../../controllers/errors.js"; -import type { Channel, Message } from "../../generated/prisma/client.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"; +import type { FullMessage } from "../message/types.js"; import { getUserIdsInCommunity } from "../user/user.js"; import { SocketMessageTypes } from "../websocket/types.js"; import { sendMessageToUsersWS } from "../websocket/websocket.js"; @@ -174,8 +175,20 @@ const deleteChannelByIdAuth = async ( const getChannelMessagesById = async ( id: string, -): Promise => { +): Promise => { return await getDB().message.findMany({ + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, + }, where: { channelId: id, }, @@ -185,7 +198,7 @@ const getChannelMessagesById = async ( const getChannelMessagesByIdAuth = async ( id: string, authHeader: string | undefined, -): Promise => { +): Promise => { const authUser = await getUserFromAuth(authHeader); const channel = await getChannelById(id); const community = await getCommunityById(channel?.communityId ?? ""); diff --git a/src/services/community/community.ts b/src/services/community/community.ts index de5ae1d..01ee850 100644 --- a/src/services/community/community.ts +++ b/src/services/community/community.ts @@ -132,6 +132,7 @@ const getCommunityMembersById = async ( select: { id: true, username: true, + nickname: true, }, }); }; diff --git a/src/services/community/types.ts b/src/services/community/types.ts index 1fe3973..2f4f030 100644 --- a/src/services/community/types.ts +++ b/src/services/community/types.ts @@ -11,6 +11,8 @@ interface IUpdateCommunity { interface ICommunityMember { id: string; username: string; + nickname?: string | null; + avatar?: string; } interface ICommunityChannel { diff --git a/src/services/file/file.ts b/src/services/file/file.ts new file mode 100644 index 0000000..1f68338 --- /dev/null +++ b/src/services/file/file.ts @@ -0,0 +1,308 @@ +import { API_ERROR } from "../../controllers/errors.js"; +import type { + Attachment, + Chunk, + Community, + User, +} 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"; +import type { + AttachmentWithChunks, + ICreateAttachment, + ICreateChunk, +} from "./types.js"; + +const createUserAvatar = async ( + ownerId: string, + filename: string, +): Promise => { + return await getDB().user.update({ + where: { + id: ownerId, + }, + data: { + avatar: filename, + }, + }); +}; + +const createUserAvatarAuth = async ( + filename: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + + if (!authUser) { + return API_ERROR.ACCESS_DENIED; + } + + return await createUserAvatar(authUser.id, filename); +}; + +const createCommunityAvatar = async ( + communityId: string, + filename: string, +): Promise => { + return await getDB().community.update({ + where: { + id: communityId, + }, + data: { + avatar: filename, + }, + }); +}; + +const createCommunityAvatarAuth = async ( + filename: string, + communityId: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const community = await getCommunityById(communityId); + + if ( + !authUser || + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.COMMUNITY_MANAGE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await createCommunityAvatar(communityId, filename); +}; + +const getAttachmentById = async ( + id: string, +): Promise => { + return await getDB().attachment.findUnique({ + where: { id: id }, + include: { + chunks: { + select: { + id: true, + }, + }, + }, + }); +}; + +const getAttachmentByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const attachment = await getAttachmentById(id); + const community = await getCommunityById(attachment?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return attachment; +}; + +const createAttachment = async ( + create: ICreateAttachment, +): Promise => { + return await getDB().attachment.create({ + data: { + ...create, + finishedUploading: false, + }, + }); +}; + +const createAttachmentAuth = async ( + create: ICreateAttachment, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const community = await getCommunityById(create.communityId); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_CREATE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await createAttachment(create); +}; + +const finishAttachmentById = async (id: string): Promise => { + return await getDB().attachment.update({ + where: { id: id }, + data: { + finishedUploading: true, + }, + }); +}; + +const finishAttachmentByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const attachment = await getAttachmentById(id); + const community = await getCommunityById(attachment?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_CREATE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return finishAttachmentById(id); +}; + +const getChunkById = async (id: string): Promise => { + return await getDB().chunk.findUnique({ + where: { id: id }, + }); +}; + +const getChunkByIdAuth = async ( + id: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const chunk = await getChunkById(id); + const community = await getCommunityById(chunk?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return chunk; +}; + +const getChunkByAttachmentIndex = async ( + attachmentId: string, + index: number, +): Promise => { + return await getDB().chunk.findFirst({ + where: { attachmentId: attachmentId, index: index }, + }); +}; + +const getChunkByAttachmentIndexAuth = async ( + attachmentId: string, + index: number, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const chunk = await getChunkByAttachmentIndex(attachmentId, index); + const community = await getCommunityById(chunk?.communityId ?? ""); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_READ], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return chunk; +}; + +const createChunk = async ( + communityId: string, + create: ICreateChunk, +): Promise => { + return await getDB().chunk.create({ + data: { + communityId: communityId, + ...create, + }, + }); +}; + +const createChunkAuth = async ( + create: ICreateChunk, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const attachment = await getAttachmentById(create.attachmentId); + const community = await getCommunityById(attachment?.communityId ?? ""); + + if ( + !community || + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MESSAGES_CREATE], + )) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await createChunk(community.id, create); +}; + +export { + createUserAvatar, + createUserAvatarAuth, + createCommunityAvatar, + createCommunityAvatarAuth, + getAttachmentById, + getAttachmentByIdAuth, + createAttachment, + createAttachmentAuth, + finishAttachmentById, + finishAttachmentByIdAuth, + getChunkById, + getChunkByIdAuth, + getChunkByAttachmentIndex, + getChunkByAttachmentIndexAuth, + createChunk, + createChunkAuth, +}; diff --git a/src/services/file/index.ts b/src/services/file/index.ts new file mode 100644 index 0000000..3afdcc8 --- /dev/null +++ b/src/services/file/index.ts @@ -0,0 +1,2 @@ +export * from "./file.js"; +export * from "./types.js"; diff --git a/src/services/file/types.ts b/src/services/file/types.ts new file mode 100644 index 0000000..1063d5c --- /dev/null +++ b/src/services/file/types.ts @@ -0,0 +1,22 @@ +import type { Attachment } from "../../generated/prisma/client.js"; + +interface AttachmentWithChunks extends Attachment { + chunks: { + id: string; + }[]; +} + +interface ICreateAttachment { + filename: string; + mimetype: string; + size: number; + communityId: string; +} + +interface ICreateChunk { + iv: Buffer; + index: number; + attachmentId: string; +} + +export { type AttachmentWithChunks, type ICreateAttachment, type ICreateChunk }; diff --git a/src/services/message/message.ts b/src/services/message/message.ts index 84c9842..5186901 100644 --- a/src/services/message/message.ts +++ b/src/services/message/message.ts @@ -13,10 +13,22 @@ import { getCommunityById } from "../community/community.js"; import { getUserIdsInCommunityReadMessagesPermission } from "../user/user.js"; import { SocketMessageTypes } from "../websocket/types.js"; import { sendMessageToUsersWS } from "../websocket/websocket.js"; -import type { ICreateMessage, IUpdateMessage } from "./types.js"; +import type { FullMessage, ICreateMessage, IUpdateMessage } from "./types.js"; -const getMessageById = async (id: string): Promise => { +const getMessageById = async (id: string): Promise => { return await getDB().message.findUnique({ + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, + }, where: { id: id }, }); }; @@ -24,7 +36,7 @@ const getMessageById = async (id: string): Promise => { const getMessageByIdAuth = async ( id: string, authHeader: string | undefined, -): Promise => { +): Promise => { const authUser = await getUserFromAuth(authHeader); const message = await getMessageById(id); const channel = await getChannelById(message?.channelId ?? ""); @@ -50,14 +62,43 @@ const createMessage = async ( ownerId: string, communityId: string, create: ICreateMessage, -): Promise => { +): Promise => { const message = await getDB().message.create({ data: { ownerId: ownerId, ...create, + attachments: { + connect: + create.attachments?.map((attachment) => ({ + id: attachment, + })) ?? [], + }, + }, + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, }, }); + for (const attachmentId of create.attachments ?? []) { + await getDB().attachment.update({ + where: { + id: attachmentId, + }, + data: { + messageId: message.id, + }, + }); + } + const userIds = await getUserIdsInCommunityReadMessagesPermission(communityId); @@ -69,7 +110,10 @@ const createMessage = async ( id: message.id, text: message.text, iv: message.iv ?? "", + replyToId: message.replyToId ?? "", edited: message.edited, + reactions: [], + attachments: create.attachments ?? [], ownerId: message.ownerId, creationDate: message.creationDate.getTime(), }, @@ -82,7 +126,7 @@ const createMessage = async ( const createMessageAuth = async ( create: ICreateMessage, authHeader: string | undefined, -): Promise => { +): Promise => { const authUser = await getUserFromAuth(authHeader); const channel = await getChannelById(create.channelId); const community = await getCommunityById(channel?.communityId ?? ""); @@ -109,7 +153,7 @@ const updateMessageById = async ( id: string, communityId: string, update: IUpdateMessage, -): Promise => { +): Promise => { const message = await getMessageById(id); if (!message) { return null; @@ -126,6 +170,18 @@ const updateMessageById = async ( editHistory: newEditHistory, edited: true, }, + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, + }, }); const userIds = @@ -139,7 +195,14 @@ const updateMessageById = async ( id: updatedMessage.id, text: updatedMessage.text, iv: updatedMessage.iv ?? "", + replyToId: updatedMessage.replyToId ?? "", edited: updatedMessage.edited, + reactions: updatedMessage.reactions.map( + (reaction) => reaction.id, + ), + attachments: updatedMessage.attachments.map( + (attachment) => attachment.id, + ), ownerId: updatedMessage.ownerId, creationDate: updatedMessage.creationDate.getTime(), }, @@ -153,7 +216,7 @@ const updateMessageByIdAuth = async ( id: string, update: IUpdateMessage, authHeader: string | undefined, -): Promise => { +): Promise => { const authUser = await getUserFromAuth(authHeader); const message = await getMessageById(id); const channel = await getChannelById(message?.channelId ?? ""); @@ -169,7 +232,7 @@ const updateMessageByIdAuth = async ( return API_ERROR.ACCESS_DENIED; } - return await updateMessageById(id, community?.id, update); + return await updateMessageById(id, community.id, update); }; const deleteMessageById = async ( @@ -178,8 +241,23 @@ const deleteMessageById = async ( ): Promise => { const deletedMessage = await getDB().message.delete({ where: { id: id }, + include: { + attachments: { + select: { + id: true, + }, + }, + }, }); + for (const attachmentId of deletedMessage.attachments) { + await getDB().attachment.delete({ + where: { + id: attachmentId.id, + }, + }); + } + const userIds = await getUserIdsInCommunityReadMessagesPermission(communityId); @@ -220,6 +298,237 @@ const deleteMessageByIdAuth = async ( return await deleteMessageById(id, community.id); }; +const reactMessageById = async ( + id: string, + communityId: string, + userId: string, + content: string, +): Promise => { + const message = await getMessageById(id); + if (!message) { + return null; + } + + const userExists = + (await getDB().user.count({ + where: { + id: userId, + }, + })) < 1; + if (!userExists) { + return null; + } + + let reaction = await getDB().reaction.findFirst({ + where: { + messageId: message.id, + content: content, + }, + }); + if (!reaction) { + reaction = await getDB().reaction.create({ + data: { + content: content, + messageId: message.id, + users: { + connect: { + id: userId, + }, + }, + }, + }); + } + + const updatedMessage = await getDB().message.update({ + where: { + id: id, + }, + data: { + reactions: { + connect: { + id: reaction.id, + }, + }, + }, + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, + }, + }); + + const userIds = + await getUserIdsInCommunityReadMessagesPermission(communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.SET_MESSAGE, + payload: { + channelId: updatedMessage.channelId, + message: { + id: updatedMessage.id, + text: updatedMessage.text, + iv: updatedMessage.iv ?? "", + replyToId: updatedMessage.replyToId ?? "", + edited: updatedMessage.edited, + reactions: updatedMessage.reactions.map( + (reaction) => reaction.id, + ), + attachments: updatedMessage.attachments.map( + (attachment) => attachment.id, + ), + ownerId: updatedMessage.ownerId, + creationDate: updatedMessage.creationDate.getTime(), + }, + }, + }); + + return updatedMessage; +}; + +const reactMessageByIdAuth = async ( + id: string, + content: 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 ( + !authUser || + !community || + !(await isUserOwnerOrAdmin(authUser, { + message: message, + })) || + !(await isUserInCommunity(authUser, community)) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await reactMessageById(id, community.id, authUser.id, content); +}; + +const unreactMessageById = async ( + id: string, + communityId: string, + userId: string, + content: string, +): Promise => { + const message = await getMessageById(id); + if (!message) { + return null; + } + + const userExists = + (await getDB().user.count({ + where: { + id: userId, + }, + })) < 1; + if (!userExists) { + return null; + } + + let reaction = await getDB().reaction.findFirst({ + where: { + messageId: message.id, + content: content, + }, + }); + if (!reaction) { + return null; + } + reaction = await getDB().reaction.delete({ + where: { + id: reaction?.id, + }, + }); + + const updatedMessage = await getDB().message.update({ + where: { + id: id, + }, + data: { + reactions: { + disconnect: { + id: reaction.id, + }, + }, + }, + include: { + reactions: { + select: { + id: true, + }, + }, + attachments: { + select: { + id: true, + }, + }, + }, + }); + + const userIds = + await getUserIdsInCommunityReadMessagesPermission(communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.SET_MESSAGE, + payload: { + channelId: updatedMessage.channelId, + message: { + id: updatedMessage.id, + text: updatedMessage.text, + iv: updatedMessage.iv ?? "", + replyToId: updatedMessage.replyToId ?? "", + edited: updatedMessage.edited, + reactions: updatedMessage.reactions.map( + (reaction) => reaction.id, + ), + attachments: updatedMessage.attachments.map( + (attachment) => attachment.id, + ), + ownerId: updatedMessage.ownerId, + creationDate: updatedMessage.creationDate.getTime(), + }, + }, + }); + + return updatedMessage; +}; + +const unreactMessageByIdAuth = async ( + id: string, + content: 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 ( + !authUser || + !community || + !(await isUserOwnerOrAdmin(authUser, { + message: message, + })) || + !(await isUserInCommunity(authUser, community)) + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await unreactMessageById(id, community?.id, authUser.id, content); +}; + export { getMessageById, getMessageByIdAuth, @@ -229,4 +538,8 @@ export { updateMessageByIdAuth, deleteMessageById, deleteMessageByIdAuth, + reactMessageById, + reactMessageByIdAuth, + unreactMessageById, + unreactMessageByIdAuth, }; diff --git a/src/services/message/types.ts b/src/services/message/types.ts index 3e1ed19..69b1825 100644 --- a/src/services/message/types.ts +++ b/src/services/message/types.ts @@ -1,11 +1,24 @@ +import type { Message } from "../../generated/prisma/client.js"; + +interface FullMessage extends Message { + reactions: { + id: string; + }[]; + attachments: { + id: string; + }[]; +} + interface ICreateMessage { text: string; iv: string; channelId: string; + replyToId?: string; + attachments?: string[]; } interface IUpdateMessage { text: string; } -export { type ICreateMessage, type IUpdateMessage }; +export { type FullMessage, type ICreateMessage, type IUpdateMessage }; diff --git a/src/services/user/types.ts b/src/services/user/types.ts index fe8aed8..6012797 100644 --- a/src/services/user/types.ts +++ b/src/services/user/types.ts @@ -7,6 +7,7 @@ interface ICreateUser { } interface IUpdateUser { + nickname?: string; email?: string; description?: string; }