End to end encrypted attachment upload and streaming

This commit is contained in:
Aslan 2026-01-16 18:30:00 -05:00
parent 6f292756ed
commit 603d969972
63 changed files with 1926 additions and 156 deletions

1
.gitignore vendored
View file

@ -132,3 +132,4 @@ dist
/src/generated/prisma /src/generated/prisma
files

84
package-lock.json generated
View file

@ -1,22 +1,24 @@
{ {
"name": "tether", "name": "tether",
"version": "0.5.2", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tether", "name": "tether",
"version": "0.5.2", "version": "0.6.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.3.0",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mime-types": "^3.0.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"ua-parser-js": "^2.0.8", "ua-parser-js": "^2.0.8",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@ -24,6 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@ -140,6 +143,12 @@
"fast-uri": "^3.0.0" "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": { "node_modules/@fastify/cookie": {
"version": "11.0.2", "version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
@ -180,6 +189,22 @@
"toad-cache": "^3.7.0" "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": { "node_modules/@fastify/error": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@ -250,6 +275,29 @@
"dequal": "^2.0.3" "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": { "node_modules/@fastify/proxy-addr": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
@ -591,6 +639,13 @@
"@types/node": "*" "@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": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -1575,6 +1630,31 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "tether", "name": "tether",
"version": "0.5.2", "version": "0.6.0",
"description": "Communication server using the Nexlink protocol", "description": "Communication server using the Nexlink protocol",
"repository": { "repository": {
"type": "git", "type": "git",
@ -17,6 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@ -28,12 +29,14 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.3.0",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mime-types": "^3.0.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"ua-parser-js": "^2.0.8", "ua-parser-js": "^2.0.8",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View file

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

View file

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

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Message" ADD COLUMN "edited" BOOLEAN NOT NULL DEFAULT false;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Attachment" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View file

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

View file

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

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "nickname" TEXT;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Attachment" ALTER COLUMN "messageId" DROP NOT NULL;

View file

@ -9,16 +9,19 @@ datasource db {
} }
model Community { model Community {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
name String @unique name String @unique
description String? description String?
creationDate DateTime @default(now()) avatar String?
User User @relation(name: "OwnerCommunityToUser", fields: [ownerId], references: [id]) creationDate DateTime @default(now())
User User @relation(name: "OwnerCommunityToUser", fields: [ownerId], references: [id])
ownerId String ownerId String
members User[] @relation(name: "MembersCommunitiesToUsers") members User[] @relation(name: "MembersCommunitiesToUsers")
channels Channel[] channels Channel[]
roles Role[] roles Role[]
invites Invite[] invites Invite[]
attachments Attachment[]
chunks Chunk[]
} }
model Channel { model Channel {
@ -45,9 +48,11 @@ model Role {
model User { model User {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
username String @unique username String @unique
nickname String?
email String? @unique email String? @unique
passwordHash String? passwordHash String?
description String? description String?
avatar String?
admin Boolean @default(false) admin Boolean @default(false)
registerDate DateTime @default(now()) registerDate DateTime @default(now())
lastLogin DateTime? lastLogin DateTime?
@ -57,6 +62,7 @@ model User {
communities Community[] @relation(name: "MembersCommunitiesToUsers") communities Community[] @relation(name: "MembersCommunitiesToUsers")
roles Role[] @relation(name: "UsersRolesToUsers") roles Role[] @relation(name: "UsersRolesToUsers")
messages Message[] messages Message[]
reactions Reaction[]
} }
model Session { model Session {
@ -84,14 +90,49 @@ model Invite {
} }
model Message { model Message {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
text String text String
iv String? iv String?
editHistory String[] @default([]) replyToId String?
edited Boolean @default(false) edited Boolean @default(false)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) editHistory String[] @default([])
reactions Reaction[]
attachments Attachment[]
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String ownerId String
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String 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
} }

View file

@ -25,6 +25,7 @@ import {
getChannelMessagesByIdAuth, getChannelMessagesByIdAuth,
} from "../../services/channel/channel.js"; } from "../../services/channel/channel.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
import { responseFullChannel } from "./helpers.js";
const getChannel = async (request: FastifyRequest, reply: FastifyReply) => { const getChannel = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetChannelParams; const { id } = request.params as IGetChannelParams;
@ -46,13 +47,7 @@ const getChannel = async (request: FastifyRequest, reply: FastifyReply) => {
} as IGetChannelResponseError; } as IGetChannelResponseError;
} }
return { return responseFullChannel(channel) as IGetChannelResponseSuccess;
id: channel.id,
name: channel.name,
description: channel.description,
communityId: channel.communityId,
creationDate: channel.creationDate.getTime(),
} as IGetChannelResponseSuccess;
}; };
const postCreateChannel = async ( const postCreateChannel = async (
@ -70,13 +65,7 @@ const postCreateChannel = async (
} as IPostCreateChannelResponseError; } as IPostCreateChannelResponseError;
} }
return { return responseFullChannel(channel) as IPostCreateChannelResponseSuccess;
id: channel.id,
name: channel.name,
description: channel.description,
communityId: channel.communityId,
creationDate: channel.creationDate.getTime(),
} as IPostCreateChannelResponseSuccess;
}; };
const patchChannel = async (request: FastifyRequest, reply: FastifyReply) => { const patchChannel = async (request: FastifyRequest, reply: FastifyReply) => {
@ -104,13 +93,7 @@ const patchChannel = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPatchChannelResponseError; } as IPatchChannelResponseError;
} }
return { return responseFullChannel(channel) as IPatchChannelResponseSuccess;
id: channel.id,
name: channel.name,
description: channel.description,
communityId: channel.communityId,
creationDate: channel.creationDate.getTime(),
} as IPatchChannelResponseSuccess;
}; };
const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => { const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => {
@ -165,7 +148,10 @@ const getMessages = async (request: FastifyRequest, reply: FastifyReply) => {
id: message.id, id: message.id,
text: message.text, text: message.text,
iv: message.iv, iv: message.iv,
replyToId: message.replyToId,
edited: message.edited, edited: message.edited,
reactions: message.reactions.map((reaction) => reaction.id),
attachments: message.attachments.map((attachment) => attachment.id),
ownerId: message.ownerId, ownerId: message.ownerId,
creationDate: message.creationDate.getTime(), creationDate: message.creationDate.getTime(),
})), })),

