fix: MEDIUM issues #31-#36 — inline text editor, image/paste persist, input validation on all routes, useEffect deps, online count fix
This commit is contained in:
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// List all blocks for a drawing
|
// List all blocks for a drawing
|
||||||
fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
return db.listBlocks(drawingId);
|
return db.listBlocks(drawingId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,8 +21,11 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBBlock>;
|
const body = request.body as Partial<DBBlock>;
|
||||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
const nameErr = validateName(body.name, 'name');
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +33,13 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
fastify.patch('/api/blocks/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBBlock>;
|
const body = request.body as Partial<DBBlock>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name, 'name');
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
const updated = db.updateBlock(id, body);
|
const updated = db.updateBlock(id, body);
|
||||||
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
if (!updated) return reply.code(404).send({ error: 'Block not found' });
|
||||||
return updated;
|
return updated;
|
||||||
@@ -37,6 +49,8 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
fastify.delete('/api/blocks/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteBlock(id);
|
const ok = db.deleteBlock(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
if (!ok) return reply.code(404).send({ error: 'Block not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// List drawings for a project
|
// List drawings for a project
|
||||||
fastify.get('/api/projects/:projectId/drawings', async (request, reply) => {
|
fastify.get('/api/projects/:projectId/drawings', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { projectId } = request.params as { projectId: string };
|
const { projectId } = request.params as { projectId: string };
|
||||||
|
const idErr = validateIdParam(projectId, 'projectId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
return db.listDrawings(projectId);
|
return db.listDrawings(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,6 +21,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.get('/api/drawings/:id', async (request, reply) => {
|
fastify.get('/api/drawings/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const drawing = db.getDrawing(id);
|
const drawing = db.getDrawing(id);
|
||||||
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
|
||||||
return drawing;
|
return drawing;
|
||||||
@@ -27,7 +32,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { projectId } = request.params as { projectId: string };
|
const { projectId } = request.params as { projectId: string };
|
||||||
|
const idErr = validateIdParam(projectId, 'projectId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBDrawing>;
|
const body = request.body as Partial<DBDrawing>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +46,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
fastify.patch('/api/drawings/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBDrawing>;
|
const body = request.body as Partial<DBDrawing>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
const updated = db.updateDrawing(id, body);
|
const updated = db.updateDrawing(id, body);
|
||||||
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
|
||||||
return updated;
|
return updated;
|
||||||
@@ -45,6 +62,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
fastify.delete('/api/drawings/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteDrawing(id);
|
const ok = db.deleteDrawing(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// List elements for a drawing
|
// List elements for a drawing
|
||||||
fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => {
|
fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
return db.listElements(drawingId);
|
return db.listElements(drawingId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,6 +21,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBElement>;
|
const body = request.body as Partial<DBElement>;
|
||||||
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
|
||||||
});
|
});
|
||||||
@@ -26,6 +31,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.patch('/api/elements/:id', async (request, reply) => {
|
fastify.patch('/api/elements/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBElement>;
|
const body = request.body as Partial<DBElement>;
|
||||||
const updated = db.updateElement(id, body);
|
const updated = db.updateElement(id, body);
|
||||||
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
if (!updated) return reply.code(404).send({ error: 'Element not found' });
|
||||||
@@ -36,6 +43,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.delete('/api/elements/:id', async (request, reply) => {
|
fastify.delete('/api/elements/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteElement(id);
|
const ok = db.deleteElement(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
if (!ok) return reply.code(404).send({ error: 'Element not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// List layers for a drawing
|
// List layers for a drawing
|
||||||
fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => {
|
fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
return db.listLayers(drawingId);
|
return db.listLayers(drawingId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +21,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { drawingId } = request.params as { drawingId: string };
|
const { drawingId } = request.params as { drawingId: string };
|
||||||
|
const idErr = validateIdParam(drawingId, 'drawingId');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBLayer>;
|
const body = request.body as Partial<DBLayer>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +35,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.patch('/api/layers/:id', async (request, reply) => {
|
fastify.patch('/api/layers/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBLayer>;
|
const body = request.body as Partial<DBLayer>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
const updated = db.updateLayer(id, body);
|
const updated = db.updateLayer(id, body);
|
||||||
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
|
||||||
return updated;
|
return updated;
|
||||||
@@ -36,6 +51,8 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
fastify.delete('/api/layers/:id', async (request, reply) => {
|
fastify.delete('/api/layers/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteLayer(id);
|
const ok = db.deleteLayer(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
function extractToken(request: any): string | null {
|
function extractToken(request: any): string | null {
|
||||||
const auth = request.headers?.authorization;
|
const auth = request.headers?.authorization;
|
||||||
@@ -46,6 +47,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
|
|||||||
const user = authService.getUserFromSession(token);
|
const user = authService.getUserFromSession(token);
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const notif = db.getNotification(id);
|
const notif = db.getNotification(id);
|
||||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
||||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||||
@@ -61,6 +64,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas
|
|||||||
const user = authService.getUserFromSession(token);
|
const user = authService.getUserFromSession(token);
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const notif = db.getNotification(id);
|
const notif = db.getNotification(id);
|
||||||
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
if (!notif) return reply.code(404).send({ error: 'Notification not found' });
|
||||||
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// List projects owned by the authenticated user
|
// List projects owned by the authenticated user
|
||||||
@@ -18,6 +19,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.get('/api/projects/:id', async (request, reply) => {
|
fastify.get('/api/projects/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const project = db.getProject(id);
|
const project = db.getProject(id);
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||||
return project;
|
return project;
|
||||||
@@ -28,7 +31,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
const user = requireAuth(request, reply, authService);
|
const user = requireAuth(request, reply, authService);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const body = request.body as Partial<DBProject>;
|
const body = request.body as Partial<DBProject>;
|
||||||
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
return reply.code(201).send(db.createProject({ ...body, owner_id: user.id }));
|
return reply.code(201).send(db.createProject({ ...body, owner_id: user.id }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +40,13 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.patch('/api/projects/:id', async (request, reply) => {
|
fastify.patch('/api/projects/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBProject>;
|
const body = request.body as Partial<DBProject>;
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameErr = validateName(body.name);
|
||||||
|
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||||
|
}
|
||||||
const updated = db.updateProject(id, body);
|
const updated = db.updateProject(id, body);
|
||||||
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
if (!updated) return reply.code(404).send({ error: 'Project not found' });
|
||||||
return updated;
|
return updated;
|
||||||
@@ -46,6 +56,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte
|
|||||||
fastify.delete('/api/projects/:id', async (request, reply) => {
|
fastify.delete('/api/projects/:id', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteProject(id);
|
const ok = db.deleteProject(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
if (!ok) return reply.code(404).send({ error: 'Project not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js';
|
||||||
|
|
||||||
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// Get a single setting by key
|
// Get a single setting by key
|
||||||
fastify.get('/api/settings/:key', async (request, reply) => {
|
fastify.get('/api/settings/:key', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { key } = request.params as { key: string };
|
const { key } = request.params as { key: string };
|
||||||
|
const keyErr = validateSettingsKey(key);
|
||||||
|
if (keyErr) return reply.code(400).send({ error: keyErr });
|
||||||
const setting = db.getSetting(key);
|
const setting = db.getSetting(key);
|
||||||
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
||||||
return setting;
|
return setting;
|
||||||
@@ -20,11 +23,12 @@ export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInt
|
|||||||
fastify.put('/api/settings/:key', async (request, reply) => {
|
fastify.put('/api/settings/:key', async (request, reply) => {
|
||||||
if (!requireAuth(request, reply, authService)) return;
|
if (!requireAuth(request, reply, authService)) return;
|
||||||
const { key } = request.params as { key: string };
|
const { key } = request.params as { key: string };
|
||||||
|
const keyErr = validateSettingsKey(key);
|
||||||
|
if (keyErr) return reply.code(400).send({ error: keyErr });
|
||||||
const body = request.body as { value?: string };
|
const body = request.body as { value?: string };
|
||||||
if (body.value === undefined) {
|
const valueErr = validateSettingsValue(body.value);
|
||||||
return { error: 'Value is required' };
|
if (valueErr) return reply.code(400).send({ error: valueErr });
|
||||||
}
|
db.setSetting(key, body.value as string);
|
||||||
db.setSetting(key, body.value);
|
return { key, value: body.value as string, updated_at: new Date().toISOString() };
|
||||||
return { key, value: body.value, updated_at: new Date().toISOString() };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
function extractToken(request: any): string | null {
|
function extractToken(request: any): string | null {
|
||||||
const auth = request.headers?.authorization;
|
const auth = request.headers?.authorization;
|
||||||
@@ -21,6 +22,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
const user = authService.getUserFromSession(token);
|
const user = authService.getUserFromSession(token);
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
const { projectId } = request.params as { projectId: string };
|
const { projectId } = request.params as { projectId: string };
|
||||||
|
const projIdErr = validateIdParam(projectId, 'projectId');
|
||||||
|
if (projIdErr) return reply.code(400).send({ error: projIdErr });
|
||||||
const project = db.getProject(projectId);
|
const project = db.getProject(projectId);
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||||
@@ -34,6 +37,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
const user = authService.getUserFromSession(token);
|
const user = authService.getUserFromSession(token);
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
const { projectId } = request.params as { projectId: string };
|
const { projectId } = request.params as { projectId: string };
|
||||||
|
const projIdErr = validateIdParam(projectId, 'projectId');
|
||||||
|
if (projIdErr) return reply.code(400).send({ error: projIdErr });
|
||||||
const project = db.getProject(projectId);
|
const project = db.getProject(projectId);
|
||||||
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
if (!project) return reply.code(404).send({ error: 'Project not found' });
|
||||||
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
|
||||||
@@ -69,6 +74,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf
|
|||||||
const user = authService.getUserFromSession(token);
|
const user = authService.getUserFromSession(token);
|
||||||
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const share = db.getProjectShare(id);
|
const share = db.getProjectShare(id);
|
||||||
if (!share) return reply.code(404).send({ error: 'Share not found' });
|
if (!share) return reply.code(404).send({ error: 'Share not found' });
|
||||||
const project = db.getProject(share.project_id);
|
const project = db.getProject(share.project_id);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
|
import { validateIdParam } from '../utils/validation.js';
|
||||||
|
|
||||||
function extractToken(request: FastifyRequest): string | null {
|
function extractToken(request: FastifyRequest): string | null {
|
||||||
const auth = request.headers?.authorization;
|
const auth = request.headers?.authorization;
|
||||||
@@ -43,6 +44,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
|
|||||||
fastify.get('/api/users/:id', async (request, reply) => {
|
fastify.get('/api/users/:id', async (request, reply) => {
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
if (!requireAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const user = db.getUser(id);
|
const user = db.getUser(id);
|
||||||
if (!user) return reply.code(404).send({ error: 'User not found' });
|
if (!user) return reply.code(404).send({ error: 'User not found' });
|
||||||
const { password_hash, ...safe } = user;
|
const { password_hash, ...safe } = user;
|
||||||
@@ -53,6 +56,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
|
|||||||
fastify.patch('/api/users/:id', async (request, reply) => {
|
fastify.patch('/api/users/:id', async (request, reply) => {
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
if (!requireAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const body = request.body as Partial<DBUser>;
|
const body = request.body as Partial<DBUser>;
|
||||||
// Prevent password update via this endpoint
|
// Prevent password update via this endpoint
|
||||||
delete body.password_hash;
|
delete body.password_hash;
|
||||||
@@ -66,6 +71,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa
|
|||||||
fastify.delete('/api/users/:id', async (request, reply) => {
|
fastify.delete('/api/users/:id', async (request, reply) => {
|
||||||
if (!requireAdmin(request, reply, authService)) return;
|
if (!requireAdmin(request, reply, authService)) return;
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const idErr = validateIdParam(id, 'id');
|
||||||
|
if (idErr) return reply.code(400).send({ error: idErr });
|
||||||
const ok = db.deleteUser(id);
|
const ok = db.deleteUser(id);
|
||||||
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
if (!ok) return reply.code(404).send({ error: 'User not found' });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Validation utilities for backend route input validation.
|
||||||
|
* Provides lightweight, manual validation functions without external dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Maximum allowed length for name fields */
|
||||||
|
export const MAX_NAME_LENGTH = 255;
|
||||||
|
|
||||||
|
/** Maximum allowed length for ID fields */
|
||||||
|
export const MAX_ID_LENGTH = 128;
|
||||||
|
|
||||||
|
/** Allowed ID pattern: alphanumeric, hyphens, underscores */
|
||||||
|
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a name field (non-empty string within max length).
|
||||||
|
* Returns null if valid, or an error message string if invalid.
|
||||||
|
*/
|
||||||
|
export function validateName(value: unknown, fieldName = 'name'): string | null {
|
||||||
|
const capitalized = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return `${capitalized} is required and must be a string`;
|
||||||
|
}
|
||||||
|
if (value.trim().length === 0) {
|
||||||
|
return `${capitalized} must not be empty`;
|
||||||
|
}
|
||||||
|
if (value.length > MAX_NAME_LENGTH) {
|
||||||
|
return `${capitalized} must be at most ${MAX_NAME_LENGTH} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an ID parameter (non-empty, alphanumeric/hyphen/underscore, within max length).
|
||||||
|
* Returns null if valid, or an error message string if invalid.
|
||||||
|
*/
|
||||||
|
export function validateIdParam(id: unknown, fieldName = 'id'): string | null {
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return `${fieldName} is required and must be a string`;
|
||||||
|
}
|
||||||
|
if (id.length === 0) {
|
||||||
|
return `${fieldName} must not be empty`;
|
||||||
|
}
|
||||||
|
if (id.length > MAX_ID_LENGTH) {
|
||||||
|
return `${fieldName} is too long`;
|
||||||
|
}
|
||||||
|
if (!ID_PATTERN.test(id)) {
|
||||||
|
return `${fieldName} contains invalid characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a settings key (non-empty, reasonable length, alphanumeric + hyphens/underscores/dots).
|
||||||
|
* Returns null if valid, or an error message string if invalid.
|
||||||
|
*/
|
||||||
|
export function validateSettingsKey(key: unknown): string | null {
|
||||||
|
if (typeof key !== 'string' || key.trim().length === 0) {
|
||||||
|
return 'Settings key is required';
|
||||||
|
}
|
||||||
|
if (key.length > MAX_NAME_LENGTH) {
|
||||||
|
return 'Settings key is too long';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_.-]+$/.test(key)) {
|
||||||
|
return 'Settings key contains invalid characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a settings value (non-empty string within reasonable length).
|
||||||
|
* Returns null if valid, or an error message string if invalid.
|
||||||
|
*/
|
||||||
|
export function validateSettingsValue(value: unknown): string | null {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return 'Value is required';
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return 'Value must be a string';
|
||||||
|
}
|
||||||
|
if (value.length > 65535) {
|
||||||
|
return 'Value is too long';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Validation Utils Tests
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { validateName, validateIdParam, validateSettingsKey, validateSettingsValue, MAX_NAME_LENGTH, MAX_ID_LENGTH } from '../src/utils/validation.js';
|
||||||
|
|
||||||
|
describe('Validation Utils', () => {
|
||||||
|
describe('validateName', () => {
|
||||||
|
it('should accept a valid name', () => {
|
||||||
|
expect(validateName('Test Project')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject undefined', () => {
|
||||||
|
expect(validateName(undefined)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject null', () => {
|
||||||
|
expect(validateName(null)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-string types', () => {
|
||||||
|
expect(validateName(123)).toContain('required');
|
||||||
|
expect(validateName(true)).toContain('required');
|
||||||
|
expect(validateName({})).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty string', () => {
|
||||||
|
expect(validateName('')).toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject whitespace-only string', () => {
|
||||||
|
expect(validateName(' ')).toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject string exceeding max length', () => {
|
||||||
|
const longName = 'a'.repeat(MAX_NAME_LENGTH + 1);
|
||||||
|
expect(validateName(longName)).toContain('at most');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept string at exactly max length', () => {
|
||||||
|
const maxName = 'a'.repeat(MAX_NAME_LENGTH);
|
||||||
|
expect(validateName(maxName)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom field name in error', () => {
|
||||||
|
const err = validateName(undefined, 'project');
|
||||||
|
expect(err).toContain('Project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateIdParam', () => {
|
||||||
|
it('should accept a valid alphanumeric ID', () => {
|
||||||
|
expect(validateIdParam('abc123')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept ID with hyphens and underscores', () => {
|
||||||
|
expect(validateIdParam('my-id_123')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject undefined', () => {
|
||||||
|
expect(validateIdParam(undefined)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject null', () => {
|
||||||
|
expect(validateIdParam(null)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty string', () => {
|
||||||
|
expect(validateIdParam('')).toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject ID with special characters', () => {
|
||||||
|
expect(validateIdParam('abc!def')).toContain('invalid');
|
||||||
|
expect(validateIdParam('abc def')).toContain('invalid');
|
||||||
|
expect(validateIdParam('abc/def')).toContain('invalid');
|
||||||
|
expect(validateIdParam('abc.def')).toContain('invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject ID exceeding max length', () => {
|
||||||
|
const longId = 'a'.repeat(MAX_ID_LENGTH + 1);
|
||||||
|
expect(validateIdParam(longId)).toContain('too long');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept ID at exactly max length', () => {
|
||||||
|
const maxId = 'a'.repeat(MAX_ID_LENGTH);
|
||||||
|
expect(validateIdParam(maxId)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSettingsKey', () => {
|
||||||
|
it('should accept a valid key', () => {
|
||||||
|
expect(validateSettingsKey('theme.color')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept key with dots, hyphens, underscores', () => {
|
||||||
|
expect(validateSettingsKey('my-setting_key.value')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject undefined', () => {
|
||||||
|
expect(validateSettingsKey(undefined)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty string', () => {
|
||||||
|
expect(validateSettingsKey('')).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject key with special characters', () => {
|
||||||
|
expect(validateSettingsKey('abc!def')).toContain('invalid');
|
||||||
|
expect(validateSettingsKey('abc def')).toContain('invalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSettingsValue', () => {
|
||||||
|
it('should accept a valid string value', () => {
|
||||||
|
expect(validateSettingsValue('some value')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept empty string value', () => {
|
||||||
|
expect(validateSettingsValue('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject undefined', () => {
|
||||||
|
expect(validateSettingsValue(undefined)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject null', () => {
|
||||||
|
expect(validateSettingsValue(null)).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-string types', () => {
|
||||||
|
expect(validateSettingsValue(123)).toContain('string');
|
||||||
|
expect(validateSettingsValue(true)).toContain('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+63
-6
@@ -19,6 +19,7 @@ import MobileDrawers from './components/MobileDrawers';
|
|||||||
import BackgroundImport from './components/BackgroundImport';
|
import BackgroundImport from './components/BackgroundImport';
|
||||||
import HistoryPanel from './components/HistoryPanel';
|
import HistoryPanel from './components/HistoryPanel';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
|
import InlineTextEditor from './components/InlineTextEditor';
|
||||||
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
import { BackgroundService, type BackgroundConfig } from './services/backgroundService';
|
||||||
import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history';
|
import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history';
|
||||||
import { getCommandRegistry } from './services/commandRegistry';
|
import { getCommandRegistry } from './services/commandRegistry';
|
||||||
@@ -140,8 +141,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
pluginRegistry.initDefaults();
|
pluginRegistry.initDefaults();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Status bar
|
// Status bar – only count self as online when actually connected
|
||||||
const onlineCount = collab.cursors.length + 1;
|
const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0;
|
||||||
|
|
||||||
// Mobile drawers
|
// Mobile drawers
|
||||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||||
@@ -520,13 +521,37 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
setToolState(state);
|
setToolState(state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Inline text editor state
|
||||||
|
const [editingTextElement, setEditingTextElement] = useState<CADElement | null>(null);
|
||||||
|
|
||||||
const handleTextEdit = useCallback((el: CADElement) => {
|
const handleTextEdit = useCallback((el: CADElement) => {
|
||||||
const text = window.prompt('Text eingeben:', '');
|
setEditingTextElement(el);
|
||||||
if (text !== null && text.trim() !== '') {
|
}, []);
|
||||||
|
|
||||||
|
const handleTextEditorSubmit = useCallback((text: string) => {
|
||||||
|
if (editingTextElement && text.trim() !== '') {
|
||||||
|
const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } };
|
||||||
setElements((prev) => prev.map((e) =>
|
setElements((prev) => prev.map((e) =>
|
||||||
e.id === el.id ? { ...e, properties: { ...e.properties, text } } : e
|
e.id === editingTextElement.id ? updatedEl : e
|
||||||
));
|
));
|
||||||
|
// Push to Yjs CRDT for real-time sync
|
||||||
|
collab.setElement(updatedEl);
|
||||||
|
// Update in backend
|
||||||
|
if (token) {
|
||||||
|
setSavedStatus('Speichert…');
|
||||||
|
updateElement(token, updatedEl.id, updatedEl).then(() => {
|
||||||
|
setSavedStatus('gespeichert');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to save text edit:', err);
|
||||||
|
setSavedStatus('Fehler beim Speichern');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
setEditingTextElement(null);
|
||||||
|
}, [editingTextElement, collab, token]);
|
||||||
|
|
||||||
|
const handleTextEditorCancel = useCallback(() => {
|
||||||
|
setEditingTextElement(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCommandTrigger = useCallback((msg: string) => {
|
const handleCommandTrigger = useCallback((msg: string) => {
|
||||||
@@ -816,6 +841,19 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
}));
|
}));
|
||||||
setElements((prev) => [...prev, ...pasted]);
|
setElements((prev) => [...prev, ...pasted]);
|
||||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]);
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${pasted.length} Element(e) eingefügt`, type: 'info' }]);
|
||||||
|
// Push pasted elements to Yjs CRDT and backend
|
||||||
|
for (const el of pasted) {
|
||||||
|
collab.setElement(el);
|
||||||
|
}
|
||||||
|
if (drawingId && token) {
|
||||||
|
setSavedStatus('Speichert…');
|
||||||
|
Promise.all(pasted.map(el => createElementTyped(token, drawingId, el))).then(() => {
|
||||||
|
setSavedStatus('gespeichert');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to save pasted elements:', err);
|
||||||
|
setSavedStatus('Fehler beim Speichern');
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]);
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]);
|
||||||
}
|
}
|
||||||
@@ -847,6 +885,18 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
};
|
};
|
||||||
setElements((prev) => [...prev, imgEl]);
|
setElements((prev) => [...prev, imgEl]);
|
||||||
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]);
|
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Bild eingefügt', type: 'info' }]);
|
||||||
|
// Push image element to Yjs CRDT for real-time sync
|
||||||
|
collab.setElement(imgEl);
|
||||||
|
// Save to backend
|
||||||
|
if (drawingId && token) {
|
||||||
|
setSavedStatus('Speichert…');
|
||||||
|
createElementTyped(token, drawingId, imgEl).then(() => {
|
||||||
|
setSavedStatus('gespeichert');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to save image element:', err);
|
||||||
|
setSavedStatus('Fehler beim Speichern');
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(input.files[0]);
|
reader.readAsDataURL(input.files[0]);
|
||||||
}
|
}
|
||||||
@@ -892,7 +942,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
||||||
if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; }
|
if (action === 'ki-draw') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Zeichnen: Beschreibung eingeben', type: 'info' }]); return; }
|
||||||
if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; }
|
if (action === 'ki-analyze') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Analyse gestartet', type: 'info' }]); return; }
|
||||||
}, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled]);
|
}, [handleUndo, handleRedo, handleImport, handleExport, onNavigateBack, drawingId, token, elements, activeLayerId, gridEnabled, collab]);
|
||||||
|
|
||||||
const handleToolChange = useCallback((tool: string) => {
|
const handleToolChange = useCallback((tool: string) => {
|
||||||
setActiveTool(tool);
|
setActiveTool(tool);
|
||||||
@@ -1461,6 +1511,13 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editingTextElement && (
|
||||||
|
<InlineTextEditor
|
||||||
|
initialText={editingTextElement.properties?.text ?? ''}
|
||||||
|
onSubmit={handleTextEditorSubmit}
|
||||||
|
onCancel={handleTextEditorCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* InlineTextEditor – Floating input overlay for non-blocking text editing.
|
||||||
|
* Replaces window.prompt() for text element editing.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface InlineTextEditorProps {
|
||||||
|
initialText: string;
|
||||||
|
onSubmit: (text: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InlineTextEditor: React.FC<InlineTextEditorProps> = ({ initialText, onSubmit, onCancel }) => {
|
||||||
|
const [text, setText] = useState(initialText);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(text);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
onSubmit(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--color-bg-secondary, #2a2a2a)',
|
||||||
|
border: '1px solid var(--color-border, #444)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
minWidth: '320px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--color-text-secondary, #aaa)',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Text eingeben
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
background: 'var(--color-bg-primary, #1a1a1a)',
|
||||||
|
color: 'var(--color-text-primary, #fff)',
|
||||||
|
border: '1px solid var(--color-border, #555)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '13px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text-secondary, #aaa)',
|
||||||
|
border: '1px solid var(--color-border, #555)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen (Esc)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(text)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '13px',
|
||||||
|
background: 'var(--color-accent, #3498db)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bestätigen (Enter)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineTextEditor;
|
||||||
@@ -61,6 +61,12 @@ const DEFAULT_WS_URL = `wss://${window.location.host}`;
|
|||||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
||||||
|
|
||||||
|
// Refs for stable values that don't need to trigger effect re-runs
|
||||||
|
const wsUrlRef = useRef(wsUrl);
|
||||||
|
wsUrlRef.current = wsUrl;
|
||||||
|
const enabledRef = useRef(enabled);
|
||||||
|
enabledRef.current = enabled;
|
||||||
|
|
||||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||||
const providerRef = useRef<WebSocketProvider | null>(null);
|
const providerRef = useRef<WebSocketProvider | null>(null);
|
||||||
const awarenessRef = useRef<AwarenessManager | null>(null);
|
const awarenessRef = useRef<AwarenessManager | null>(null);
|
||||||
@@ -77,7 +83,9 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
|
|
||||||
// Initialize Yjs document, provider, and awareness
|
// Initialize Yjs document, provider, and awareness
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !docName) return;
|
const currentWsUrl = wsUrlRef.current;
|
||||||
|
const currentEnabled = enabledRef.current;
|
||||||
|
if (!currentEnabled || !docName) return;
|
||||||
|
|
||||||
const doc = new YjsDocument();
|
const doc = new YjsDocument();
|
||||||
yjsDocRef.current = doc;
|
yjsDocRef.current = doc;
|
||||||
@@ -86,7 +94,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
awarenessRef.current = awareness;
|
awarenessRef.current = awareness;
|
||||||
|
|
||||||
const provider = new WebSocketProvider({
|
const provider = new WebSocketProvider({
|
||||||
url: wsUrl,
|
url: currentWsUrl,
|
||||||
docName,
|
docName,
|
||||||
yjsDoc: doc,
|
yjsDoc: doc,
|
||||||
awareness,
|
awareness,
|
||||||
@@ -147,7 +155,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
providerRef.current = null;
|
providerRef.current = null;
|
||||||
awarenessRef.current = null;
|
awarenessRef.current = null;
|
||||||
};
|
};
|
||||||
}, [docName, wsUrl, userId, userName, userColor, enabled, token]);
|
}, [docName, userId, token]);
|
||||||
|
|
||||||
// Mutators
|
// Mutators
|
||||||
const setElement = useCallback((el: CADElement) => {
|
const setElement = useCallback((el: CADElement) => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* InlineTextEditor Tests
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import InlineTextEditor from '../src/components/InlineTextEditor';
|
||||||
|
|
||||||
|
describe('InlineTextEditor', () => {
|
||||||
|
it('should render with initial text', () => {
|
||||||
|
render(<InlineTextEditor initialText="Hello" onSubmit={() => {}} onCancel={() => {}} />);
|
||||||
|
const input = screen.getByDisplayValue('Hello');
|
||||||
|
expect(input).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSubmit with text when Enter is pressed', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<InlineTextEditor initialText="Test" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
const input = screen.getByDisplayValue('Test');
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onCancel when Escape is pressed', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(<InlineTextEditor initialText="Test" onSubmit={() => {}} onCancel={onCancel} />);
|
||||||
|
const input = screen.getByDisplayValue('Test');
|
||||||
|
fireEvent.keyDown(input, { key: 'Escape' });
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update text when typing', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<InlineTextEditor initialText="" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: 'New Text' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('New Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSubmit when confirm button is clicked', () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(<InlineTextEditor initialText="Initial" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
const confirmBtn = screen.getByText('Bestätigen (Enter)');
|
||||||
|
fireEvent.click(confirmBtn);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('Initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onCancel when cancel button is clicked', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(<InlineTextEditor initialText="" onSubmit={() => {}} onCancel={onCancel} />);
|
||||||
|
const cancelBtn = screen.getByText('Abbrechen (Esc)');
|
||||||
|
fireEvent.click(cancelBtn);
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user