diff --git a/README.md b/README.md new file mode 100644 index 0000000..484f09a --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# File Converter + +## Local development + +1. Copy `.env.example` to `.env` and fill in your local MariaDB credentials. +2. Apply the schema: `mysql -h -u -p < db/schema.sql` +3. Install dependencies: `npm install` +4. Run the API: `npm start` +5. Run the worker (separate terminal): `npm run worker` +6. Run tests: `npm test` (requires the same MariaDB reachable via your `.env` vars, exported into the shell) + +## Deployment on o2switch + +1. Upload the project (excluding `node_modules/`) via SSH/Git. +2. Create/adjust `.env` on the server with production values (`STORAGE_DIR` pointing to a writable path under your account, MariaDB credentials from cPanel). +3. Apply `db/schema.sql` to the MariaDB database created in cPanel. +4. `npm install` (never with `--ignore-scripts` — Puppeteer needs its postinstall step to download Chromium). +5. Configure the app in cPanel "Setup Node.js App", pointing its entry point at `src/server.js`. Passenger manages this process (start/stop/restart). +6. Start the worker independently of Passenger, over SSH: `pm2 start ecosystem.config.cjs`, then `pm2 save`. Try `pm2 startup` to survive a server reboot; if that's not permitted without root on this account, fall back to a cPanel cron job every 5 minutes that runs `pm2 resurrect` (or checks `pm2 list` and restarts the app if absent) — validate which option this hosting plan actually allows once connected over SSH. +7. Add a cPanel cron job to run the cleanup script periodically, e.g. every 15 minutes: + `*/15 * * * * cd /home// && /usr/bin/node src/cleanup.js >> logs/cleanup.log 2>&1` + (adjust the path and node binary location to match your actual account — check with `which node` over SSH). +8. On every subsequent deployment: pull changes, `npm install`, `npm run build` (frontend), then restart the Passenger app from cPanel and `pm2 restart convert-worker`. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..e53a6f8 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,12 @@ +module.exports = { + apps: [ + { + name: 'convert-worker', + script: 'src/worker.js', + interpreter: 'node', + env: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/src/cleanup.js b/src/cleanup.js new file mode 100644 index 0000000..84e8efc --- /dev/null +++ b/src/cleanup.js @@ -0,0 +1,28 @@ +import { loadConfig } from './config.js'; +import { getPool, closePool } from './db.js'; +import { uploadPath, outputPath, deleteIfExists } from './storage.js'; +import { findExpiredJobs, deleteJob } from './jobs/jobRepository.js'; + +export async function runCleanup(pool, config) { + const expiredJobs = await findExpiredJobs(pool); + + for (const job of expiredJobs) { + await deleteIfExists(uploadPath(config, job.id, job.sourceFormat)); + await deleteIfExists(outputPath(config, job.id, job.targetFormat)); + await deleteJob(pool, job.id); + } + + return expiredJobs.length; +} + +async function main() { + const config = loadConfig(); + const pool = getPool(config); + const deletedCount = await runCleanup(pool, config); + console.log(`Cleanup: removed ${deletedCount} expired job(s).`); + await closePool(); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/test/cleanup.test.js b/test/cleanup.test.js new file mode 100644 index 0000000..b042750 --- /dev/null +++ b/test/cleanup.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { getPool, closePool } from '../src/db.js'; +import { loadConfig } from '../src/config.js'; +import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js'; +import { createJob, markDone, getJobById } from '../src/jobs/jobRepository.js'; +import { runCleanup } from '../src/cleanup.js'; + +let pool; +let config; + +beforeAll(async () => { + config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-cleanup-')) }; + await ensureStorageDirs(config); + pool = getPool(config); +}); + +afterAll(async () => { + await closePool(); + await fs.rm(config.storageDir, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await pool.query('DELETE FROM conversion_jobs'); +}); + +describe('runCleanup', () => { + it('deletes an expired done job, its input file, and its output file', async () => { + const id = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + await fs.writeFile(uploadPath(config, id, 'png'), 'input bytes'); + await fs.writeFile(outputPath(config, id, 'webp'), 'output bytes'); + await createJob(pool, { + id, + family: 'image', + sourceFormat: 'png', + targetFormat: 'webp', + originalFilename: 'photo.png', + inputPath: `${id}.png`, + inputMimeType: 'image/png', + expiresAt: new Date(Date.now() - 1000), + }); + await markDone(pool, id, { outputPath: `${id}.webp`, outputMimeType: 'image/webp' }); + + const deletedCount = await runCleanup(pool, config); + + expect(deletedCount).toBe(1); + expect(await getJobById(pool, id)).toBeNull(); + await expect(fs.stat(uploadPath(config, id, 'png'))).rejects.toThrow(); + await expect(fs.stat(outputPath(config, id, 'webp'))).rejects.toThrow(); + }); + + it('deletes an expired pending job (with no output file) without throwing', async () => { + const id = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + await fs.writeFile(uploadPath(config, id, 'png'), 'input bytes'); + await createJob(pool, { + id, + family: 'image', + sourceFormat: 'png', + targetFormat: 'webp', + originalFilename: 'photo.png', + inputPath: `${id}.png`, + inputMimeType: 'image/png', + expiresAt: new Date(Date.now() - 1000), + }); + + const deletedCount = await runCleanup(pool, config); + + expect(deletedCount).toBe(1); + expect(await getJobById(pool, id)).toBeNull(); + }); + + it('leaves non-expired jobs untouched', async () => { + const id = '12121212-1212-4212-8212-121212121212'; + await createJob(pool, { + id, + family: 'image', + sourceFormat: 'png', + targetFormat: 'webp', + originalFilename: 'photo.png', + inputPath: `${id}.png`, + inputMimeType: 'image/png', + expiresAt: new Date(Date.now() + 3600 * 1000), + }); + + const deletedCount = await runCleanup(pool, config); + + expect(deletedCount).toBe(0); + expect(await getJobById(pool, id)).not.toBeNull(); + }); +});