View file

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

View file

@ -1,3 +1,4 @@
export * from "./channel.js"; export * from "./channel.js";
export * from "./routes.js"; export * from "./routes.js";
export * from "./types.js"; export * from "./types.js";
export * from "./helpers.js";

View file

@ -80,7 +80,10 @@ interface IGetMessagesResponseMessage {
id: string; id: string;
text: string; text: string;
iv: string; iv: string;
replyToId?: string;
edited: boolean; edited: boolean;
reactions: string[];
attachments: string[];
ownerId: string; ownerId: string;
creationDate: number; creationDate: number;
} }

View file

@ -47,6 +47,7 @@ import {
} from "../../services/community/community.js"; } from "../../services/community/community.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
import type { ICreateInvite } from "../../services/community/types.js"; import type { ICreateInvite } from "../../services/community/types.js";
import { responseFullCommunity } from "./helpers.js";
const getCommunity = async (request: FastifyRequest, reply: FastifyReply) => { const getCommunity = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetCommunityParams; const { id } = request.params as IGetCommunityParams;
@ -60,13 +61,7 @@ const getCommunity = async (request: FastifyRequest, reply: FastifyReply) => {
} as IGetCommunityResponseError; } as IGetCommunityResponseError;
} }
return { return responseFullCommunity(community) as IGetCommunityResponseSuccess;
id: community.id,
name: community.name,
description: community.description,
ownerId: community.ownerId,
creationDate: community.creationDate.getTime(),
} as IGetCommunityResponseSuccess;
}; };
const postCreateCommunity = async ( const postCreateCommunity = async (
@ -87,13 +82,9 @@ const postCreateCommunity = async (
} as IPostCreateCommunityResponseError; } as IPostCreateCommunityResponseError;
} }
return { return responseFullCommunity(
id: community.id, community,
name: community.name, ) as IPostCreateCommunityResponseSuccess;
description: community.description,
ownerId: community.ownerId,
creationDate: community.creationDate.getTime(),
} as IPostCreateCommunityResponseSuccess;
}; };
const patchCommunity = async (request: FastifyRequest, reply: FastifyReply) => { const patchCommunity = async (request: FastifyRequest, reply: FastifyReply) => {
@ -121,13 +112,7 @@ const patchCommunity = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPatchCommunityResponseError; } as IPatchCommunityResponseError;
} }
return { return responseFullCommunity(community) as IPatchCommunityResponseSuccess;
id: community.id,
name: community.name,
description: community.description,
ownerId: community.ownerId,
creationDate: community.creationDate.getTime(),
} as IPatchCommunityResponseSuccess;
}; };
const deleteCommunity = async ( const deleteCommunity = async (
@ -185,6 +170,8 @@ const getMembers = async (request: FastifyRequest, reply: FastifyReply) => {
members: members.map((member) => ({ members: members.map((member) => ({
id: member.id, id: member.id,
username: member.username, username: member.username,
nickname: member.nickname ?? member.username,
avatar: member.avatar,
})), })),
} as IGetMembersResponseSuccess; } as IGetMembersResponseSuccess;
}; };

