| import { NextFunction, Request, Router, Response } from 'express'; |
| import multer from 'multer'; |
| import { authenticateUser, AuthenticatedRequest } from '../middleware/auth'; |
| import { deleteInputFile, uploadInputFileFromPath } from '../../db/storage'; |
| import { |
| createJobWithTicket, |
| getJobBySubmissionRequestId, |
| getUserProfile, |
| } from '../../db/tickets'; |
| import { logger } from '../../utils/logger'; |
| import { createHash, randomUUID } from 'crypto'; |
| import { createReadStream, promises as fsPromises } from 'fs'; |
| import * as os from 'os'; |
|
|
| const router = Router(); |
|
|
| |
| |
| |
| |
| const upload = multer({ |
| storage: multer.diskStorage({ |
| destination: os.tmpdir(), |
| filename: (_req, file, callback) => { |
| callback(null, `relv-upload-${randomUUID()}${getFileExtension(file.originalname)}`); |
| }, |
| }), |
| limits: { fileSize: 100 * 1024 * 1024 }, |
| }); |
|
|
| function receiveUpload( |
| req: Request, |
| res: Response, |
| next: NextFunction, |
| ): void { |
| upload.single('file')(req, res, (error: unknown) => { |
| if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') { |
| res.status(413).json({ error: 'File exceeds the 100MB upload limit' }); |
| return; |
| } |
| if (error) { |
| next(error); |
| return; |
| } |
| next(); |
| }); |
| } |
|
|
| |
| const ALLOWED_EXTENSIONS = new Set([ |
| '.docx', |
| '.xlsx', |
| '.pptx', |
| '.ps', |
| '.pdf', |
| '.html', |
| '.rtf', |
| '.odt', |
| '.hwp', |
| '.txt', |
| ]); |
| const ALLOWED_MODES = new Set(['upload', 'resubmit']); |
| const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; |
|
|
| const DEFAULT_FILTERS: Record<string, unknown> = { |
| excludeBibliography: false, |
| excludeQuotes: false, |
| excludeCitations: false, |
| excludeSmallMatches: true, |
| smallMatchMode: 'words', |
| smallMatchThreshold: 8, |
| }; |
|
|
| function getFileExtension(filename: string): string { |
| const lastDot = filename.lastIndexOf('.'); |
| return lastDot >= 0 ? filename.slice(lastDot).toLowerCase() : ''; |
| } |
|
|
| async function sha256File(filePath: string): Promise<string> { |
| const hash = createHash('sha256'); |
| for await (const chunk of createReadStream(filePath)) { |
| hash.update(chunk as Buffer); |
| } |
| return hash.digest('hex'); |
| } |
|
|
| function existingJobMatchesRequest( |
| existingJob: { |
| assignment_target_id: string; |
| mode: string; |
| input_file_name: string; |
| input_file_size: number | null; |
| input_file_sha256: string | null; |
| }, |
| request: { |
| assignmentTargetId: string; |
| mode: string; |
| inputFileName: string; |
| inputFileSize: number; |
| inputFileSha256: string; |
| }, |
| ): boolean { |
| return ( |
| existingJob.assignment_target_id === request.assignmentTargetId && |
| existingJob.mode === request.mode && |
| existingJob.input_file_name === request.inputFileName && |
| existingJob.input_file_size === request.inputFileSize && |
| existingJob.input_file_sha256 === request.inputFileSha256 |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| router.post( |
| '/api/submit', |
| authenticateUser, |
| receiveUpload, |
| async (req, res: Response): Promise<void> => { |
| const authReq = req as AuthenticatedRequest; |
| const temporaryUploadPath = authReq.file?.path; |
|
|
| try { |
| |
| const { |
| assignment_target_id, |
| mode, |
| filters: filtersRaw, |
| submission_request_id: bodySubmissionRequestId, |
| } = authReq.body; |
|
|
| if (!assignment_target_id || !mode) { |
| res.status(400).json({ |
| error: 'Missing required fields: assignment_target_id, mode', |
| }); |
| return; |
| } |
|
|
| if (!ALLOWED_MODES.has(String(mode))) { |
| res.status(400).json({ |
| error: 'Invalid mode. Allowed: upload, resubmit', |
| }); |
| return; |
| } |
|
|
| const suppliedRequestId = String( |
| bodySubmissionRequestId || authReq.get('Idempotency-Key') || '', |
| ).trim(); |
| const submissionRequestId = suppliedRequestId || randomUUID(); |
|
|
| if (!UUID_PATTERN.test(submissionRequestId)) { |
| res.status(400).json({ error: 'Invalid submission request ID' }); |
| return; |
| } |
|
|
| |
| let filters: Record<string, unknown>; |
| try { |
| const parsedFilters = |
| typeof filtersRaw === 'string' |
| ? JSON.parse(filtersRaw) |
| : filtersRaw && typeof filtersRaw === 'object' |
| ? filtersRaw |
| : {}; |
| filters = { ...DEFAULT_FILTERS, ...parsedFilters }; |
| } catch { |
| res.status(400).json({ error: 'Invalid filters JSON' }); |
| return; |
| } |
|
|
| |
| if (!authReq.file) { |
| res.status(400).json({ error: 'File is required' }); |
| return; |
| } |
| const uploadedFile = authReq.file; |
|
|
| |
| const ext = getFileExtension(uploadedFile.originalname); |
| if (!ALLOWED_EXTENSIONS.has(ext)) { |
| res.status(400).json({ |
| error: `Unsupported file type: ${ext}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`, |
| }); |
| return; |
| } |
|
|
| |
| |
| const smallMatchMode = |
| filters.smallMatchMode === 'percent' || |
| filters.smallMatchMode === 'off' || |
| filters.smallMatchMode === 'words' |
| ? filters.smallMatchMode |
| : 'words'; |
| filters.smallMatchMode = smallMatchMode; |
|
|
| if (filters.excludeSmallMatches === true && smallMatchMode !== 'off') { |
| let threshold = Number(filters.smallMatchThreshold) || 8; |
| const maxThreshold = smallMatchMode === 'percent' ? 100 : 40; |
| threshold = Math.max(1, Math.min(maxThreshold, threshold)); |
| filters.smallMatchThreshold = threshold; |
| } else { |
| filters.excludeSmallMatches = false; |
| filters.smallMatchMode = 'off'; |
| filters.smallMatchThreshold = null; |
| } |
|
|
| |
| const inputFileSha256 = await sha256File(uploadedFile.path); |
|
|
| const existingJob = await getJobBySubmissionRequestId( |
| authReq.userId, |
| submissionRequestId, |
| ); |
|
|
| if (existingJob) { |
| if (!existingJobMatchesRequest(existingJob, { |
| assignmentTargetId: String(assignment_target_id), |
| mode: String(mode), |
| inputFileName: uploadedFile.originalname, |
| inputFileSize: uploadedFile.size, |
| inputFileSha256, |
| })) { |
| res.status(409).json({ |
| error: 'Submission request ID was already used for a different file or configuration', |
| }); |
| return; |
| } |
|
|
| const profile = await getUserProfile(authReq.userId); |
| logger.info('Idempotent submit replay returned existing job', { |
| jobId: existingJob.id, |
| userId: authReq.userId, |
| submissionRequestId, |
| }); |
| res.status(200).json({ |
| jobId: existingJob.id, |
| ticketBalance: profile?.ticket_balance ?? 0, |
| idempotentReplay: true, |
| }); |
| return; |
| } |
|
|
| |
| |
| const storagePath = await uploadInputFileFromPath( |
| authReq.userId, |
| `${submissionRequestId}/${inputFileSha256}`, |
| uploadedFile.originalname, |
| uploadedFile.path, |
| true, |
| ); |
|
|
| |
| |
| const creation = await (async () => { |
| try { |
| return await createJobWithTicket({ |
| userId: authReq.userId, |
| assignmentTargetId: assignment_target_id, |
| mode, |
| filters, |
| inputFileName: uploadedFile.originalname, |
| inputStoragePath: storagePath, |
| inputFileSize: uploadedFile.size, |
| inputFileSha256, |
| submissionRequestId, |
| }); |
| } catch (creationError) { |
| |
| |
| let recoveredJob; |
| try { |
| recoveredJob = await getJobBySubmissionRequestId( |
| authReq.userId, |
| submissionRequestId, |
| ); |
| } catch (recoveryError) { |
| logger.warn('Could not verify job creation after RPC failure; preserving input object', { |
| userId: authReq.userId, |
| submissionRequestId, |
| error: |
| recoveryError instanceof Error |
| ? recoveryError.message |
| : String(recoveryError), |
| }); |
| throw creationError; |
| } |
|
|
| if (recoveredJob) { |
| if (!existingJobMatchesRequest(recoveredJob, { |
| assignmentTargetId: String(assignment_target_id), |
| mode: String(mode), |
| inputFileName: uploadedFile.originalname, |
| inputFileSize: uploadedFile.size, |
| inputFileSha256, |
| })) { |
| await deleteInputFile(storagePath).catch(() => {}); |
| throw new Error('Idempotency key recovered a different job payload'); |
| } |
| logger.warn('Recovered committed job after job-creation response failure', { |
| jobId: recoveredJob.id, |
| userId: authReq.userId, |
| submissionRequestId, |
| }); |
| return { jobId: recoveredJob.id, created: false }; |
| } |
|
|
| await deleteInputFile(storagePath).catch((cleanupError: unknown) => { |
| logger.warn('Failed to roll back staged input after job creation failure', { |
| storagePath, |
| error: |
| cleanupError instanceof Error |
| ? cleanupError.message |
| : String(cleanupError), |
| }); |
| }); |
| throw creationError; |
| } |
| })(); |
|
|
| |
| const profile = await getUserProfile(authReq.userId); |
| const ticketBalance = profile?.ticket_balance ?? 0; |
|
|
| logger.info(creation.created ? 'Job submitted successfully' : 'Idempotent submit race resolved', { |
| jobId: creation.jobId, |
| userId: authReq.userId, |
| fileName: uploadedFile.originalname, |
| submissionRequestId, |
| }); |
|
|
| |
| res.status(creation.created ? 201 : 200).json({ |
| jobId: creation.jobId, |
| ticketBalance, |
| idempotentReplay: !creation.created, |
| }); |
| } catch (err: unknown) { |
| const message = err instanceof Error ? err.message : String(err); |
|
|
| |
| if (message.includes('insufficient') || message.includes('ticket')) { |
| res.status(402).json({ error: 'Insufficient ticket balance' }); |
| return; |
| } |
|
|
| if (message.includes('Idempotency key')) { |
| res.status(409).json({ error: 'Submission request ID conflict' }); |
| return; |
| } |
|
|
| logger.error('Submit endpoint error', { |
| userId: authReq.userId, |
| error: message, |
| }); |
|
|
| res.status(500).json({ error: 'Internal server error' }); |
| } finally { |
| if (temporaryUploadPath) { |
| await fsPromises.unlink(temporaryUploadPath).catch((error: unknown) => { |
| logger.warn('Failed to remove temporary upload file', { |
| temporaryUploadPath, |
| error: error instanceof Error ? error.message : String(error), |
| }); |
| }); |
| } |
| } |
| }, |
| ); |
|
|
| export default router; |
|
|