diff --git a/backend/src/routes/blocks.ts b/backend/src/routes/blocks.ts index 326b8ba..cefb59a 100644 --- a/backend/src/routes/blocks.ts +++ b/backend/src/routes/blocks.ts @@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // List all blocks for a drawing fastify.get('/api/drawings/:drawingId/blocks', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); }); @@ -18,8 +21,11 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; - 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 })); }); @@ -27,7 +33,13 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.patch('/api/blocks/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + 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); if (!updated) return reply.code(404).send({ error: 'Block not found' }); return updated; @@ -37,6 +49,8 @@ export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.delete('/api/blocks/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'Block not found' }); return reply.code(204).send(); diff --git a/backend/src/routes/drawings.ts b/backend/src/routes/drawings.ts index fd10808..ed2b632 100644 --- a/backend/src/routes/drawings.ts +++ b/backend/src/routes/drawings.ts @@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // List drawings for a project fastify.get('/api/projects/:projectId/drawings', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); }); @@ -18,6 +21,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.get('/api/drawings/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!drawing) return reply.code(404).send({ error: 'Drawing not found' }); return drawing; @@ -27,7 +32,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.post('/api/projects/:projectId/drawings', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + 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 })); }); @@ -35,7 +46,13 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.patch('/api/drawings/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + if (body.name !== undefined) { + const nameErr = validateName(body.name); + if (nameErr) return reply.code(400).send({ error: nameErr }); + } const updated = db.updateDrawing(id, body); if (!updated) return reply.code(404).send({ error: 'Drawing not found' }); return updated; @@ -45,6 +62,8 @@ export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.delete('/api/drawings/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'Drawing not found' }); return reply.code(204).send(); diff --git a/backend/src/routes/elements.ts b/backend/src/routes/elements.ts index d8ccf99..5ecb1cd 100644 --- a/backend/src/routes/elements.ts +++ b/backend/src/routes/elements.ts @@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateIdParam } from '../utils/validation.js'; export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // List elements for a drawing fastify.get('/api/drawings/:drawingId/elements', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); }); @@ -18,6 +21,8 @@ export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; 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) => { if (!requireAuth(request, reply, authService)) return; 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; const updated = db.updateElement(id, body); 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) => { if (!requireAuth(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'Element not found' }); return reply.code(204).send(); diff --git a/backend/src/routes/layers.ts b/backend/src/routes/layers.ts index 3c0b219..9283ff2 100644 --- a/backend/src/routes/layers.ts +++ b/backend/src/routes/layers.ts @@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // List layers for a drawing fastify.get('/api/drawings/:drawingId/layers', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); }); @@ -18,7 +21,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + 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 })); }); @@ -26,7 +35,13 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.patch('/api/layers/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + if (body.name !== undefined) { + const nameErr = validateName(body.name); + if (nameErr) return reply.code(400).send({ error: nameErr }); + } const updated = db.updateLayer(id, body); if (!updated) return reply.code(404).send({ error: 'Layer not found' }); return updated; @@ -36,6 +51,8 @@ export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterf fastify.delete('/api/layers/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'Layer not found' }); return reply.code(204).send(); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 7043139..427d501 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -4,6 +4,7 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateIdParam } from '../utils/validation.js'; function extractToken(request: any): string | null { const auth = request.headers?.authorization; @@ -46,6 +47,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas 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 idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); 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' }); @@ -61,6 +64,8 @@ export function registerNotificationRoutes(fastify: FastifyInstance, db: Databas 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 idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); 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' }); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 22cc27f..3cd389d 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -5,6 +5,7 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // 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) => { if (!requireAuth(request, reply, authService)) return; 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); if (!project) return reply.code(404).send({ error: 'Project not found' }); return project; @@ -28,7 +31,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte const user = requireAuth(request, reply, authService); if (!user) return; const body = request.body as Partial; - 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 })); }); @@ -36,7 +40,13 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.patch('/api/projects/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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; + if (body.name !== undefined) { + const nameErr = validateName(body.name); + if (nameErr) return reply.code(400).send({ error: nameErr }); + } const updated = db.updateProject(id, body); if (!updated) return reply.code(404).send({ error: 'Project not found' }); return updated; @@ -46,6 +56,8 @@ export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInte fastify.delete('/api/projects/:id', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'Project not found' }); return reply.code(204).send(); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 90867cb..9cf388d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -5,12 +5,15 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js'; export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // Get a single setting by key fastify.get('/api/settings/:key', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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); if (!setting) return reply.code(404).send({ error: 'Setting not found' }); return setting; @@ -20,11 +23,12 @@ export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInt fastify.put('/api/settings/:key', async (request, reply) => { if (!requireAuth(request, reply, authService)) return; 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 }; - if (body.value === undefined) { - return { error: 'Value is required' }; - } - db.setSetting(key, body.value); - return { key, value: body.value, updated_at: new Date().toISOString() }; + const valueErr = validateSettingsValue(body.value); + if (valueErr) return reply.code(400).send({ error: valueErr }); + db.setSetting(key, body.value as string); + return { key, value: body.value as string, updated_at: new Date().toISOString() }; }); } diff --git a/backend/src/routes/shares.ts b/backend/src/routes/shares.ts index 15ae173..c32b246 100644 --- a/backend/src/routes/shares.ts +++ b/backend/src/routes/shares.ts @@ -4,6 +4,7 @@ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateIdParam } from '../utils/validation.js'; function extractToken(request: any): string | null { const auth = request.headers?.authorization; @@ -21,6 +22,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf 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 projIdErr = validateIdParam(projectId, 'projectId'); + if (projIdErr) return reply.code(400).send({ error: projIdErr }); 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' }); @@ -34,6 +37,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf 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 projIdErr = validateIdParam(projectId, 'projectId'); + if (projIdErr) return reply.code(400).send({ error: projIdErr }); 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' }); @@ -69,6 +74,8 @@ export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterf 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 idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); const share = db.getProjectShare(id); if (!share) return reply.code(404).send({ error: 'Share not found' }); const project = db.getProject(share.project_id); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 0fb7705..2d4b3fb 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js'; import type { AuthService } from '../auth/AuthService.js'; +import { validateIdParam } from '../utils/validation.js'; function extractToken(request: FastifyRequest): string | null { const auth = request.headers?.authorization; @@ -43,6 +44,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa fastify.get('/api/users/:id', async (request, reply) => { if (!requireAdmin(request, reply, authService)) return; 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); if (!user) return reply.code(404).send({ error: 'User not found' }); const { password_hash, ...safe } = user; @@ -53,6 +56,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa fastify.patch('/api/users/:id', async (request, reply) => { if (!requireAdmin(request, reply, authService)) return; 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; // Prevent password update via this endpoint delete body.password_hash; @@ -66,6 +71,8 @@ export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterfa fastify.delete('/api/users/:id', async (request, reply) => { if (!requireAdmin(request, reply, authService)) return; 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); if (!ok) return reply.code(404).send({ error: 'User not found' }); return reply.code(204).send(); diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 0000000..7e5e5f4 --- /dev/null +++ b/backend/src/utils/validation.ts @@ -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; +} diff --git a/backend/tests/validation.test.ts b/backend/tests/validation.test.ts new file mode 100644 index 0000000..e2c98ab --- /dev/null +++ b/backend/tests/validation.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ab068f..df8bc43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import MobileDrawers from './components/MobileDrawers'; import BackgroundImport from './components/BackgroundImport'; import HistoryPanel from './components/HistoryPanel'; import SettingsModal from './components/SettingsModal'; +import InlineTextEditor from './components/InlineTextEditor'; import { BackgroundService, type BackgroundConfig } from './services/backgroundService'; import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history'; import { getCommandRegistry } from './services/commandRegistry'; @@ -140,8 +141,8 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack pluginRegistry.initDefaults(); }, []); - // Status bar - const onlineCount = collab.cursors.length + 1; + // Status bar – only count self as online when actually connected + const onlineCount = collab.status === 'connected' ? collab.cursors.length : 0; // Mobile drawers const [mobileLeftOpen, setMobileLeftOpen] = useState(false); @@ -520,13 +521,37 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack setToolState(state); }, []); + // Inline text editor state + const [editingTextElement, setEditingTextElement] = useState(null); + const handleTextEdit = useCallback((el: CADElement) => { - const text = window.prompt('Text eingeben:', ''); - if (text !== null && text.trim() !== '') { + setEditingTextElement(el); + }, []); + + const handleTextEditorSubmit = useCallback((text: string) => { + if (editingTextElement && text.trim() !== '') { + const updatedEl = { ...editingTextElement, properties: { ...editingTextElement.properties, text } }; 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) => { @@ -816,6 +841,19 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack })); setElements((prev) => [...prev, ...pasted]); 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 { setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zwischenablage leer', type: 'info' }]); } @@ -847,6 +885,18 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack }; setElements((prev) => [...prev, imgEl]); 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]); } @@ -892,7 +942,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack 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-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) => { setActiveTool(tool); @@ -1461,6 +1511,13 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack )} + {editingTextElement && ( + + )} ); }; diff --git a/frontend/src/components/InlineTextEditor.tsx b/frontend/src/components/InlineTextEditor.tsx new file mode 100644 index 0000000..4669a2e --- /dev/null +++ b/frontend/src/components/InlineTextEditor.tsx @@ -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 = ({ initialText, onSubmit, onCancel }) => { + const [text, setText] = useState(initialText); + const inputRef = useRef(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 ( +
+ + 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', + }} + /> +
+ + +
+
+ ); +}; + +export default InlineTextEditor; diff --git a/frontend/src/crdt/useYjsBinding.ts b/frontend/src/crdt/useYjsBinding.ts index fb10288..65d4080 100644 --- a/frontend/src/crdt/useYjsBinding.ts +++ b/frontend/src/crdt/useYjsBinding.ts @@ -61,6 +61,12 @@ const DEFAULT_WS_URL = `wss://${window.location.host}`; export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { 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(null); const providerRef = useRef(null); const awarenessRef = useRef(null); @@ -77,7 +83,9 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { // Initialize Yjs document, provider, and awareness useEffect(() => { - if (!enabled || !docName) return; + const currentWsUrl = wsUrlRef.current; + const currentEnabled = enabledRef.current; + if (!currentEnabled || !docName) return; const doc = new YjsDocument(); yjsDocRef.current = doc; @@ -86,7 +94,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { awarenessRef.current = awareness; const provider = new WebSocketProvider({ - url: wsUrl, + url: currentWsUrl, docName, yjsDoc: doc, awareness, @@ -147,7 +155,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { providerRef.current = null; awarenessRef.current = null; }; - }, [docName, wsUrl, userId, userName, userColor, enabled, token]); + }, [docName, userId, token]); // Mutators const setElement = useCallback((el: CADElement) => { diff --git a/frontend/tests/InlineTextEditor.test.tsx b/frontend/tests/InlineTextEditor.test.tsx new file mode 100644 index 0000000..51d7aef --- /dev/null +++ b/frontend/tests/InlineTextEditor.test.tsx @@ -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( {}} onCancel={() => {}} />); + const input = screen.getByDisplayValue('Hello'); + expect(input).toBeDefined(); + }); + + it('should call onSubmit with text when Enter is pressed', () => { + const onSubmit = vi.fn(); + render( {}} />); + 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( {}} 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( {}} />); + 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( {}} />); + 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( {}} onCancel={onCancel} />); + const cancelBtn = screen.getByText('Abbrechen (Esc)'); + fireEvent.click(cancelBtn); + expect(onCancel).toHaveBeenCalled(); + }); +});