- Add rate limiting for login, registration, and invitations - Implement CSRF protection with SameSite cookies and security headers - Configure secure session cookies with httpOnly and secure flags - Enhance input sanitization for XSS and injection prevention - Add comprehensive security tests (41 tests passing) - Document security enhancements in SECURITY_ENHANCEMENTS.md Requirements: 9.3, 9.4, 9.5
619 lines
16 KiB
TypeScript
619 lines
16 KiB
TypeScript
'use server'
|
|
|
|
import { prisma } from './db'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { auth } from '@/auth'
|
|
import { z } from 'zod'
|
|
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
|
|
|
// Validation schemas
|
|
const inviteSchema = z.object({
|
|
email: z.string().email({ message: 'Invalid email address' }),
|
|
permission: z.enum(['editor', 'viewer'], { message: 'Permission must be either editor or viewer' }),
|
|
})
|
|
|
|
// Retry utility for database operations
|
|
async function retryOperation<T>(
|
|
operation: () => Promise<T>,
|
|
maxRetries: number = 3,
|
|
delayMs: number = 1000
|
|
): Promise<T> {
|
|
let lastError: Error | undefined
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
return await operation()
|
|
} catch (error) {
|
|
lastError = error as Error
|
|
|
|
// Don't retry on validation errors or not found errors
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message.includes('Unique constraint') ||
|
|
error.message.includes('Record to update not found') ||
|
|
error.message.includes('Record to delete does not exist'))
|
|
) {
|
|
throw error
|
|
}
|
|
|
|
// Wait before retrying (exponential backoff)
|
|
if (attempt < maxRetries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, attempt)))
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
/**
|
|
* Invite a user to collaborate on a list
|
|
* Requirements: 5.1, 6.1
|
|
*/
|
|
export async function inviteUserToList(
|
|
listId: string,
|
|
email: string,
|
|
permission: 'editor' | 'viewer'
|
|
) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error('Unauthorized: Please log in to invite users')
|
|
}
|
|
|
|
const userId = session.user.id
|
|
|
|
// Rate limiting - Requirement 9.3
|
|
const rateLimitKey = `invite:${userId}`
|
|
const rateLimit = checkRateLimit(rateLimitKey, INVITATION_RATE_LIMIT)
|
|
|
|
if (!rateLimit.allowed) {
|
|
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
|
|
throw new Error(`Too many invitation attempts. Please try again in ${minutesUntilReset} minutes.`)
|
|
}
|
|
|
|
// Validate input
|
|
if (!listId) {
|
|
throw new Error('List ID is required')
|
|
}
|
|
|
|
const validation = inviteSchema.safeParse({ email, permission })
|
|
if (!validation.success) {
|
|
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
|
|
throw new Error(errors)
|
|
}
|
|
|
|
const { email: validEmail, permission: validPermission } = validation.data
|
|
|
|
// Check if user is the owner of the list
|
|
const list = await retryOperation(() =>
|
|
prisma.list.findFirst({
|
|
where: {
|
|
id: listId,
|
|
ownerId: userId,
|
|
},
|
|
})
|
|
)
|
|
|
|
if (!list) {
|
|
throw new Error('List not found or you do not have permission to invite users')
|
|
}
|
|
|
|
// Check if the invited email is the owner's email
|
|
if (validEmail === session.user.email) {
|
|
throw new Error('You cannot invite yourself to your own list')
|
|
}
|
|
|
|
// Check if user is already a collaborator
|
|
const existingCollaborator = await retryOperation(() =>
|
|
prisma.collaborator.findFirst({
|
|
where: {
|
|
listId: listId,
|
|
user: {
|
|
email: validEmail,
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
if (existingCollaborator) {
|
|
throw new Error('User is already a collaborator on this list')
|
|
}
|
|
|
|
// Check if there's already a pending invitation
|
|
const existingInvitation = await retryOperation(() =>
|
|
prisma.invitation.findFirst({
|
|
where: {
|
|
listId: listId,
|
|
email: validEmail,
|
|
status: 'pending',
|
|
},
|
|
})
|
|
)
|
|
|
|
if (existingInvitation) {
|
|
throw new Error('An invitation has already been sent to this email')
|
|
}
|
|
|
|
// Create invitation with expiration (7 days from now)
|
|
const expiresAt = new Date()
|
|
expiresAt.setDate(expiresAt.getDate() + 7)
|
|
|
|
const invitation = await retryOperation(() =>
|
|
prisma.invitation.create({
|
|
data: {
|
|
email: validEmail,
|
|
listId: listId,
|
|
inviterId: userId,
|
|
permission: validPermission,
|
|
expiresAt: expiresAt,
|
|
},
|
|
include: {
|
|
list: true,
|
|
inviter: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
revalidatePath(`/list/${listId}`)
|
|
return invitation
|
|
} catch (error) {
|
|
console.error('Error inviting user:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('Invalid') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('permission') ||
|
|
error.message.includes('already') ||
|
|
error.message.includes('cannot')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to send invitation. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get pending invitations for the current user
|
|
* Requirements: 8.1, 8.2
|
|
*/
|
|
export async function getInvitations() {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id || !session?.user?.email) {
|
|
throw new Error('Unauthorized: Please log in to view invitations')
|
|
}
|
|
|
|
const userEmail = session.user.email
|
|
|
|
// Get all pending invitations for this user's email
|
|
const invitations = await retryOperation(() =>
|
|
prisma.invitation.findMany({
|
|
where: {
|
|
email: userEmail,
|
|
status: 'pending',
|
|
expiresAt: {
|
|
gt: new Date(), // Only get non-expired invitations
|
|
},
|
|
},
|
|
include: {
|
|
list: true,
|
|
inviter: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
})
|
|
)
|
|
|
|
return invitations
|
|
} catch (error) {
|
|
console.error('Error fetching invitations:', error)
|
|
if (error instanceof Error && error.message.includes('Unauthorized')) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to load invitations. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accept an invitation and become a collaborator
|
|
* Requirements: 5.2
|
|
*/
|
|
export async function acceptInvitation(invitationId: string) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id || !session?.user?.email) {
|
|
throw new Error('Unauthorized: Please log in to accept invitations')
|
|
}
|
|
|
|
const userId = session.user.id
|
|
const userEmail = session.user.email
|
|
|
|
if (!invitationId) {
|
|
throw new Error('Invitation ID is required')
|
|
}
|
|
|
|
// Get the invitation
|
|
const invitation = await retryOperation(() =>
|
|
prisma.invitation.findUnique({
|
|
where: { id: invitationId },
|
|
include: { list: true },
|
|
})
|
|
)
|
|
|
|
if (!invitation) {
|
|
throw new Error('Invitation not found')
|
|
}
|
|
|
|
// Verify the invitation is for this user
|
|
if (invitation.email !== userEmail) {
|
|
throw new Error('This invitation is not for you')
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if (invitation.status !== 'pending') {
|
|
throw new Error('This invitation has already been processed')
|
|
}
|
|
|
|
// Check if invitation has expired
|
|
if (invitation.expiresAt < new Date()) {
|
|
throw new Error('This invitation has expired')
|
|
}
|
|
|
|
// Check if user is already a collaborator (shouldn't happen, but just in case)
|
|
const existingCollaborator = await retryOperation(() =>
|
|
prisma.collaborator.findFirst({
|
|
where: {
|
|
userId: userId,
|
|
listId: invitation.listId,
|
|
},
|
|
})
|
|
)
|
|
|
|
if (existingCollaborator) {
|
|
// Update invitation status and return
|
|
await retryOperation(() =>
|
|
prisma.invitation.update({
|
|
where: { id: invitationId },
|
|
data: { status: 'accepted' },
|
|
})
|
|
)
|
|
throw new Error('You are already a collaborator on this list')
|
|
}
|
|
|
|
// Create collaborator and update invitation status in a transaction
|
|
const result = await retryOperation(() =>
|
|
prisma.$transaction(async (tx) => {
|
|
// Create collaborator
|
|
const collaborator = await tx.collaborator.create({
|
|
data: {
|
|
userId: userId,
|
|
listId: invitation.listId,
|
|
permission: invitation.permission,
|
|
},
|
|
})
|
|
|
|
// Update invitation status
|
|
await tx.invitation.update({
|
|
where: { id: invitationId },
|
|
data: { status: 'accepted' },
|
|
})
|
|
|
|
return collaborator
|
|
})
|
|
)
|
|
|
|
revalidatePath('/')
|
|
revalidatePath(`/list/${invitation.listId}`)
|
|
return result
|
|
} catch (error) {
|
|
console.error('Error accepting invitation:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('not found') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('not for you') ||
|
|
error.message.includes('already') ||
|
|
error.message.includes('expired')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to accept invitation. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decline an invitation
|
|
* Requirements: 5.3
|
|
*/
|
|
export async function declineInvitation(invitationId: string) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id || !session?.user?.email) {
|
|
throw new Error('Unauthorized: Please log in to decline invitations')
|
|
}
|
|
|
|
const userEmail = session.user.email
|
|
|
|
if (!invitationId) {
|
|
throw new Error('Invitation ID is required')
|
|
}
|
|
|
|
// Get the invitation
|
|
const invitation = await retryOperation(() =>
|
|
prisma.invitation.findUnique({
|
|
where: { id: invitationId },
|
|
})
|
|
)
|
|
|
|
if (!invitation) {
|
|
throw new Error('Invitation not found')
|
|
}
|
|
|
|
// Verify the invitation is for this user
|
|
if (invitation.email !== userEmail) {
|
|
throw new Error('This invitation is not for you')
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if (invitation.status !== 'pending') {
|
|
throw new Error('This invitation has already been processed')
|
|
}
|
|
|
|
// Delete the invitation (as per requirement 5.3 - remove the invitation record)
|
|
await retryOperation(() =>
|
|
prisma.invitation.delete({
|
|
where: { id: invitationId },
|
|
})
|
|
)
|
|
|
|
revalidatePath('/')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error declining invitation:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('not found') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('not for you') ||
|
|
error.message.includes('already')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to decline invitation. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all collaborators for a list
|
|
* Requirements: 5.4
|
|
*/
|
|
export async function getCollaborators(listId: string) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error('Unauthorized: Please log in to view collaborators')
|
|
}
|
|
|
|
const userId = session.user.id
|
|
|
|
if (!listId) {
|
|
throw new Error('List ID is required')
|
|
}
|
|
|
|
// Check if user has access to the list
|
|
const list = await retryOperation(() =>
|
|
prisma.list.findFirst({
|
|
where: {
|
|
id: listId,
|
|
OR: [
|
|
{ ownerId: userId },
|
|
{
|
|
collaborators: {
|
|
some: {
|
|
userId: userId,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
)
|
|
|
|
if (!list) {
|
|
throw new Error('List not found or you do not have permission to view collaborators')
|
|
}
|
|
|
|
// Get all collaborators
|
|
const collaborators = await retryOperation(() =>
|
|
prisma.collaborator.findMany({
|
|
where: {
|
|
listId: listId,
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
})
|
|
)
|
|
|
|
return collaborators
|
|
} catch (error) {
|
|
console.error('Error fetching collaborators:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('permission')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to load collaborators. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a collaborator's permission level
|
|
* Requirements: 6.5
|
|
*/
|
|
export async function updateCollaboratorPermission(
|
|
listId: string,
|
|
collaboratorId: string,
|
|
permission: 'editor' | 'viewer'
|
|
) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error('Unauthorized: Please log in to update permissions')
|
|
}
|
|
|
|
const userId = session.user.id
|
|
|
|
if (!listId || !collaboratorId) {
|
|
throw new Error('List ID and collaborator ID are required')
|
|
}
|
|
|
|
const validation = z.enum(['editor', 'viewer']).safeParse(permission)
|
|
if (!validation.success) {
|
|
throw new Error('Permission must be either editor or viewer')
|
|
}
|
|
|
|
// Check if user is the owner of the list
|
|
const list = await retryOperation(() =>
|
|
prisma.list.findFirst({
|
|
where: {
|
|
id: listId,
|
|
ownerId: userId,
|
|
},
|
|
})
|
|
)
|
|
|
|
if (!list) {
|
|
throw new Error('List not found or you do not have permission to update permissions')
|
|
}
|
|
|
|
// Update the collaborator's permission
|
|
const updated = await retryOperation(() =>
|
|
prisma.collaborator.update({
|
|
where: {
|
|
id: collaboratorId,
|
|
listId: listId, // Ensure the collaborator belongs to this list
|
|
},
|
|
data: {
|
|
permission: permission,
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
)
|
|
|
|
revalidatePath(`/list/${listId}`)
|
|
return updated
|
|
} catch (error) {
|
|
console.error('Error updating collaborator permission:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('permission') ||
|
|
error.message.includes('not found')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to update permission. Please try again.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a collaborator from a list
|
|
* Requirements: 5.5
|
|
*/
|
|
export async function removeCollaborator(
|
|
listId: string,
|
|
collaboratorId: string
|
|
) {
|
|
try {
|
|
// Get current user session
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error('Unauthorized: Please log in to remove collaborators')
|
|
}
|
|
|
|
const userId = session.user.id
|
|
|
|
if (!listId || !collaboratorId) {
|
|
throw new Error('List ID and collaborator ID are required')
|
|
}
|
|
|
|
// Check if user is the owner of the list
|
|
const list = await retryOperation(() =>
|
|
prisma.list.findFirst({
|
|
where: {
|
|
id: listId,
|
|
ownerId: userId,
|
|
},
|
|
})
|
|
)
|
|
|
|
if (!list) {
|
|
throw new Error('List not found or you do not have permission to remove collaborators')
|
|
}
|
|
|
|
// Delete the collaborator
|
|
await retryOperation(() =>
|
|
prisma.collaborator.delete({
|
|
where: {
|
|
id: collaboratorId,
|
|
listId: listId, // Ensure the collaborator belongs to this list
|
|
},
|
|
})
|
|
)
|
|
|
|
revalidatePath(`/list/${listId}`)
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error removing collaborator:', error)
|
|
if (error instanceof Error && (
|
|
error.message.includes('required') ||
|
|
error.message.includes('Unauthorized') ||
|
|
error.message.includes('permission') ||
|
|
error.message.includes('not found')
|
|
)) {
|
|
throw error
|
|
}
|
|
throw new Error('Failed to remove collaborator. Please try again.')
|
|
}
|
|
}
|