| import { Router, Response } from 'express'; |
| import multer from 'multer'; |
| import { authenticateUser, AuthenticatedRequest } from '../middleware/auth'; |
| import { uploadInputFile } from '../../db/storage'; |
| import { |
| createJobWithTicket, |
| getJobBySubmissionRequestId, |
| getUserProfile, |
| } from '../../db/tickets'; |
| import { logger } from '../../utils/logger'; |
| import { createHash, randomUUID } from 'crypto'; |
|
|
| const router = Router(); |
|
|
| |
| const upload = multer({ |
| storage: multer.memoryStorage(), |
| limits: { fileSize: 50 * 1024 * 1024 }, |
| }); |
|
|
| |
| 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() : ''; |
| } |
|
|
| 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, |
| upload.single('file'), |
| async (req, res: Response): Promise<void> => { |
| const authReq = req as AuthenticatedRequest; |
|
|
| 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 ext = getFileExtension(authReq.file.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 = createHash('sha256') |
| .update(authReq.file.buffer) |
| .digest('hex'); |
|
|
| const existingJob = await getJobBySubmissionRequestId( |
| authReq.userId, |
| submissionRequestId, |
| ); |
|
|
| if (existingJob) { |
| if (!existingJobMatchesRequest(existingJob, { |
| assignmentTargetId: String(assignment_target_id), |
| mode: String(mode), |
| inputFileName: authReq.file.originalname, |
| inputFileSize: authReq.file.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 uploadInputFile( |
| authReq.userId, |
| `${submissionRequestId}/${inputFileSha256}`, |
| authReq.file.originalname, |
| authReq.file.buffer, |
| true, |
| ); |
|
|
| |
| |
| const creation = await createJobWithTicket({ |
| userId: authReq.userId, |
| assignmentTargetId: assignment_target_id, |
| mode, |
| filters, |
| inputFileName: authReq.file.originalname, |
| inputStoragePath: storagePath, |
| inputFileSize: authReq.file.size, |
| inputFileSha256, |
| submissionRequestId, |
| }); |
|
|
| |
| 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: authReq.file.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' }); |
| } |
| }, |
| ); |
|
|
| export default router; |
|
|