Spaces:
Sleeping
Sleeping
| 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}`)); | |