Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Database schema with Link api and services for Link Management #140

Merged
merged 12 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

This file was deleted.

This file was deleted.

3 changes: 0 additions & 3 deletions Client/prisma/migrations/migration_lock.toml

This file was deleted.

36 changes: 36 additions & 0 deletions Client/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model User {
updated_at DateTime @updatedAt @db.Timestamptz(6)
PasswordResetToken PasswordResetToken[]
Document Document[]
Link Link[]
}

model Document {
Expand All @@ -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 {
Expand All @@ -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)
}
2 changes: 2 additions & 0 deletions Client/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
87 changes: 87 additions & 0 deletions Client/src/app/api/links/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
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 });
}
}
61 changes: 61 additions & 0 deletions Client/src/app/api/links/shared_access/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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,
},
});
}
19 changes: 19 additions & 0 deletions Client/src/providers/storage/supabase/supabaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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;
}
}
55 changes: 55 additions & 0 deletions Client/src/services/linkService.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return;
}

static async getLink(linkId: string) {
return prisma.link.findUnique({
where: { linkId },
});
}

static async getFileFromLink(linkId: string): Promise<string> {
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;
}
}