fix: standardize API error responses, fix parseInt radix, harden Docker service

- Create api_response.ts helper for consistent { success, error/data } format
- Add radix parameter (10) to all parseInt calls across controllers and services
- Fix race condition in DockerService by making in-memory guard atomic
- Fix container command splitting to handle quoted arguments properly
- Stop leaking internal error.message to API responses; log details server-side

https://claude.ai/code/session_01JFvpTYgm8GiE4vJ4cJKsFx
This commit is contained in:
Claude 2026-03-24 09:30:49 +00:00
parent 9ca20a99e5
commit def1a0733f
No known key found for this signature in database
9 changed files with 81 additions and 51 deletions

View File

@ -1,5 +1,6 @@
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { BenchmarkService } from '#services/benchmark_service' import { BenchmarkService } from '#services/benchmark_service'
import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark' import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark'
import { RunBenchmarkJob } from '#jobs/run_benchmark_job' import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
@ -52,9 +53,11 @@ export default class BenchmarkController {
result, result,
}) })
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error)
logger.error(`[BenchmarkController] Sync benchmark failed: ${detail}`)
return response.status(500).send({ return response.status(500).send({
success: false, success: false,
error: error.message, error: 'Benchmark execution failed',
}) })
} }
} }
@ -168,11 +171,12 @@ export default class BenchmarkController {
percentile: submitResult.percentile, percentile: submitResult.percentile,
}) })
} catch (error) { } catch (error) {
// Pass through the status code from the service if available, otherwise default to 400 const detail = error instanceof Error ? error.message : String(error)
logger.error(`[BenchmarkController] Submit failed: ${detail}`)
const statusCode = (error as any).statusCode || 400 const statusCode = (error as any).statusCode || 400
return response.status(statusCode).send({ return response.status(statusCode).send({
success: false, success: false,
error: error.message, error: 'Failed to submit benchmark results',
}) })
} }
} }

View File

