fix: security and UX improvements for notifications and shares
- Add ownership checks on all notification and share routes (403 Forbidden) - Validate permission field (only view/edit/admin allowed) - Remove user_id from POST notifications (only self-notifications) - Add getNotification/getProjectShare to DB interface + adapter - Add res.ok checks on all frontend API calls - Add click-outside handler for notification dropdown - Add initial notification load on mount for badge count - Add email validation + duplicate check in ShareDialog - Add Enter key handler in ShareDialog - Add submitting state to prevent double-click - Guard against null token in Dashboard
This commit is contained in:
@@ -155,12 +155,14 @@ export interface DatabaseInterface {
|
||||
|
||||
// Notifications
|
||||
listNotifications(userId: string): DBNotification[];
|
||||
getNotification(id: string): DBNotification | null;
|
||||
createNotification(data: Partial<DBNotification>): DBNotification;
|
||||
markNotificationRead(id: string): boolean;
|
||||
deleteNotification(id: string): boolean;
|
||||
|
||||
// Project Shares
|
||||
listProjectShares(projectId: string): DBProjectShare[];
|
||||
getProjectShare(id: string): DBProjectShare | null;
|
||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare;
|
||||
deleteProjectShare(id: string): boolean;
|
||||
}
|
||||
|
||||
@@ -272,6 +272,10 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[];
|
||||
}
|
||||
|
||||
getNotification(id: string): DBNotification | null {
|
||||
return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null;
|
||||
}
|
||||
|
||||
createNotification(data: Partial<DBNotification>): DBNotification {
|
||||
const id = data.id ?? `notif-${Date.now()}`;
|
||||
this.db.prepare(
|
||||
@@ -293,6 +297,10 @@ export class SqliteAdapter implements DatabaseInterface {
|
||||
return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[];
|
||||
}
|
||||
|
||||
getProjectShare(id: string): DBProjectShare | null {
|
||||
return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare | null;
|
||||
}
|
||||
|
||||
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
|
||||
const id = data.id ?? `share-${Date.now()}`;
|
||||
const shareToken = data.share_token ?? randomUUID();
|
||||
|
||||
@@ -21,41 +21,49 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
|
||||
return db.listNotifications(user.id);
|
||||
});
|
||||
|
||||
// Create notification
|
||||
// Create notification (only for self)
|
||||
fastify.post('/api/notifications', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const body = request.body as { type?: string; title?: string; message?: string; user_id?: string };
|
||||
const body = request.body as { type?: string; title?: string; message?: string };
|
||||
if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' });
|
||||
const validTypes = ['info', 'share', 'warning', 'error'];
|
||||
const type = validTypes.includes(body.type ?? '') ? body.type! : 'info';
|
||||
return reply.code(201).send(db.createNotification({
|
||||
user_id: body.user_id ?? user.id,
|
||||
type: body.type ?? 'info',
|
||||
user_id: user.id,
|
||||
type,
|
||||
title: body.title,
|
||||
message: body.message,
|
||||
}));
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
// Mark notification as read (ownership check)
|
||||
fastify.patch('/api/notifications/:id/read', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { id } = request.params as { id: string };
|
||||
const notif = db.getNotification(id);
|
||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const ok = db.markNotificationRead(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Delete notification
|
||||
// Delete notification (ownership check)
|
||||
fastify.delete('/api/notifications/:id', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { id } = request.params as { id: string };
|
||||
const notif = db.getNotification(id);
|
||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const ok = db.deleteNotification(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Notification not found' });
|
||||
return reply.code(204).send();
|
||||
|
||||
@@ -11,32 +11,41 @@ function extractToken(request: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const VALID_PERMISSIONS = ['view', 'edit', 'admin'];
|
||||
|
||||
export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||
// List shares for a project
|
||||
// List shares for a project (owner only)
|
||||
fastify.get('/api/projects/:projectId/shares', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const project = db.getProject(projectId);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
return db.listProjectShares(projectId);
|
||||
});
|
||||
|
||||
// Create a project share
|
||||
// Create a project share (owner only)
|
||||
fastify.post('/api/projects/:projectId/shares', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const project = db.getProject(projectId);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const body = request.body as { shared_with_email?: string; permission?: string };
|
||||
if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' });
|
||||
const permission = VALID_PERMISSIONS.includes(body.permission ?? '') ? body.permission! : 'view';
|
||||
|
||||
const share = db.createProjectShare({
|
||||
project_id: projectId,
|
||||
shared_with_email: body.shared_with_email,
|
||||
shared_by: user.id,
|
||||
permission: body.permission ?? 'view',
|
||||
permission,
|
||||
});
|
||||
|
||||
// Create a notification for the shared user if they exist
|
||||
@@ -46,20 +55,25 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
||||
user_id: sharedUser.id,
|
||||
type: 'share',
|
||||
title: 'Projekt geteilt',
|
||||
message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${db.getProject(projectId)?.name ?? 'Unbenannt'}`,
|
||||
message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${project.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send(share);
|
||||
});
|
||||
|
||||
// Delete a project share
|
||||
// Delete a project share (owner only)
|
||||
fastify.delete('/api/shares/:id', async (request, reply) => {
|
||||
const token = extractToken(request);
|
||||
if (!token) return reply.code(401).send({ error: 'Authentication required' });
|
||||
const user = authService.getUserFromSession(token);
|
||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
const { id } = request.params as { id: string };
|
||||
const share = db.getProjectShare(id);
|
||||
if (!share) return reply.code(404).send({ error: 'Share not found' });
|
||||
const project = db.getProject(share.project_id);
|
||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||
const ok = db.deleteProjectShare(id);
|
||||
if (!ok) return reply.code(404).send({ error: 'Share not found' });
|
||||
return reply.code(204).send();
|
||||
|
||||
Reference in New Issue
Block a user