const express = require('express'); const multer = require('multer'); const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const cors = require('cors'); const archiver = require('archiver'); const https = require('https'); const http = require('http'); const app = express(); const PORT = process.env.PORT || 3001; // Store job metadata for downloading later const jobStore = {}; // Google Drive API key — set as a Secret in HF Spaces Settings // (Settings → Variables and Secrets → New Secret: GOOGLE_DRIVE_API_KEY) const GDRIVE_API_KEY = process.env.GOOGLE_DRIVE_API_KEY || ''; app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, '../frontend/dist'))); // ── Upload dir ──────────────────────────────────────────────────────────── const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadDir), filename: (req, file, cb) => { const u = Date.now() + '-' + Math.round(Math.random() * 1e9); cb(null, u + '-' + file.originalname); }, }); const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024 } }); const R_SCRIPT_PATH = path.join(__dirname, '..', 'lc_ms_peak_picker.R'); // ── Helper: run R on one file ───────────────────────────────────────────── function runRScript(inputFile, outputFile, params) { return new Promise((resolve, reject) => { const { ppm, sn, min_pw, max_pw, pref_k, pref_i, sample_name } = params; const args = [ R_SCRIPT_PATH, '--input', inputFile, '--output', outputFile, '--ppm', ppm, '--sn', sn, '--min_pw', min_pw, '--max_pw', max_pw, '--pref_k', pref_k, '--pref_i', pref_i, '--sample_name', sample_name, ]; const proc = spawn('Rscript', args); let errLog = ''; proc.stdout.on('data', d => console.log(d.toString())); proc.stderr.on('data', d => { errLog += d; console.error(d.toString()); }); proc.on('close', code => { if (code !== 0) return reject(new Error(`R failed for ${sample_name}: ${errLog}`)); if (!fs.existsSync(outputFile)) return reject(new Error(`No output for ${sample_name}`)); resolve(outputFile); }); }); } // ── Helper: process a list of jobs → { successOutputs, errors } ─────────── async function processJobs(jobs, params) { const results = await Promise.allSettled( jobs.map(j => runRScript(j.inputFile, j.outputFile, { ...params, sample_name: j.sample_name })) ); const successOutputs = [], errors = []; jobs.forEach((j, i) => { if (results[i].status === 'fulfilled') { successOutputs.push({ path: j.outputFile, name: `${j.sample_name}.peaks` }); } else { errors.push({ file: j.sample_name, reason: results[i].reason?.message }); } if (fs.existsSync(j.inputFile)) fs.unlinkSync(j.inputFile); }); return { successOutputs, errors }; } // ── Helper: create ZIP on disk ────────────────────────────────────────────── function createZipOnDisk(destPath, successOutputs, errors) { return new Promise((resolve, reject) => { const output = fs.createWriteStream(destPath); const archive = archiver('zip', { zlib: { level: 6 } }); output.on('close', resolve); archive.on('error', reject); archive.pipe(output); successOutputs.forEach(({ path: p, name }) => archive.file(p, { name })); if (errors.length > 0) { archive.append(errors.map(e => `${e.file}: ${e.reason}`).join('\n'), { name: 'errors.txt' }); } archive.finalize(); }).then(() => { // Delete individual output files after zipping successOutputs.forEach(({ path: p }) => { if (fs.existsSync(p)) fs.unlinkSync(p); }); }); } // ── Helper: follow redirects and download a URL to disk ────────────────── function downloadFile(url, destPath, redirectCount = 0) { return new Promise((resolve, reject) => { if (redirectCount > 10) return reject(new Error('Too many redirects')); const proto = url.startsWith('https') ? https : http; const req = proto.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => { if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { return downloadFile(res.headers.location, destPath, redirectCount + 1).then(resolve).catch(reject); } if (res.statusCode !== 200) { return reject(new Error(`HTTP ${res.statusCode} downloading file`)); } const out = fs.createWriteStream(destPath); res.pipe(out); out.on('finish', () => out.close(resolve)); out.on('error', reject); }); req.on('error', reject); }); } // ── Helper: fetch JSON from HTTPS ───────────────────────────────────────── function fetchJSON(url) { return new Promise((resolve, reject) => { https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error('Invalid JSON from Drive API: ' + data.slice(0, 200))); } }); }).on('error', reject); }); } // ── Helper: extract Drive folder ID from URL ────────────────────────────── function extractFolderId(url) { const patterns = [/\/folders\/([a-zA-Z0-9_-]{10,})/, /[?&]id=([a-zA-Z0-9_-]{10,})/]; for (const p of patterns) { const m = url.match(p); if (m) return m[1]; } return null; } // ── Helper: list mzML/mzXML files in a public Drive folder via API ──────── async function listDriveFiles(folderId, pageToken = '', allFiles = []) { const q = encodeURIComponent(`'${folderId}' in parents and trashed = false`); const fields = encodeURIComponent('nextPageToken,files(id,name,mimeType,size)'); let url = `https://www.googleapis.com/drive/v3/files?q=${q}&fields=${fields}&pageSize=100&key=${GDRIVE_API_KEY}`; if (pageToken) url += `&pageToken=${encodeURIComponent(pageToken)}`; const data = await fetchJSON(url); if (data.error) { const msg = data.error.message || JSON.stringify(data.error); throw new Error(`Drive API error: ${msg}`); } const supported = (data.files || []).filter(f => { const n = f.name.toLowerCase(); return n.endsWith('.mzml') || n.endsWith('.mzxml'); }); allFiles.push(...supported); if (data.nextPageToken) { return listDriveFiles(folderId, data.nextPageToken, allFiles); } return allFiles; } // ── Helper: download a file from Drive API (handles large file confirmations) async function downloadDriveFile(fileId, destPath) { // Using Drive API v3 alt=media — works for public files with API key const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=${GDRIVE_API_KEY}`; await downloadFile(url, destPath); } // ═══════════════════════════════════════════════════════════════════════════ // ROUTES // ═══════════════════════════════════════════════════════════════════════════ // ── Batch upload endpoint ───────────────────────────────────────────────── app.post('/api/peak-picker/batch', upload.array('files', 100), async (req, res) => { if (!req.files?.length) return res.status(400).json({ error: 'No files uploaded.' }); const params = { ppm: req.body.ppm || '15', sn: req.body.sn || '1.5', min_pw: req.body.min_pw || '5', max_pw: req.body.max_pw || '20', pref_k: req.body.pref_k || '0', pref_i: req.body.pref_i || '0', }; const jobs = req.files.map(f => ({ inputFile: f.path, outputFile: path.join(uploadDir, `${f.filename}.peaks`), sample_name: path.parse(f.originalname).name, })); const { successOutputs, errors } = await processJobs(jobs, params); if (!successOutputs.length) return res.status(500).json({ error: 'All files failed.', details: errors }); const jobId = Date.now().toString(36) + '-' + Math.random().toString(36).substr(2, 5); let finalPath = ''; let finalName = ''; if (successOutputs.length === 1 && !errors.length) { finalPath = successOutputs[0].path; finalName = successOutputs[0].name; } else { finalPath = path.join(uploadDir, `${jobId}.zip`); finalName = 'peak_picker_results.zip'; await createZipOnDisk(finalPath, successOutputs, errors); } jobStore[jobId] = { path: finalPath, name: finalName, count: successOutputs.length, timestamp: Date.now() }; // Cleanup old jobs (older than 2 hours) to prevent disk filling up if (Math.random() < 0.1) { const TWO_HOURS = 2 * 60 * 60 * 1000; const now = Date.now(); Object.keys(jobStore).forEach(k => { const job = jobStore[k]; if (job.timestamp && (now - job.timestamp > TWO_HOURS)) { try { if (fs.existsSync(job.path)) { fs.unlinkSync(job.path); } delete jobStore[k]; console.log(`Cleaned up expired job: ${k}`); } catch (e) { console.error(`Failed to clean up job ${k}:`, e.message); } } }); } res.json({ success: true, jobId, count: successOutputs.length, total: jobs.length, errors }); }); // ── Google Drive endpoint ───────────────────────────────────────────────── app.post('/api/peak-picker/gdrive', async (req, res) => { const { folderUrl, ppm, sn, min_pw, max_pw, pref_k, pref_i } = req.body; if (!folderUrl) return res.status(400).json({ error: 'No Google Drive folder URL provided.' }); // Check API key is configured if (!GDRIVE_API_KEY) { return res.status(500).json({ error: 'Google Drive API key is not configured on the server. Please add GOOGLE_DRIVE_API_KEY as a Secret in HF Spaces Settings.', }); } const folderId = extractFolderId(folderUrl); if (!folderId) return res.status(400).json({ error: 'Could not parse folder ID from URL.' }); // 1. List files let fileList; try { fileList = await listDriveFiles(folderId); } catch (err) { return res.status(500).json({ error: `Failed to list Drive folder: ${err.message}` }); } if (!fileList.length) { return res.status(404).json({ error: 'No .mzML or .mzXML files found in this folder. Make sure the folder is shared as "Anyone with the link can view" and contains supported files.', }); } // 2. Download all files const params = { ppm: ppm||'15', sn: sn||'1.5', min_pw: min_pw||'5', max_pw: max_pw||'20', pref_k: pref_k||'0', pref_i: pref_i||'0' }; const jobs = []; const downloadErrors = []; await Promise.allSettled(fileList.map(async ({ id, name }) => { const u = Date.now() + '-' + Math.round(Math.random() * 1e9); const localPath = path.join(uploadDir, `${u}-${name}`); try { await downloadDriveFile(id, localPath); jobs.push({ inputFile: localPath, outputFile: path.join(uploadDir, `${u}-${path.parse(name).name}.peaks`), sample_name: path.parse(name).name, }); } catch (err) { downloadErrors.push({ file: name, reason: err.message }); } })); if (!jobs.length) return res.status(500).json({ error: 'Failed to download any files.', details: downloadErrors }); // 3. Process all const { successOutputs, errors } = await processJobs(jobs, params); const allErrors = [...downloadErrors, ...errors]; if (!successOutputs.length) return res.status(500).json({ error: 'All files failed to process.', details: allErrors }); const jobId = Date.now().toString(36) + '-' + Math.random().toString(36).substr(2, 5); let finalPath = ''; let finalName = ''; if (successOutputs.length === 1 && !allErrors.length) { finalPath = successOutputs[0].path; finalName = successOutputs[0].name; } else { finalPath = path.join(uploadDir, `${jobId}.zip`); finalName = 'peak_picker_results.zip'; await createZipOnDisk(finalPath, successOutputs, allErrors); } jobStore[jobId] = { path: finalPath, name: finalName, count: successOutputs.length, timestamp: Date.now() }; res.json({ success: true, jobId, count: successOutputs.length, total: jobs.length, errors: allErrors }); }); // ── Download endpoint ───────────────────────────────────────────────────── app.get('/api/peak-picker/download/:jobId', (req, res) => { const job = jobStore[req.params.jobId]; if (!job || !fs.existsSync(job.path)) { return res.status(404).send('Download link expired or invalid.'); } res.download(job.path, job.name); }); // ── Check if API key is configured (called by frontend on tab switch) ───── app.get('/api/gdrive-status', (req, res) => { res.json({ configured: Boolean(GDRIVE_API_KEY) }); }); // Error handling middleware (catches Multer and other unhandled errors) app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_UNEXPECTED_FILE') { return res.status(400).json({ error: 'Too many files uploaded. Maximum is 100 files.' }); } return res.status(400).json({ error: `Upload error: ${err.message}` }); } console.error('Unhandled error:', err); res.status(500).json({ error: err.message || 'Internal server error.' }); }); // Catch-all → React app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); }); app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));