@ -5,6 +5,7 @@ import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#val
import KVStore from '#models/kv_store' import KVStore from '#models/kv_store'
import { SystemService } from '#services/system_service' import { SystemService } from '#services/system_service'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import { apiSuccess, apiError } from '../helpers/api_response.js'
@inject() @inject()
export default class ChatsController { export default class ChatsController {
@ -13,7 +14,7 @@ export default class ChatsController {
async inertia({ inertia, response }: HttpContext) { async inertia({ inertia, response }: HttpContext) {
const aiAssistantInstalled = await this.systemService.checkServiceInstalled(SERVICE_NAMES.OLLAMA) const aiAssistantInstalled = await this.systemService.checkServiceInstalled(SERVICE_NAMES.OLLAMA)
if (!aiAssistantInstalled) { if (!aiAssistantInstalled) {
return response.status(404).json({ error: 'AI Assistant service not installed' }) return response.status(404).json({ success: false, error: 'AI Assistant service not installed' })
} }
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled') const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
@ -29,11 +30,11 @@ export default class ChatsController {
} }
async show({ params, response }: HttpContext) { async show({ params, response }: HttpContext) {
const sessionId = parseInt(params.id) const sessionId = parseInt(params.id, 10)
const session = await this.chatService.getSession(sessionId) const session = await this.chatService.getSession(sessionId)
if (!session) { if (!session) {
return response.status(404).json({ error: 'Session not found' }) return response.status(404).json({ success: false, error: 'Session not found' })
} }
return session return session
@ -45,58 +46,48 @@ export default class ChatsController {
const session = await this.chatService.createSession(data.title, data.model) const session = await this.chatService.createSession(data.title, data.model)
return response.status(201).json(session) return response.status(201).json(session)
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to create session', error)
error: error instanceof Error ? error.message : 'Failed to create session',
})
} }
} }
async suggestions({ response }: HttpContext) { async suggestions({ response }: HttpContext) {
try { try {
const suggestions = await this.chatService.getChatSuggestions() const suggestions = await this.chatService.getChatSuggestions()
return response.status(200).json({ suggestions }) return response.status(200).json({ success: true, suggestions })
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to get suggestions', error)
error: error instanceof Error ? error.message : 'Failed to get suggestions',
})
} }
} }
async update({ params, request, response }: HttpContext) { async update({ params, request, response }: HttpContext) {
try { try {
const sessionId = parseInt(params.id) const sessionId = parseInt(params.id, 10)
const data = await request.validateUsing(updateSessionSchema) const data = await request.validateUsing(updateSessionSchema)
const session = await this.chatService.updateSession(sessionId, data) const session = await this.chatService.updateSession(sessionId, data)
return session return session
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to update session', error)
error: error instanceof Error ? error.message : 'Failed to update session',
})
} }
} }
async destroy({ params, response }: HttpContext) { async destroy({ params, response }: HttpContext) {
try { try {
const sessionId = parseInt(params.id) const sessionId = parseInt(params.id, 10)
await this.chatService.deleteSession(sessionId) await this.chatService.deleteSession(sessionId)
return response.status(204) return response.status(204)
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to delete session', error)
error: error instanceof Error ? error.message : 'Failed to delete session',
})
} }
} }
async addMessage({ params, request, response }: HttpContext) { async addMessage({ params, request, response }: HttpContext) {
try { try {
const sessionId = parseInt(params.id) const sessionId = parseInt(params.id, 10)
const data = await request.validateUsing(addMessageSchema) const data = await request.validateUsing(addMessageSchema)
const message = await this.chatService.addMessage(sessionId, data.role, data.content) const message = await this.chatService.addMessage(sessionId, data.role, data.content)
return response.status(201).json(message) return response.status(201).json(message)
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to add message', error)
error: error instanceof Error ? error.message : 'Failed to add message',
})
} }
} }
@ -105,9 +96,7 @@ export default class ChatsController {
const result = await this.chatService.deleteAllSessions() const result = await this.chatService.deleteAllSessions()
return response.status(200).json(result) return response.status(200).json(result)
} catch (error) { } catch (error) {
return response.status(500).json({ return apiError(response, 500, 'Failed to delete all sessions', error)
error: error instanceof Error ? error.message : 'Failed to delete all sessions',
})
} }
} }
} }

View File

