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(); /** Multer configured for in-memory storage (files go to Supabase, not local disk) */ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB }); /** Allowed file extensions for Turnitin submissions */ 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 = { 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 ); } /** * POST /api/submit * Accepts a file upload + metadata, validates, stores the file, * creates a job (deducting a ticket), and returns the job ID. */ router.post( '/api/submit', authenticateUser, upload.single('file'), async (req, res: Response): Promise => { const authReq = req as AuthenticatedRequest; try { // 1. Validate required fields 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; } // Parse filters (may come as stringified JSON) let filters: Record; 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; } // 2. Validate file exists if (!authReq.file) { res.status(400).json({ error: 'File is required' }); return; } // 3. Validate file type 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; } // 4. Validate filters. Legacy Turnitin supports small-match words, // percent, or off; modern Turnitin uses word threshold only. 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; } // 5. Fingerprint the payload before any state-changing operation. 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; } // A deterministic request/hash path makes concurrent retries upload the // same bytes to the same object before the database invariant resolves them. const storagePath = await uploadInputFile( authReq.userId, `${submissionRequestId}/${inputFileSha256}`, authReq.file.originalname, authReq.file.buffer, true, ); // 6. Atomically create one job/ticket ledger entry, or return the job // already created by another Space handling this exact request. 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, }); // Fetch updated ticket balance 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, }); // 7. Return success 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); // Handle specific error cases from the RPC 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;