diff --git a/Client/prisma/migrations/20241024163715_remove_unessecary_fields/migration.sql b/Client/prisma/migrations/20241024163715_remove_unessecary_fields/migration.sql deleted file mode 100644 index ccfa640..0000000 --- a/Client/prisma/migrations/20241024163715_remove_unessecary_fields/migration.sql +++ /dev/null @@ -1,62 +0,0 @@ --- CreateEnum -CREATE TYPE "Status" AS ENUM ('ACTIVE', 'ARCHIVED'); - --- CreateEnum -CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'COACH', 'CLIENT', 'USER'); - --- CreateTable -CREATE TABLE "ArchivedUser" ( - "id" BIGSERIAL NOT NULL, - "user_id" TEXT NOT NULL, - "name" TEXT NOT NULL DEFAULT '', - "email" TEXT NOT NULL DEFAULT '', - "password" TEXT NOT NULL, - "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "role" "UserRole" NOT NULL DEFAULT 'USER', - "address" TEXT DEFAULT '', - - CONSTRAINT "ArchivedUser_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "PasswordResetToken" ( - "id" SERIAL NOT NULL, - "token" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "reset_at" TIMESTAMP(3), - "user_id" TEXT NOT NULL, - - CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "User" ( - "id" BIGSERIAL NOT NULL, - "user_id" TEXT NOT NULL, - "name" TEXT NOT NULL DEFAULT '', - "email" TEXT NOT NULL DEFAULT '', - "password" TEXT NOT NULL, - "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "role" "UserRole" NOT NULL DEFAULT 'USER', - "Status" "Status" NOT NULL DEFAULT 'ACTIVE', - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ArchivedUser_user_id_key" ON "ArchivedUser"("user_id"); - --- CreateIndex -CREATE UNIQUE INDEX "ArchivedUser_email_key" ON "ArchivedUser"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "User_user_id_key" ON "User"("user_id"); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/Client/prisma/migrations/20241024170439_change_big_int_to_int/migration.sql b/Client/prisma/migrations/20241024170439_change_big_int_to_int/migration.sql deleted file mode 100644 index 9609653..0000000 --- a/Client/prisma/migrations/20241024170439_change_big_int_to_int/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ -/* - Warnings: - - - The primary key for the `ArchivedUser` table will be changed. If it partially fails, the table could be left without primary key constraint. - - You are about to alter the column `id` on the `ArchivedUser` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. - - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. - - You are about to alter the column `id` on the `User` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`. - -*/ --- AlterTable -ALTER TABLE "ArchivedUser" DROP CONSTRAINT "ArchivedUser_pkey", -ALTER COLUMN "id" SET DATA TYPE INTEGER, -ADD CONSTRAINT "ArchivedUser_pkey" PRIMARY KEY ("id"); - --- AlterTable -ALTER TABLE "User" DROP CONSTRAINT "User_pkey", -ALTER COLUMN "id" SET DATA TYPE INTEGER, -ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id"); diff --git a/Client/prisma/migrations/20241112161117_add_first_and_last_name/migration.sql b/Client/prisma/migrations/20241112161117_add_first_and_last_name/migration.sql deleted file mode 100644 index dfa7067..0000000 --- a/Client/prisma/migrations/20241112161117_add_first_and_last_name/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `name` on the `ArchivedUser` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "ArchivedUser" DROP COLUMN "name", -ADD COLUMN "first_name" TEXT NOT NULL DEFAULT '', -ADD COLUMN "last_name" TEXT NOT NULL DEFAULT ''; - --- AlterTable -ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'ADMIN'; diff --git a/Client/prisma/migrations/migration_lock.toml b/Client/prisma/migrations/migration_lock.toml deleted file mode 100644 index fbffa92..0000000 --- a/Client/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file diff --git a/Client/prisma/schema.prisma b/Client/prisma/schema.prisma index 13f05a9..b4d90b8 100644 --- a/Client/prisma/schema.prisma +++ b/Client/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { updated_at DateTime @updatedAt @db.Timestamptz(6) PasswordResetToken PasswordResetToken[] Document Document[] + Link Link[] } model Document { @@ -56,6 +57,7 @@ model Document { createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) User User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + Link Link[] } enum Status { @@ -68,3 +70,37 @@ enum UserRole { ADMIN USER } + +model Link { + id Int @id @default(autoincrement()) + linkId String @unique + documentId String + userId String + friendlyName String? @unique + linkUrl String @unique + isPublic Boolean @default(false) + password String? // used for SharingOptions + expirationTime DateTime? + hasSharingOptions Boolean @default(false) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + + // Relations + Document Document @relation(fields: [documentId], references: [document_id], onDelete: Cascade) + User User @relation(fields: [userId], references: [user_id], onDelete: Cascade) + + LinkVisitors LinkVisitors[] +} + +model LinkVisitors { + id Int @id @default(autoincrement()) + linkId String + first_name String + last_name String + email String + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + + // Relations + Link Link @relation(fields: [linkId], references: [linkId], onDelete: Cascade) +} diff --git a/Client/src/app/api/auth/[...nextauth]/route.ts b/Client/src/app/api/auth/[...nextauth]/route.ts index bfaa475..80c576b 100644 --- a/Client/src/app/api/auth/[...nextauth]/route.ts +++ b/Client/src/app/api/auth/[...nextauth]/route.ts @@ -34,6 +34,8 @@ export const authOptions: NextAuthOptions = { where: { email: credentials.email }, }); + console.log('User:', user); // Debug log + // Check if the user exists and if their status is ARCHIVED if (!user) { throw new Error('No user found with the provided email'); diff --git a/Client/src/app/api/links/route.ts b/Client/src/app/api/links/route.ts new file mode 100644 index 0000000..eb68d91 --- /dev/null +++ b/Client/src/app/api/links/route.ts @@ -0,0 +1,87 @@ +import prisma from '@lib/prisma'; +import { NextRequest, NextResponse } from 'next/server'; +import { authenticate } from '@lib/middleware/authenticate'; +import LinkService from '@/services/linkService'; +import bcryptjs from 'bcryptjs'; + +export async function GET(req: NextRequest): Promise { + try { + const userId = await authenticate(req); + + const searchParams = req.nextUrl.searchParams; + const linkIdFromParams = searchParams.get('linkId'); + + if (!linkIdFromParams) { + return createErrorResponse('link Id is required.', 400); + } + + const link = await LinkService.getLink(linkIdFromParams); + if (!link) { + return createErrorResponse('Link not found.', 404); + } + + if (link.expirationTime && new Date(link.expirationTime) <= new Date()) { + return NextResponse.json({ message: 'Link is expired' }, { status: 410 }); + } + + if (!link.hasSharingOptions && link.isPublic) { + const signedUrl = await LinkService.getFileFromLink(linkIdFromParams); + return NextResponse.json({ message: 'Link URL generated', data: { signedUrl } }, { status: 200 }); + } else { + return NextResponse.json({ message: 'Link has sharing options enabled', data: { link } }, { status: 200 }); + } + } catch (error) { + return createErrorResponse('Server error.', 500, error); + } +} + +export async function POST(req: NextRequest): Promise { + try { + const userId = await authenticate(req); + const { documentId, friendlyName, isPublic, password, expirationTime, hasSharingOptions } = await req.json(); + + if (!documentId) { + return createErrorResponse('Document ID is required.', 400); + } + + const { linkUrl, linkId } = LinkService.generateLinkDetails(); + + if (expirationTime && new Date(expirationTime) < new Date()) { + return createErrorResponse('Expiration time cannot be in the past.', 400); + } + + const hashedPassword = password ? await bcryptjs.hash(password, 10) : null; + + const newLink = await prisma.link.create({ + data: { + userId, + linkId, + linkUrl, + documentId, + isPublic: isPublic, + password: hashedPassword, + friendlyName: friendlyName || "", + expirationTime: expirationTime || null, + hasSharingOptions: hasSharingOptions || false, + }, + }); + + return NextResponse.json( + { message: 'Link created successfully.', link: newLink }, + { status: 201 } + ); + } catch (error) { + return createErrorResponse('Server error.', 500, error); + } +} + +function createErrorResponse(message: string, status: number, details?: any) { + if (Array.isArray(details) && details.length === 2) { + const [errorCode, errorMessage] = details; + console.error(`[${new Date().toISOString()}] ${errorMessage}`, details); + return NextResponse.json({ error: errorMessage, details }, { status: errorCode }); + } else { + console.error(`[${new Date().toISOString()}] ${message}`, details); + return NextResponse.json({ error: message, details }, { status }); + } +} diff --git a/Client/src/app/api/links/shared_access/route.ts b/Client/src/app/api/links/shared_access/route.ts new file mode 100644 index 0000000..2d9c5ee --- /dev/null +++ b/Client/src/app/api/links/shared_access/route.ts @@ -0,0 +1,61 @@ +import bcryptjs from 'bcryptjs'; +import prisma from '@lib/prisma'; +import LinkService from '@/services/linkService'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest): Promise { + try { + const { linkId, firstName, lastName, email, password } = await req.json(); + + if (!linkId || !firstName || !lastName || !email || !password) { + return createErrorResponse('Link ID, firstName, lastName, email and password are required.', 400); + } + + const link = await LinkService.getLink(linkId); + if (!link) { + return createErrorResponse('Link not found.', 404); + } + + if (!link.hasSharingOptions) { + return createErrorResponse('This link does not require sharing options.', 400); + } + + const isPasswordValid = await validatePassword(password, link.password as string); + if (!isPasswordValid) { + return createErrorResponse('Invalid password.', 403); + } + + await logLinkVisitor(linkId, firstName, lastName, email); + + const signedUrl = await LinkService.getFileFromLink(linkId); + return NextResponse.json({ message: 'Link URL generated', data: { signedUrl } }, { status: 200 }); + } catch (error) { + return createErrorResponse('Server error.', 500, error); + } +} + +function createErrorResponse(message: string, status: number, details?: any) { + if (Array.isArray(details) && details.length === 2) { + const [errorCode, errorMessage] = details; + console.error(`[${new Date().toISOString()}] ${errorMessage}`, details); + return NextResponse.json({ error: errorMessage, details }, { status: errorCode }); + } else { + console.error(`[${new Date().toISOString()}] ${message}`, details); + return NextResponse.json({ error: message, details }, { status }); + } +} + +async function validatePassword(providedPassword: string, storedPassword: string) { + return bcryptjs.compare(providedPassword, storedPassword); +} + +async function logLinkVisitor(linkId: string, first_name: string, last_name: string, email: string) { + return prisma.linkVisitors.create({ + data: { + linkId, + first_name, + last_name, + email, + }, + }); +} diff --git a/Client/src/providers/storage/supabase/supabaseProvider.ts b/Client/src/providers/storage/supabase/supabaseProvider.ts index 1af04e1..462fa85 100644 --- a/Client/src/providers/storage/supabase/supabaseProvider.ts +++ b/Client/src/providers/storage/supabase/supabaseProvider.ts @@ -50,4 +50,23 @@ export class SupabaseProvider implements StorageProvider { throw new Error('File deletion failed.'); } } + + /** + * Generates a signed URL for a file in the Supabase storage bucket. + * @param bucket - The name of the bucket containing the file. + * @param filePath - The path of the file within the bucket. + * @param expiresIn - The number of seconds until the signed URL expires (default: 3 days). + * @returns - A promise resolving to the signed URL. + */ + async generateSignedUrl(bucket: string, filePath: string, expiresIn: number = 259200): Promise { + const { data, error } = await this.supabase.storage + .from(bucket) + .createSignedUrl(filePath, expiresIn); + + if (error) { + throw new Error(`Error generating signed URL: ${error.message}`); + } + + return data.signedUrl; + } } diff --git a/Client/src/services/linkService.ts b/Client/src/services/linkService.ts new file mode 100644 index 0000000..34e0cd8 --- /dev/null +++ b/Client/src/services/linkService.ts @@ -0,0 +1,55 @@ +import { v4 as uuidv4 } from 'uuid'; +import prisma from '@lib/prisma'; +import { SupabaseProvider } from '@/providers/storage/supabase/supabaseProvider'; + +export default class LinkService { + + static generateLinkDetails(): { linkUrl: string, linkId: string; } { + const uniqueId = uuidv4(); + const HOST = process.env.HOST || 'http://localhost:3000'; + + return { + linkId: uniqueId, + linkUrl: `${HOST}/${uniqueId}` + }; + } + + static async deleteLink(linkId: string): Promise { + return; + } + + static async getLink(linkId: string) { + return prisma.link.findUnique({ + where: { linkId }, + }); + } + + static async getFileFromLink(linkId: string): Promise { + const bucketName = 'documents'; + let link = await prisma.link.findUnique({ + where: { + linkId + }, + include: { + Document: true + } + }); + + if (!link || !link.Document) { + throw [404, "Link not found"]; + } + + if (!link.isPublic) { + throw [403, "Link is not public"]; + } + + if (link.expirationTime && new Date(link.expirationTime) < new Date()) { + throw [410, "Link has expired"]; + } + + const supabaseProvider = new SupabaseProvider(); + const signedUrl = await supabaseProvider.generateSignedUrl(bucketName, link.Document.filePath); + + return signedUrl; + } +}