@ -6,6 +6,7 @@ import app from '@adonisjs/core/services/app'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { sanitizeFilename } from '../utils/fs.js' import { sanitizeFilename } from '../utils/fs.js'
import { deleteFileSchema, getJobStatusSchema } from '#validators/rag' import { deleteFileSchema, getJobStatusSchema } from '#validators/rag'
import { apiError } from '../helpers/api_response.js'
@inject() @inject()
export default class RagController { export default class RagController {
@ -14,7 +15,7 @@ export default class RagController {
public async upload({ request, response }: HttpContext) { public async upload({ request, response }: HttpContext) {
const uploadedFile = request.file('file', { size: '50mb' }) const uploadedFile = request.file('file', { size: '50mb' })
if (!uploadedFile) { if (!uploadedFile) {
return response.status(400).json({ error: 'No file uploaded' }) return response.status(400).json({ success: false, error: 'No file uploaded' })
} }
if (!uploadedFile.isValid) { if (!uploadedFile.isValid) {
@ -38,6 +39,7 @@ export default class RagController {
}) })
return response.status(202).json({ return response.status(202).json({
success: true,
message: result.message, message: result.message,
jobId: result.jobId, jobId: result.jobId,
fileName, fileName,
@ -58,7 +60,7 @@ export default class RagController {
const status = await EmbedFileJob.getStatus(fullPath) const status = await EmbedFileJob.getStatus(fullPath)
if (!status.exists) { if (!status.exists) {
return response.status(404).json({ error: 'Job not found for this file' }) return response.status(404).json({ success: false, error: 'Job not found for this file' })
} }
return response.status(200).json(status) return response.status(200).json(status)
@ -66,16 +68,16 @@ export default class RagController {
public async getStoredFiles({ response }: HttpContext) { public async getStoredFiles({ response }: HttpContext) {
const files = await this.ragService.getStoredFiles() const files = await this.ragService.getStoredFiles()
return response.status(200).json({ files }) return response.status(200).json({ success: true, files })
} }
public async deleteFile({ request, response }: HttpContext) { public async deleteFile({ request, response }: HttpContext) {
const { source } = await request.validateUsing(deleteFileSchema) const { source } = await request.validateUsing(deleteFileSchema)
const result = await this.ragService.deleteFileBySource(source) const result = await this.ragService.deleteFileBySource(source)
if (!result.success) { if (!result.success) {
return response.status(500).json({ error: result.message }) return response.status(500).json({ success: false, error: result.message })
} }
return response.status(200).json({ message: result.message }) return response.status(200).json({ success: true, message: result.message })
} }
public async scanAndSync({ response }: HttpContext) { public async scanAndSync({ response }: HttpContext) {
@ -83,7 +85,7 @@ export default class RagController {
const syncResult = await this.ragService.scanAndSyncStorage() const syncResult = await this.ragService.scanAndSyncStorage()
return response.status(200).json(syncResult) return response.status(200).json(syncResult)
} catch (error) { } catch (error) {
return response.status(500).json({ error: 'Error scanning and syncing storage', details: error.message }) return apiError(response, 500, 'Error scanning and syncing storage', error)
} }
} }
} }

View File

@ -6,6 +6,7 @@ import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system'; import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { apiError } from '../helpers/api_response.js'
@inject() @inject()
export default class SystemController { export default class SystemController {
@ -35,7 +36,7 @@ export default class SystemController {
if (result.success) { if (result.success) {
response.send({ success: true, message: result.message }); response.send({ success: true, message: result.message });
} else { } else {
response.status(400).send({ error: result.message }); response.status(400).send({ success: false, error: result.message });
} }
} }
@ -43,7 +44,7 @@ export default class SystemController {
const payload = await request.validateUsing(affectServiceValidator); const payload = await request.validateUsing(affectServiceValidator);
const result = await this.dockerService.affectContainer(payload.service_name, payload.action); const result = await this.dockerService.affectContainer(payload.service_name, payload.action);
if (!result) { if (!result) {
response.internalServerError({ error: 'Failed to affect service' }); response.internalServerError({ success: false, error: 'Failed to affect service' });
return; return;
} }
response.send({ success: result.success, message: result.message }); response.send({ success: result.success, message: result.message });
@ -58,7 +59,7 @@ export default class SystemController {
const payload = await request.validateUsing(installServiceValidator); const payload = await request.validateUsing(installServiceValidator);
const result = await this.dockerService.forceReinstall(payload.service_name); const result = await this.dockerService.forceReinstall(payload.service_name);
if (!result) { if (!result) {
response.internalServerError({ error: 'Failed to force reinstall service' }); response.internalServerError({ success: false, error: 'Failed to force reinstall service' });
return; return;
} }
response.send({ success: result.success, message: result.message }); response.send({ success: result.success, message: result.message });
@ -94,6 +95,7 @@ export default class SystemController {
if (!status) { if (!status) {
response.status(500).send({ response.status(500).send({
success: false,
error: 'Failed to retrieve update status', error: 'Failed to retrieve update status',
}); });
return; return;
@ -132,7 +134,7 @@ export default class SystemController {
.first() .first()
if (!service) { if (!service) {
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` }) return response.status(404).send({ success: false, error: `Service ${serviceName} not found or not installed` })
} }
try { try {
@ -142,9 +144,9 @@ export default class SystemController {
hostArch, hostArch,
service.source_repo service.source_repo
) )
response.send({ versions: updates }) response.send({ success: true, versions: updates })
} catch (error) { } catch (error) {
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` }) return apiError(response, 500, 'Failed to fetch available versions', error)
} }
} }
@ -158,7 +160,7 @@ export default class SystemController {
if (result.success) { if (result.success) {
response.send({ success: true, message: result.message }) response.send({ success: true, message: result.message })
} else { } else {
response.status(400).send({ error: result.message }) response.status(400).send({ success: false, error: result.message })
} }
} }

View File

@ -0,0 +1,31 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
/**
* Standardized API response helpers.
*
* Success responses follow the shape `{ success: true, data?: ..., message?: ... }`.
* Error responses follow the shape `{ success: false, error: '...' }`.
*
* Internal error details are logged server-side and never leaked to the client.
*/
export function apiSuccess(data?: Record<string, unknown> | unknown[] | string) {
if (typeof data === 'string') {
return { success: true, message: data }
}
return { success: true, ...(data && typeof data === 'object' ? (Array.isArray(data) ? { data } : data) : {}) }
}
export function apiError(
response: HttpContext['response'],
status: number,
clientMessage: string,
internalError?: unknown
) {
if (internalError) {
const detail = internalError instanceof Error ? internalError.message : String(internalError)
logger.error(`[API] ${clientMessage}: ${detail}`)
}
return response.status(status).send({ success: false, error: clientMessage })
}

View File

@ -623,7 +623,7 @@ export class BenchmarkService {
return { return {
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0, events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,
total_time: totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30, total_time: totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30,
total_events: totalEventsMatch ? parseInt(totalEventsMatch[1]) : 0, total_events: totalEventsMatch ? parseInt(totalEventsMatch[1], 10) : 0,
} }
} }

View File

@ -194,16 +194,19 @@ export class DockerService {
} }
} }
// Check if installation is already in progress (database-level) // Atomic in-memory guard: check and set in a single operation to prevent
if (service.installation_status === 'installing') { // race conditions where two requests pass the check before either sets the flag.
if (this.activeInstallations.has(serviceName)) {
return { return {
success: false, success: false,
message: `Service ${serviceName} installation is already in progress`, message: `Service ${serviceName} installation is already in progress`,
} }
} }
this.activeInstallations.add(serviceName)
// Double-check with in-memory tracking (race condition protection) // Now check database-level status (safe because the in-memory flag blocks concurrent callers)
if (this.activeInstallations.has(serviceName)) { if (service.installation_status === 'installing') {
this.activeInstallations.delete(serviceName)
return { return {
success: false, success: false,
message: `Service ${serviceName} installation is already in progress`, message: `Service ${serviceName} installation is already in progress`,
@ -211,7 +214,6 @@ export class DockerService {
} }
// Mark installation as in progress // Mark installation as in progress
this.activeInstallations.add(serviceName)
service.installation_status = 'installing' service.installation_status = 'installing'
await service.save() await service.save()
@ -513,7 +515,7 @@ export class DockerService {
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }), ...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }), ...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
...(containerConfig?.Env && { Env: containerConfig.Env }), ...(containerConfig?.Env && { Env: containerConfig.Env }),
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}), ...(service.container_command ? { Cmd: service.container_command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg: string) => arg.replace(/^["']|["']$/g, '')) ?? [] } : {}),
// Ensure container is attached to the Nomad docker network in production // Ensure container is attached to the Nomad docker network in production
...(process.env.NODE_ENV === 'production' && { ...(process.env.NODE_ENV === 'production' && {
NetworkingConfig: { NetworkingConfig: {

View File

@ -223,7 +223,7 @@ export class ZIMExtractionService {
}); });
} }
// Start new section // Start new section
const level = parseInt(tagName.substring(1)); // Extract number from h2, h3, h4 const level = parseInt(tagName.substring(1), 10); // Extract number from h2, h3, h4
currentSection = { currentSection = {
heading: $el.text().replace(/\[edit\]/gi, '').trim(), heading: $el.text().replace(/\[edit\]/gi, '').trim(),
content: [], content: [],

View File

@ -44,7 +44,7 @@ export async function doResumableDownload({
}) })
const contentType = headResponse.headers['content-type'] || '' const contentType = headResponse.headers['content-type'] || ''
const totalBytes = parseInt(headResponse.headers['content-length'] || '0') const totalBytes = parseInt(headResponse.headers['content-length'] || '0', 10)
const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes' const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes'
// If allowedMimeTypes is provided, check content type // If allowedMimeTypes is provided, check content type