View file

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

View file

@ -1,3 +1,4 @@
export * from "./community.js"; export * from "./community.js";
export * from "./routes.js"; export * from "./routes.js";
export * from "./types.js"; export * from "./types.js";
export * from "./helpers.js";

View file

@ -4,6 +4,7 @@ interface ICommunity {
id: string; id: string;
name: string; name: string;
description: string; description: string;
avatar?: string;
ownerId: string; ownerId: string;
creationDate: number; creationDate: number;
} }
@ -78,6 +79,8 @@ interface IGetMembersResponseSuccess {
interface IGetMembersResponseMember { interface IGetMembersResponseMember {
id: string; id: string;
username: string; username: string;
nickname?: string;
avatar?: string;
} }
interface IGetChannelsParams { interface IGetChannelsParams {

View file

@ -1,7 +1,8 @@
enum API_ERROR { enum API_ERROR {
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
NOT_FOUND = "NOT_FOUND", NOT_FOUND = "NOT_FOUND",
ACCESS_DENIED = "ACCESS_DENIED", ACCESS_DENIED = "ACCESS_DENIED",
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
FILE_INVALID = "FILE_INVALID",
} }
export { API_ERROR }; export { API_ERROR };

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export * from "./file.js";
export * from "./routes.js";
export * from "./types.js";
export * from "./helpers.js";

View file

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

View file

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

View file

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

View file

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

View file

@ -13,14 +13,25 @@ import type {
IDeleteMessageParams, IDeleteMessageParams,
IDeleteMessageResponseError, IDeleteMessageResponseError,
IDeleteMessageResponseSuccess, IDeleteMessageResponseSuccess,
IReactMessageParams,
IReactMessageRequest,
IReactMessageResponseError,
IReactMessageResponseSuccess,
IUnreactMessageParams,
IUnreactMessageRequest,
IUnreactMessageResponseError,
IUnreactMessageResponseSuccess,
} from "./types.js"; } from "./types.js";
import { import {
createMessageAuth, createMessageAuth,
deleteMessageByIdAuth, deleteMessageByIdAuth,
getMessageByIdAuth, getMessageByIdAuth,
updateMessageByIdAuth, updateMessageByIdAuth,
reactMessageByIdAuth,
unreactMessageByIdAuth,
} from "../../services/message/message.js"; } from "../../services/message/message.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
import { responseFullMessage } from "./helpers.js";
const getMessage = async (request: FastifyRequest, reply: FastifyReply) => { const getMessage = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetMessageParams; const { id } = request.params as IGetMessageParams;
@ -42,16 +53,7 @@ const getMessage = async (request: FastifyRequest, reply: FastifyReply) => {
} as IGetMessageResponseError; } as IGetMessageResponseError;
} }
return { return responseFullMessage(message) as IGetMessageResponseSuccess;
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;
}; };
const postCreateMessage = async ( const postCreateMessage = async (
@ -69,16 +71,7 @@ const postCreateMessage = async (
} as IPostCreateMessageResponseError; } as IPostCreateMessageResponseError;
} }
return { return responseFullMessage(message) as IPostCreateMessageResponseSuccess;
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;
}; };
const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => { const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => {
@ -106,15 +99,7 @@ const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPatchMessageResponseError; } as IPatchMessageResponseError;
} }
return { return responseFullMessage(message) as IPatchMessageResponseSuccess;
id: message.id,
text: message.text,
editHistory: message.editHistory,
edited: message.edited,
ownerId: message.ownerId,
channelId: message.channelId,
creationDate: message.creationDate.getTime(),
} as IPatchMessageResponseSuccess;
}; };
const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => { const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => {
@ -144,4 +129,59 @@ const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => {
} as IDeleteMessageResponseSuccess; } 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,
};

View file

