diff --git a/docs/wiki/3.01-API.md b/docs/wiki/3.01-API.md index 01f4201c07..60c4c74a82 100755 --- a/docs/wiki/3.01-API.md +++ b/docs/wiki/3.01-API.md @@ -397,10 +397,10 @@ curl http://127.0.0.1:3876/tags | Code | HTTP Status | Description | | ---------------- | ----------- | --------------------- | -| `TASK_NOT_FOUND` | 404 | Task does not exist | -| `INVALID_INPUT` | 400 | Invalid request body | -| `NOT_FOUND` | 404 | Route not found | -| `INTERNAL_ERROR` | 500 | Internal server error | +| `TASK_NOT_FOUND` | 404 | Task does not exist | +| `INVALID_INPUT` | 400 | Invalid request body, or a field has the wrong value type (see `error.details`) | +| `NOT_FOUND` | 404 | Route not found | +| `INTERNAL_ERROR` | 500 | Internal server error | --- diff --git a/electron/local-rest-api.ts b/electron/local-rest-api.ts index a7dcd1b2c9..8dd9492616 100644 --- a/electron/local-rest-api.ts +++ b/electron/local-rest-api.ts @@ -122,6 +122,20 @@ const handleHttpRequest = async ( req: IncomingMessage, res: ServerResponse, ): Promise => { + // Reject everything while disabled. server.close() stops accepting new + // sockets, but an in-flight keep-alive connection could still be served + // during the close window; this makes the off switch immediate. + if (!isEnabled) { + writeJson(res, 503, { + ok: false, + error: { + code: 'API_DISABLED', + message: 'Local REST API is disabled', + }, + }); + return; + } + // Block DNS rebinding: reject requests with unexpected Host headers const host = req.headers.host; if (!host || !ALLOWED_HOSTS.has(host)) { @@ -237,8 +251,15 @@ export const initLocalRestApi = (): void => { void handleHttpRequest(req, res); }); - server.on('error', (error) => { + server.on('error', (error: NodeJS.ErrnoException) => { isListening = false; + if (error.code === 'EADDRINUSE') { + warn( + `[local-rest-api] Port ${LOCAL_REST_API_PORT} is in use — API could not start. ` + + `Another process is holding it; free it and toggle the API off/on to retry.`, + ); + return; + } warn('[local-rest-api] Server error', error); }); @@ -267,15 +288,23 @@ const stopServer = (): void => { return; } + // Reset eagerly: server.close() only invokes its callback once every socket + // has closed, so a lingering keep-alive connection would otherwise leave + // isListening=true forever and make a later re-enable a no-op (#7484). + isListening = false; + server.close((error) => { if (error) { warn('[local-rest-api] Failed to stop server', error); return; } - isListening = false; log('[local-rest-api] Server stopped'); }); + + // Force keep-alive sockets shut so the API stops serving immediately on + // disable and close() can actually complete. + server.closeAllConnections(); }; export const updateLocalRestApiConfig = (cfg: GlobalConfigState): void => { diff --git a/src/app/core/electron/local-rest-api-handler.service.spec.ts b/src/app/core/electron/local-rest-api-handler.service.spec.ts index c64aff8e62..67715713b5 100644 --- a/src/app/core/electron/local-rest-api-handler.service.spec.ts +++ b/src/app/core/electron/local-rest-api-handler.service.spec.ts @@ -537,6 +537,19 @@ describe('LocalRestApiHandlerService', () => { }); }); + it('should return 400 when an allowed field has an invalid type', async () => { + const response = await sendRequestAndWait( + createRequest('POST', '/tasks', { + body: { title: 'New Task', timeEstimate: 'not-a-number' }, + }), + ); + + expect(response.body.ok).toBe(false); + expect(response.status).toBe(400); + expect((response.body as any).error.code).toBe('INVALID_INPUT'); + expect(taskServiceMock.add).not.toHaveBeenCalled(); + }); + it('should reject subTaskIds in body with 400', async () => { const response = await sendRequestAndWait( createRequest('POST', '/tasks', { @@ -780,6 +793,44 @@ describe('LocalRestApiHandlerService', () => { expect((response.body as any).error.code).toBe('UNSUPPORTED_FIELD'); expect(taskServiceMock.update).not.toHaveBeenCalled(); }); + + it('should return 400 (not dispatch) when a field has an invalid type', async () => { + const mockTask = createMockTask('task-1'); + Object.defineProperty(taskServiceMock, 'getByIdOnce$', { + get: () => (_id: string) => of(mockTask), + }); + + const response = await sendRequestAndWait( + createRequest('PATCH', '/tasks/task-1', { + body: { tagIds: 123, timeEstimate: 'abc' }, + }), + ); + + expect(response.body.ok).toBe(false); + expect(response.status).toBe(400); + expect((response.body as any).error.code).toBe('INVALID_INPUT'); + expect(taskServiceMock.update).not.toHaveBeenCalled(); + }); + + it('should accept valid typed fields and dispatch them', async () => { + const mockTask = createMockTask('task-1'); + Object.defineProperty(taskServiceMock, 'getByIdOnce$', { + get: () => (_id: string) => of(mockTask), + }); + + const response = await sendRequestAndWait( + createRequest('PATCH', '/tasks/task-1', { + body: { tagIds: ['tag-1'], timeEstimate: 1000, isDone: true }, + }), + ); + + expect(response.body.ok).toBe(true); + expect(taskServiceMock.update).toHaveBeenCalledWith('task-1', { + tagIds: ['tag-1'], + timeEstimate: 1000, + isDone: true, + }); + }); }); describe('DELETE /tasks/:id', () => { diff --git a/src/app/core/electron/local-rest-api-handler.service.ts b/src/app/core/electron/local-rest-api-handler.service.ts index 6b20d22f28..a5ddb97479 100644 --- a/src/app/core/electron/local-rest-api-handler.service.ts +++ b/src/app/core/electron/local-rest-api-handler.service.ts @@ -1,5 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { firstValueFrom } from 'rxjs'; +import typia from 'typia'; import { TaskService } from '../../features/tasks/task.service'; import { Task, TaskWithSubTasks } from '../../features/tasks/task.model'; import { TaskArchiveService } from '../../features/archive/task-archive.service'; @@ -56,6 +57,48 @@ const pickAllowedFields = (body: Record): Partial => { return result as Partial; }; +/** + * Value-level types for the fields writable via the REST API. Keys mirror + * ALLOWED_TASK_FIELDS; `pickAllowedFields` filters by key only, so this is + * where the *values* get checked. Without it a caller could push a wrong-typed + * value (e.g. `tagIds: 123`, `timeEstimate: 'abc'`) straight into the store and + * the synced op-log, where it corrupts state locally and trips typia-as-corrupt + * on other devices when the op replays. + */ +interface WritableTaskFields { + title?: string; + notes?: string; + isDone?: boolean; + timeEstimate?: number; + timeSpent?: number; + projectId?: string; + tagIds?: string[]; + dueDay?: string | null; + dueWithTime?: number | null; + plannedAt?: number; +} + +type FieldTypeError = { path: string; expected: string }; + +/** + * Validates the value types of already-key-filtered task fields. The create + * path is separately guarded by `typia.assert` in the task service (a + * bad value throws → generic 500); validating here lets both create and PATCH + * reject bad input with a clean 400 before anything is dispatched. + */ +const validateWritableFields = ( + fields: Partial, +): { ok: true } | { ok: false; errors: FieldTypeError[] } => { + const result = typia.validate(fields); + if (result.success) { + return { ok: true }; + } + return { + ok: false, + errors: result.errors.map((e) => ({ path: e.path, expected: e.expected })), + }; +}; + const firstRejectedField = (body: Record): string | undefined => REJECTED_TASK_FIELDS.find((field) => field in body); @@ -351,6 +394,17 @@ export class LocalRestApiHandlerService { const title = body.title.trim(); const additionalFields = pickAllowedFields(body); + const validation = validateWritableFields(additionalFields); + if (!validation.ok) { + return createErrorResponse( + requestId, + 400, + 'INVALID_INPUT', + 'One or more task fields have an invalid type', + validation.errors, + ); + } + if ('parentId' in body) { if (typeof body.parentId !== 'string' || !body.parentId) { return createErrorResponse( @@ -441,12 +495,24 @@ export class LocalRestApiHandlerService { ); } + const changes = pickAllowedFields(body); + const validation = validateWritableFields(changes); + if (!validation.ok) { + return createErrorResponse( + requestId, + 400, + 'INVALID_INPUT', + 'One or more task fields have an invalid type', + validation.errors, + ); + } + const task = await this._getTaskById(taskId); if (!task) { return createErrorResponse(requestId, 404, 'TASK_NOT_FOUND', 'Task not found'); } - this._taskService.update(taskId, pickAllowedFields(body)); + this._taskService.update(taskId, changes); return createSuccessResponse(requestId, 200, await this._getTaskById(taskId)); }