@ -6,6 +6,8 @@ const messageRoutes = async (fastify: FastifyInstance) => {
fastify.post(`/`, controller.postCreateMessage); fastify.post(`/`, controller.postCreateMessage);
fastify.patch(`/:id`, controller.patchMessage); fastify.patch(`/:id`, controller.patchMessage);
fastify.delete(`/:id`, controller.deleteMessage); fastify.delete(`/:id`, controller.deleteMessage);
fastify.patch(`/:id/react`, controller.reactMessage);
fastify.patch(`/:id/unreact`, controller.unreactMessage);
}; };
export { messageRoutes }; export { messageRoutes };

View file

@ -4,8 +4,11 @@ interface IMessage {
id: string; id: string;
text: string; text: string;
iv: string; iv: string;
editHistory: string[]; replyToId?: string;
edited: boolean; edited: boolean;
editHistory: string[];
reactions: string[];
attachments: string[];
ownerId: string; ownerId: string;
channelId: string; channelId: string;
creationDate: number; creationDate: number;
@ -26,6 +29,8 @@ interface IPostCreateMessageRequest {
text: string; text: string;
iv: string; iv: string;
channelId: string; channelId: string;
replyToId?: string;
attachments: string[];
} }
interface IPostCreateMessageResponseError { interface IPostCreateMessageResponseError {
@ -65,6 +70,36 @@ interface IDeleteMessageResponseSuccess {
channelId: string; 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 { export {
type IMessage, type IMessage,
type IGetMessageParams, type IGetMessageParams,
@ -80,4 +115,12 @@ export {
type IDeleteMessageParams, type IDeleteMessageParams,
type IDeleteMessageResponseError, type IDeleteMessageResponseError,
type IDeleteMessageResponseSuccess, type IDeleteMessageResponseSuccess,
type IReactMessageParams,
type IReactMessageRequest,
type IReactMessageResponseError,
type IReactMessageResponseSuccess,
type IUnreactMessageParams,
type IUnreactMessageRequest,
type IUnreactMessageResponseError,
type IUnreactMessageResponseSuccess,
}; };

View file

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

View file

@ -1,3 +1,4 @@
export * from "./role.js"; export * from "./role.js";
export * from "./routes.js"; export * from "./routes.js";
export * from "./types.js"; export * from "./types.js";
export * from "./helpers.js";

View file

@ -31,6 +31,7 @@ import {
updateRoleByIdAuth, updateRoleByIdAuth,
} from "../../services/role/role.js"; } from "../../services/role/role.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
import { responseFullRole } from "./helpers.js";
const getRole = async (request: FastifyRequest, reply: FastifyReply) => { const getRole = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetRoleParams; const { id } = request.params as IGetRoleParams;
@ -52,14 +53,7 @@ const getRole = async (request: FastifyRequest, reply: FastifyReply) => {
} as IGetRoleResponseError; } as IGetRoleResponseError;
} }
return { return responseFullRole(role) as IGetRoleResponseSuccess;
id: role.id,
name: role.name,
description: role.description,
communityId: role.communityId,
permissions: role.permissions,
creationDate: role.creationDate.getTime(),
} as IGetRoleResponseSuccess;
}; };
const postCreateRole = async (request: FastifyRequest, reply: FastifyReply) => { const postCreateRole = async (request: FastifyRequest, reply: FastifyReply) => {
@ -74,14 +68,7 @@ const postCreateRole = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPostCreateRoleResponseError; } as IPostCreateRoleResponseError;
} }
return { return responseFullRole(role) as IPostCreateRoleResponseSuccess;
id: role.id,
name: role.name,
description: role.description,
communityId: role.communityId,
permissions: role.permissions,
creationDate: role.creationDate.getTime(),
} as IPostCreateRoleResponseSuccess;
}; };
const patchRole = async (request: FastifyRequest, reply: FastifyReply) => { const patchRole = async (request: FastifyRequest, reply: FastifyReply) => {
@ -105,14 +92,7 @@ const patchRole = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPatchRoleResponseError; } as IPatchRoleResponseError;
} }
return { return responseFullRole(role) as IPatchRoleResponseSuccess;
id: role.id,
name: role.name,
description: role.description,
communityId: role.communityId,
permissions: role.permissions,
creationDate: role.creationDate.getTime(),
} as IPatchRoleResponseSuccess;
}; };
const deleteRole = async (request: FastifyRequest, reply: FastifyReply) => { const deleteRole = async (request: FastifyRequest, reply: FastifyReply) => {

View file

@ -6,7 +6,7 @@ interface IRole {
name: string; name: string;
description: string; description: string;
communityId: string; communityId: string;
permissions: PERMISSION[]; permissions: string[];
creationDate: number; creationDate: number;
} }

View file

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

View file

@ -1,3 +1,4 @@
export * from "./user.js"; export * from "./user.js";
export * from "./routes.js"; export * from "./routes.js";
export * from "./types.js"; export * from "./types.js";
export * from "./helpers.js";

View file

@ -3,8 +3,10 @@ import type { API_ERROR } from "../errors.js";
interface IUser { interface IUser {
id: string; id: string;
username: string; username: string;
email: string; nickname?: string;
description: string; email?: string;
description?: string;
avatar?: string;
admin: boolean; admin: boolean;
registerDate: number; registerDate: number;
lastLogin: number; lastLogin: number;
@ -49,6 +51,7 @@ interface IPatchUserParams {
} }
interface IPatchUserRequest { interface IPatchUserRequest {
nickname?: string;
email?: string; email?: string;
description?: string; description?: string;
} }
@ -114,6 +117,7 @@ interface IGetCommunitiesResponseCommunity {
id: string; id: string;
name: string; name: string;
description: string; description: string;
avatar?: string;
} }
export { export {

View file

@ -32,6 +32,7 @@ import {
getLoggedUserAuth, getLoggedUserAuth,
} from "../../services/user/user.js"; } from "../../services/user/user.js";
import { API_ERROR } from "../errors.js"; import { API_ERROR } from "../errors.js";
import { responseFullUser } from "./helpers.js";
const getUserLogged = async (request: FastifyRequest, reply: FastifyReply) => { const getUserLogged = async (request: FastifyRequest, reply: FastifyReply) => {
const authHeader = request.headers["authorization"]; const authHeader = request.headers["authorization"];
@ -69,15 +70,7 @@ const getUser = async (request: FastifyRequest, reply: FastifyReply) => {
} as IGetUserResponseError; } as IGetUserResponseError;
} }
return { return responseFullUser(user) as IGetUserResponseSuccess;
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;
}; };
const postCreateUser = async (request: FastifyRequest, reply: FastifyReply) => { const postCreateUser = async (request: FastifyRequest, reply: FastifyReply) => {
@ -92,15 +85,7 @@ const postCreateUser = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPostCreateUserResponseError; } as IPostCreateUserResponseError;
} }
return { return responseFullUser(user) as IPostCreateUserResponseSuccess;
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;
}; };
const patchUser = async (request: FastifyRequest, reply: FastifyReply) => { const patchUser = async (request: FastifyRequest, reply: FastifyReply) => {
@ -124,15 +109,7 @@ const patchUser = async (request: FastifyRequest, reply: FastifyReply) => {
} as IPatchUserResponseError; } as IPatchUserResponseError;
} }
return { return responseFullUser(user) as IPatchUserResponseSuccess;
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;
}; };
const deleteUser = async (request: FastifyRequest, reply: FastifyReply) => { const deleteUser = async (request: FastifyRequest, reply: FastifyReply) => {
@ -219,6 +196,7 @@ const getCommunities = async (request: FastifyRequest, reply: FastifyReply) => {
id: community.id, id: community.id,
name: community.name, name: community.name,
description: community.description, description: community.description,
avatar: community.avatar,
})), })),
} as IGetCommunitiesResponseSuccess; } as IGetCommunitiesResponseSuccess;
}; };

View file

@ -2,6 +2,7 @@ import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import websocket from "@fastify/websocket"; import websocket from "@fastify/websocket";
import multipart from "@fastify/multipart";
import { config } from "./config.js"; import { config } from "./config.js";
@ -16,7 +17,9 @@ import { channelRoutes } from "./controllers/channel/routes.js";
import { roleRoutes } from "./controllers/role/routes.js"; import { roleRoutes } from "./controllers/role/routes.js";
import { inviteRoutes } from "./controllers/invite/routes.js"; import { inviteRoutes } from "./controllers/invite/routes.js";
import { messageRoutes } from "./controllers/message/routes.js"; import { messageRoutes } from "./controllers/message/routes.js";
import { fileRoutes } from "./controllers/file/routes.js";
import { websocketRoutes } from "./controllers/websocket/routes.js"; import { websocketRoutes } from "./controllers/websocket/routes.js";
import { initializeCommunitiesWithReadableUsersCache } from "./services/user/user.js"; import { initializeCommunitiesWithReadableUsersCache } from "./services/user/user.js";
const app = Fastify({ const app = Fastify({
@ -28,10 +31,18 @@ app.register(cors, {
credentials: true, credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
}); });
app.register(cookie, { secret: getCookieSecret() }); app.register(cookie, { secret: getCookieSecret() });
app.register(websocket); 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(testRoutes);
app.register(authRoutes, { prefix: "/api/v1/auth" }); 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(roleRoutes, { prefix: "/api/v1/role" });
app.register(inviteRoutes, { prefix: "/api/v1/invite" }); app.register(inviteRoutes, { prefix: "/api/v1/invite" });
app.register(messageRoutes, { prefix: "/api/v1/message" }); app.register(messageRoutes, { prefix: "/api/v1/message" });
app.register(fileRoutes, { prefix: "/api/v1/file" });
app.register(websocketRoutes, { prefix: "/ws" }); app.register(websocketRoutes, { prefix: "/ws" });
app.listen({ port: config.port }, (err, address) => { app.listen({ port: config.port }, (err, address) => {

View file

@ -35,6 +35,7 @@ const registerUser = async (
newUser = await getDB().user.create({ newUser = await getDB().user.create({
data: { data: {
username: registration.username, username: registration.username,
nickname: registration.username,
passwordHash: passwordHash, passwordHash: passwordHash,
email: registration.email ?? null, email: registration.email ?? null,
}, },

View file

@ -1,9 +1,10 @@
import { API_ERROR } from "../../controllers/errors.js"; 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 { getDB } from "../../store/store.js";
import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js";
import { PERMISSION } from "../auth/permission.js"; import { PERMISSION } from "../auth/permission.js";
import { getCommunityById } from "../community/community.js"; import { getCommunityById } from "../community/community.js";
import type { FullMessage } from "../message/types.js";
import { getUserIdsInCommunity } from "../user/user.js"; import { getUserIdsInCommunity } from "../user/user.js";
import { SocketMessageTypes } from "../websocket/types.js"; import { SocketMessageTypes } from "../websocket/types.js";
import { sendMessageToUsersWS } from "../websocket/websocket.js"; import { sendMessageToUsersWS } from "../websocket/websocket.js";
@ -174,8 +175,20 @@ const deleteChannelByIdAuth = async (
const getChannelMessagesById = async ( const getChannelMessagesById = async (
id: string, id: string,
): Promise<Message[] | null> => { ): Promise<FullMessage[] | null> => {
return await getDB().message.findMany({ return await getDB().message.findMany({
include: {
reactions: {
select: {
id: true,
},
},
attachments: {
select: {
id: true,
},
},
},
where: { where: {
channelId: id, channelId: id,
}, },
@ -185,7 +198,7 @@ const getChannelMessagesById = async (
const getChannelMessagesByIdAuth = async ( const getChannelMessagesByIdAuth = async (
id: string, id: string,
authHeader: string | undefined, authHeader: string | undefined,
): Promise<Message[] | null | API_ERROR.ACCESS_DENIED> => { ): Promise<FullMessage[] | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader); const authUser = await getUserFromAuth(authHeader);
const channel = await getChannelById(id); const channel = await getChannelById(id);
const community = await getCommunityById(channel?.communityId ?? ""); const community = await getCommunityById(channel?.communityId ?? "");

View file

@ -132,6 +132,7 @@ const getCommunityMembersById = async (
select: { select: {
id: true, id: true,
username: true, username: true,
nickname: true,
}, },
}); });
}; };

View file

@ -11,6 +11,8 @@ interface IUpdateCommunity {
interface ICommunityMember { interface ICommunityMember {
id: string; id: string;
username: string; username: string;
nickname?: string | null;
avatar?: string;
} }
interface ICommunityChannel { interface ICommunityChannel {

308
src/services/file/file.ts Normal file
View file

@ -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<User | null> => {
return await getDB().user.update({
where: {
id: ownerId,
},
data: {
avatar: filename,
},
});
};
const createUserAvatarAuth = async (
filename: string,
authHeader: string | undefined,
): Promise<User | null | API_ERROR.ACCESS_DENIED> => {
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<Community | null> => {
return await getDB().community.update({
where: {
id: communityId,
},
data: {
avatar: filename,
},
});
};
const createCommunityAvatarAuth = async (
filename: string,
communityId: string,
authHeader: string | undefined,
): Promise<Community | null | API_ERROR.ACCESS_DENIED> => {
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<AttachmentWithChunks | null> => {
return await getDB().attachment.findUnique({
where: { id: id },
include: {
chunks: {
select: {
id: true,
},
},
},
});
};
const getAttachmentByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<AttachmentWithChunks | null | API_ERROR.ACCESS_DENIED> => {
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<Attachment> => {
return await getDB().attachment.create({
data: {
...create,
finishedUploading: false,
},
});
};
const createAttachmentAuth = async (
create: ICreateAttachment,
authHeader: string | undefined,
): Promise<Attachment | API_ERROR.ACCESS_DENIED> => {
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<Attachment | null> => {
return await getDB().attachment.update({
where: { id: id },
data: {
finishedUploading: true,
},
});
};
const finishAttachmentByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Attachment | null | API_ERROR.ACCESS_DENIED> => {
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<Chunk | null> => {
return await getDB().chunk.findUnique({
where: { id: id },
});
};
const getChunkByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Chunk | null | API_ERROR.ACCESS_DENIED> => {
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<Chunk | null> => {
return await getDB().chunk.findFirst({
where: { attachmentId: attachmentId, index: index },
});
};
const getChunkByAttachmentIndexAuth = async (
attachmentId: string,
index: number,
authHeader: string | undefined,
): Promise<Chunk | null | API_ERROR.ACCESS_DENIED> => {
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<Chunk> => {
return await getDB().chunk.create({
data: {
communityId: communityId,
...create,
},
});
};
const createChunkAuth = async (
create: ICreateChunk,
authHeader: string | undefined,
): Promise<Chunk | API_ERROR.ACCESS_DENIED> => {
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,
};

View file

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

View file

@ -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<ArrayBuffer>;
index: number;
attachmentId: string;
}
export { type AttachmentWithChunks, type ICreateAttachment, type ICreateChunk };

View file

@ -13,10 +13,22 @@ import { getCommunityById } from "../community/community.js";
import { getUserIdsInCommunityReadMessagesPermission } from "../user/user.js"; import { getUserIdsInCommunityReadMessagesPermission } from "../user/user.js";
import { SocketMessageTypes } from "../websocket/types.js"; import { SocketMessageTypes } from "../websocket/types.js";
import { sendMessageToUsersWS } from "../websocket/websocket.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<Message | null> => { const getMessageById = async (id: string): Promise<FullMessage | null> => {
return await getDB().message.findUnique({ return await getDB().message.findUnique({
include: {
reactions: {
select: {
id: true,
},
},
attachments: {
select: {
id: true,
},
},
},
where: { id: id }, where: { id: id },
}); });
}; };
@ -24,7 +36,7 @@ const getMessageById = async (id: string): Promise<Message | null> => {
const getMessageByIdAuth = async ( const getMessageByIdAuth = async (
id: string, id: string,
authHeader: string | undefined, authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => { ): Promise<FullMessage | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader); const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id); const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? ""); const channel = await getChannelById(message?.channelId ?? "");
@ -50,14 +62,43 @@ const createMessage = async (
ownerId: string, ownerId: string,
communityId: string, communityId: string,
create: ICreateMessage, create: ICreateMessage,
): Promise<Message> => { ): Promise<FullMessage> => {
const message = await getDB().message.create({ const message = await getDB().message.create({
data: { data: {
ownerId: ownerId, ownerId: ownerId,
...create, ...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 = const userIds =
await getUserIdsInCommunityReadMessagesPermission(communityId); await getUserIdsInCommunityReadMessagesPermission(communityId);
@ -69,7 +110,10 @@ const createMessage = async (
id: message.id, id: message.id,
text: message.text, text: message.text,
iv: message.iv ?? "", iv: message.iv ?? "",
replyToId: message.replyToId ?? "",
edited: message.edited, edited: message.edited,
reactions: [],
attachments: create.attachments ?? [],
ownerId: message.ownerId, ownerId: message.ownerId,
creationDate: message.creationDate.getTime(), creationDate: message.creationDate.getTime(),
}, },
@ -82,7 +126,7 @@ const createMessage = async (
const createMessageAuth = async ( const createMessageAuth = async (
create: ICreateMessage, create: ICreateMessage,
authHeader: string | undefined, authHeader: string | undefined,
): Promise<Message | API_ERROR.ACCESS_DENIED> => { ): Promise<FullMessage | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader); const authUser = await getUserFromAuth(authHeader);
const channel = await getChannelById(create.channelId); const channel = await getChannelById(create.channelId);
const community = await getCommunityById(channel?.communityId ?? ""); const community = await getCommunityById(channel?.communityId ?? "");
@ -109,7 +153,7 @@ const updateMessageById = async (
id: string, id: string,
communityId: string, communityId: string,
update: IUpdateMessage, update: IUpdateMessage,
): Promise<Message | null> => { ): Promise<FullMessage | null> => {
const message = await getMessageById(id); const message = await getMessageById(id);
if (!message) { if (!message) {
return null; return null;
@ -126,6 +170,18 @@ const updateMessageById = async (
editHistory: newEditHistory, editHistory: newEditHistory,
edited: true, edited: true,
}, },
include: {
reactions: {
select: {
id: true,
},
},
attachments: {
select: {
id: true,
},
},
},
}); });
const userIds = const userIds =
@ -139,7 +195,14 @@ const updateMessageById = async (
id: updatedMessage.id, id: updatedMessage.id,
text: updatedMessage.text, text: updatedMessage.text,
iv: updatedMessage.iv ?? "", iv: updatedMessage.iv ?? "",
replyToId: updatedMessage.replyToId ?? "",
edited: updatedMessage.edited, edited: updatedMessage.edited,
reactions: updatedMessage.reactions.map(
(reaction) => reaction.id,
),
attachments: updatedMessage.attachments.map(
(attachment) => attachment.id,
),
ownerId: updatedMessage.ownerId, ownerId: updatedMessage.ownerId,
creationDate: updatedMessage.creationDate.getTime(), creationDate: updatedMessage.creationDate.getTime(),
}, },
@ -153,7 +216,7 @@ const updateMessageByIdAuth = async (
id: string, id: string,
update: IUpdateMessage, update: IUpdateMessage,
authHeader: string | undefined, authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => { ): Promise<FullMessage | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader); const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id); const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? ""); const channel = await getChannelById(message?.channelId ?? "");
@ -169,7 +232,7 @@ const updateMessageByIdAuth = async (
return API_ERROR.ACCESS_DENIED; return API_ERROR.ACCESS_DENIED;
} }
return await updateMessageById(id, community?.id, update); return await updateMessageById(id, community.id, update);
}; };
const deleteMessageById = async ( const deleteMessageById = async (
@ -178,8 +241,23 @@ const deleteMessageById = async (
): Promise<Message | null> => { ): Promise<Message | null> => {
const deletedMessage = await getDB().message.delete({ const deletedMessage = await getDB().message.delete({
where: { id: id }, where: { id: id },
include: {
attachments: {
select: {
id: true,
},
},
},
}); });
for (const attachmentId of deletedMessage.attachments) {
await getDB().attachment.delete({
where: {
id: attachmentId.id,
},
});
}
const userIds = const userIds =
await getUserIdsInCommunityReadMessagesPermission(communityId); await getUserIdsInCommunityReadMessagesPermission(communityId);
@ -220,6 +298,237 @@ const deleteMessageByIdAuth = async (
return await deleteMessageById(id, community.id); return await deleteMessageById(id, community.id);
}; };
const reactMessageById = async (
id: string,
communityId: string,
userId: string,
content: string,
): Promise<FullMessage | null> => {
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<FullMessage | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? "");
const community = await getCommunityById(channel?.communityId ?? "");
if (
!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<FullMessage | null> => {
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<FullMessage | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? "");
const community = await getCommunityById(channel?.communityId ?? "");
if (
!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 { export {
getMessageById, getMessageById,
getMessageByIdAuth, getMessageByIdAuth,
@ -229,4 +538,8 @@ export {
updateMessageByIdAuth, updateMessageByIdAuth,
deleteMessageById, deleteMessageById,
deleteMessageByIdAuth, deleteMessageByIdAuth,
reactMessageById,
reactMessageByIdAuth,
unreactMessageById,
unreactMessageByIdAuth,
}; };

View file

@ -1,11 +1,24 @@
import type { Message } from "../../generated/prisma/client.js";
interface FullMessage extends Message {
reactions: {
id: string;
}[];
attachments: {
id: string;
}[];
}
interface ICreateMessage { interface ICreateMessage {
text: string; text: string;
iv: string; iv: string;
channelId: string; channelId: string;
replyToId?: string;
attachments?: string[];
} }
interface IUpdateMessage { interface IUpdateMessage {
text: string; text: string;
} }
export { type ICreateMessage, type IUpdateMessage }; export { type FullMessage, type ICreateMessage, type IUpdateMessage };

View file

@ -7,6 +7,7 @@ interface ICreateUser {
} }
interface IUpdateUser { interface IUpdateUser {
nickname?: string;
email?: string; email?: string;
description?: string; description?: string;
} }