From ce28efde7c770db6442ca666a8023d3e74d92878 Mon Sep 17 00:00:00 2001 From: Fred Boniface Date: Wed, 1 May 2024 13:39:29 +0100 Subject: [PATCH] Nearly there --- .gitignore | 4 + index.ts | 107 +++++++++++++++++++++++++ jobs.ts | 119 ++++++++++++++++++++++++++++ package-lock.json | 78 +++++++++++++++++- package.json | 9 ++- sh/monochrome.sh | 20 +++++ sh/print.sh | 18 +++++ sh/screen.sh | 19 +++++ src/index.ts | 21 ----- src/jobs.ts | 42 ---------- src/static/checkjob.js | 1 - src/static/style.css | 3 - src/views/processing.ejs | 10 --- static/checkjob.js | 33 ++++++++ static/style.css | 8 ++ tsconfig.json | 2 +- {src/views => views}/index.ejs | 7 +- views/processing.ejs | 19 +++++ {src/views => views}/upload_err.ejs | 5 ++ 19 files changed, 440 insertions(+), 85 deletions(-) create mode 100644 index.ts create mode 100644 jobs.ts create mode 100755 sh/monochrome.sh create mode 100755 sh/print.sh create mode 100755 sh/screen.sh delete mode 100644 src/index.ts delete mode 100644 src/jobs.ts delete mode 100644 src/static/checkjob.js delete mode 100644 src/static/style.css delete mode 100644 src/views/processing.ejs create mode 100644 static/checkjob.js create mode 100644 static/style.css rename {src/views => views}/index.ejs (69%) create mode 100644 views/processing.ejs rename {src/views => views}/upload_err.ejs (64%) diff --git a/.gitignore b/.gitignore index 6a7d6d8..740e7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* +# Dev build +build/ +uploads/ + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..adb62b3 --- /dev/null +++ b/index.ts @@ -0,0 +1,107 @@ +import express, { Request, Response, NextFunction } from "express"; +import fileUpload, { UploadedFile } from "express-fileupload"; +import path from "path"; +import fs from "fs"; +import { JobManager, runGhostscript, createJobId } from "./jobs"; +import type { context, options } from "./jobs"; + + +const app = express() +const jobManager = new JobManager() + +app.set('view engine', 'ejs'); +app.use(express.static(path.join(__dirname, 'static'))); +app.use(fileUpload()) + +app.get('/', (req: Request, res: Response) => { + res.render('index') +}) + +app.post('/new_job', (req: Request, res: Response) => { + try { + const jobId = createJobId(); + const file = req.files?.file as UploadedFile; + // Ensure file was uploaded and is of correct type + if (!file) { + return res.status(400).render("upload_err", {code: 400}) + } + if (file.mimetype !== 'application/pdf') { + return res.status(415).render("upload_err", {code: 415}) + } + + // Write file to disk + const filePath = `./uploads/${jobId}/in.pdf`; + fs.mkdirSync(`./uploads/${jobId}`, { recursive: true }); + file.mv(filePath, (err) => { + if (err) { + throw err; + } + + const quality: string = req.body.quality; + const opts: options = { quality }; + const ctx: context = { jobManager, jobId, filePath }; + runGhostscript(ctx, opts); + res.render('processing', { job_id: jobId }); + }); +} catch (error) { + if (error) + console.error(error); + res.status(500).send('Internal Server Error'); +} +}); + +app.get('/job_state', (req: Request, res: Response) => { + const job_id: string = req.query.job_id as string; + if (!job_id) { + res.status(400).send("Missing job ID") + } + const jobStatus = jobManager.get_job_status(job_id); + + // If job doesn't exist, return 404 + if (Object.keys(jobStatus).length === 0) { + res.status(404).send('Job not found'); + return; + } + + // Send job status as JSON response + res.json(jobStatus); +}) + +app.get('/download', (req: Request, res: Response) => { + try{ + const jobId = req.query.job_id as string; + if (!jobId) { + res.status(400).send("Missing job ID") + } + const jobStatus = jobManager.get_job_status(jobId); + if (Object.keys(jobStatus).length === 0) { + res.status(404).send('Job not found, files expire after 10 minutes'); + return; + } + + const filePath = path.join('.', 'uploads', `${jobId}`, `out.pdf`); + console.log("Download Request: ", filePath) + if (fs.existsSync(filePath)) { + // Set the appropriate headers for the PDF file + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename=SpeedyF-output.pdf`); + + // Create a read stream to the PDF file and pipe it to the response + const stream = fs.createReadStream(filePath); + stream.pipe(res); + } else { + // If the file doesn't exist, return a 404 Not Found response + res.status(404).send('File not found'); + } +} catch (error) { + // If an error occurs, return a 500 Internal Server Error response + console.error('An error occurred:', error); + res.status(500).send('Internal Server Error'); +} + +}); + +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`Server listening on port ${port}`) +}) diff --git a/jobs.ts b/jobs.ts new file mode 100644 index 0000000..1038259 --- /dev/null +++ b/jobs.ts @@ -0,0 +1,119 @@ +import { v4 as uuidv4 } from 'uuid'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; + +//TESTING +const delay = async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)); + +export interface Job { + state: string; + fetch_url: string | null; + error: string | null; +} + +export interface context { + jobManager: JobManager; + jobId: string; + filePath: string; +} + +export interface options { + quality: string; +} + +export interface command { + cmd: string; + opts: string[]; +} + +export class JobManager { + private jobs: { [job_id: string]: Job } = {}; + + public create_job(job_id: string): void { + this.jobs[job_id] = { state: 'pending', fetch_url: null, error: null }; + console.log("Created job id:", job_id) + } + + public set_job_state(job_id: string, state: string): void { + this.jobs[job_id].state = state; + console.log("Job state change:", job_id, " - ", state) + } + + public set_fetch_url(job_id: string, url: string): void { + this.jobs[job_id].fetch_url = url; + console.log("Job fetch url:", job_id, " - ", url) + } + + public set_error(job_id: string, error: string): void { + this.jobs[job_id].error = error; + console.error("Job error:", job_id, " - ", error) + } + + public get_job_status(job_id: string): Job { + return this.jobs[job_id] || {}; + } + + public remove_job(job_id: string): void { + if (this.jobs[job_id]) { + delete this.jobs[job_id]; + console.log("Removed job id:", job_id); + } else { + console.log("Job id not found:", job_id); + } + } +} + +const presets: { [preset: string]: string } = { + screen: './sh/screen.sh', + print: './print_ghostscript.sh', + monochrome: './monochrome_ghostscript.sh' +}; + +export function createJobId(): string { + return uuidv4() +} + +export async function runGhostscript(ctx: context, opts: options): Promise { + ctx.jobManager.create_job(ctx.jobId) + ctx.jobManager.set_job_state(ctx.jobId, "pending") + + await delay(1000) + const inputFilePath = `./uploads/${ctx.jobId}/in.pdf` + const outputFilePath = `./uploads/${ctx.jobId}/out.pdf` + ctx.jobManager.set_job_state(ctx.jobId, "running") + const gsProcess = spawn(presets[opts.quality], [ctx.jobId], {shell: true}) + gsProcess.stdout.on('data', (data) => { + console.log(`stdout: ${data}`); + }); + + gsProcess.stderr.on('data', (data) => { + console.error(`stderr: ${data}`); + }); + gsProcess.on("exit", (code) => { + if (code === 0) { + fs.unlink(inputFilePath, (err) => { + if (err) { + console.error(`Error deleting input PDF file: ${err}`); + } else { + console.log(`Input PDF file deleted: ${inputFilePath}`); + } + }); + ctx.jobManager.set_job_state(ctx.jobId, "done") + ctx.jobManager.set_fetch_url(ctx.jobId, `/download?job_id=${ctx.jobId}`) + const timeout = 60 * 10 * 1000; // 10 minutes (ms) + setTimeout(() => { + fs.unlink(outputFilePath, (err) => { + if (err) { + console.error(`Error deleting static PDF file: ${err}`); + } else { + console.log(`Static PDF file deleted: ${outputFilePath}`); + } + }); + }, timeout); + } else { + ctx.jobManager.set_job_state(ctx.jobId, "error") + ctx.jobManager.set_error(ctx.jobId, "Ghostscript exited with error: " + code) + } + }) + console.log("Job Complete") +} diff --git a/package-lock.json b/package-lock.json index 0c45287..2727eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,16 @@ "version": "0.0.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@types/ejs": "^3.1.5", "ejs": "^3.1.10", - "express": "^4.19.2" + "express": "^4.19.2", + "express-fileupload": "^1.5.0", + "uuid": "^9.0.1" }, "devDependencies": { + "@types/ejs": "^3.1.5", "@types/express": "^4.17.21", + "@types/express-fileupload": "^1.5.0", + "@types/uuid": "^9.0.8", "typescript": "^5.4.5" } }, @@ -28,6 +32,15 @@ "@types/node": "*" } }, + "node_modules/@types/busboy": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.3.tgz", + "integrity": "sha512-YMBLFN/xBD8bnqywIlGyYqsNFXu6bsiY7h3Ae0kO17qEuTjsqeyYMRPSUDacIKIquws2Y6KjmxAyNx8xB3xQbw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -40,7 +53,8 @@ "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", - "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==" + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.21", @@ -54,6 +68,16 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-fileupload": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/express-fileupload/-/express-fileupload-1.5.0.tgz", + "integrity": "sha512-Y9v88IC5ItAxkKwfnyIi1y0jSZwTMY4jqXUQLZ3jFhYJlLdRnN919bKBNM8jbVVD2cxywA/uEC1kNNpZQGwx7Q==", + "dev": true, + "dependencies": { + "@types/busboy": "*", + "@types/express": "*" + } + }, "node_modules/@types/express-serve-static-core": { "version": "4.19.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", @@ -120,6 +144,12 @@ "@types/send": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -193,6 +223,17 @@ "concat-map": "0.0.1" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -428,6 +469,17 @@ "node": ">= 0.10.0" } }, + "node_modules/express-fileupload": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.5.0.tgz", + "integrity": "sha512-jSW3w9evqM37VWkEPkL2Ck5wUo2a8qa03MH+Ou/0ZSTpNlQFBvSLjU12k2nYcHhaMPv4JVvv6+Ac1OuLgUZb7w==", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -900,6 +952,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -966,6 +1026,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 4afa4a5..ac8efe0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "run": "echo \"Run is not defined\" && exit 1" + "dev": "npx tsc && cp -r static/ build && node build/index.js", + "build": "npx tsc && cp -r static/ views/ build" }, "repository": { "type": "git", @@ -18,11 +19,15 @@ "license": "AGPL-3.0-or-later", "dependencies": { "ejs": "^3.1.10", - "express": "^4.19.2" + "express": "^4.19.2", + "express-fileupload": "^1.5.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/ejs": "^3.1.5", "@types/express": "^4.17.21", + "@types/express-fileupload": "^1.5.0", + "@types/uuid": "^9.0.8", "typescript": "^5.4.5" } } diff --git a/sh/monochrome.sh b/sh/monochrome.sh new file mode 100755 index 0000000..b43dfce --- /dev/null +++ b/sh/monochrome.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Set input and output file paths +INPUT_PDF="./uploads/${1}/in.pdf" +OUTPUT_PDF="./uploads/${1}/out.pdf" + +# Ghostscript command +gs -sDEVICE=pdfwrite \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -dCompatibilityLevel=1.4 \ + -dPDFSETTINGS=/ebook \ + -dConvertCMYKImagesToRGB=true \ + -dColorConversionStrategy=/DeviceGray \ # Adjust color conversion strategy + -dEmbedAllFonts=true \ + -dSubsetFonts=true \ + -dFastWebView=true \ + -sOutputFile="$OUTPUT_PDF" \ + "$INPUT_PDF" diff --git a/sh/print.sh b/sh/print.sh new file mode 100755 index 0000000..d70fbff --- /dev/null +++ b/sh/print.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# Set input and output file paths +INPUT_PDF="./uploads/${1}/in.pdf" +OUTPUT_PDF="./uploads/${1}/out.pdf" + +# Ghostscript command +gs -sDEVICE=pdfwrite \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -dCompatibilityLevel=1.4 \ + -dPDFSETTINGS=/printer \ + -dEmbedAllFonts=true \ + -dSubsetFonts=true \ + -dFastWebView=true \ + -sOutputFile="$OUTPUT_PDF" \ + "$INPUT_PDF" diff --git a/sh/screen.sh b/sh/screen.sh new file mode 100755 index 0000000..280d899 --- /dev/null +++ b/sh/screen.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Set input and output file paths +INPUT_PDF="./uploads/${1}/in.pdf" +OUTPUT_PDF="./uploads/${1}/out.pdf" + +# Ghostscript command +gs -sDEVICE=pdfwrite \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -dCompatibilityLevel=1.4 \ + -dPDFSETTINGS=/ebook \ + -dConvertCMYKImagesToRGB=true \ + -dEmbedAllFonts=true \ + -dSubsetFonts=true \ + -dFastWebView=true \ + -sOutputFile="$OUTPUT_PDF" \ + "$INPUT_PDF" diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index eb99308..0000000 --- a/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import express, { Request, Response, NextFunction } from "express"; -import path from "path"; -import { JobManager, runGhostscript } from "./jobs"; - -const app = express() - -app.set('view engine', 'ejs'); -app.use(express.static(path.join(__dirname, 'static'))); - -app.get('/', (req: Request, res: Response) => { - res.render('index') -}) - -app.post('/new_job', (req: Request, res: Response) => { - res.render('processing', {job_id: "no-job"}) -}) - -const port = process.env.PORT || 3000; -app.listen(port, () => { - console.log(`Server listening on port ${port}`) -}) \ No newline at end of file diff --git a/src/jobs.ts b/src/jobs.ts deleted file mode 100644 index b2fc59a..0000000 --- a/src/jobs.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface Job { - state: string; - fetch_url: string | null; - error: string | null; -} - -export interface context { - jobManager: JobManager; - jobId: string; -} - -export interface options { - quality: string; -} - -export class JobManager { - private jobs: { [job_id: string]: Job } = {}; - - public create_job(job_id: string): void { - this.jobs[job_id] = { state: 'pending', fetch_url: null, error: null }; - } - - public set_job_state(job_id: string, state: string): void { - this.jobs[job_id].state = state; - } - - public set_fetch_url(job_id: string, url: string): void { - this.jobs[job_id].fetch_url = url; - } - - public set_error(job_id: string, error: string): void { - this.jobs[job_id].error = error; - } - - public get_job_status(job_id: string): Job { - return this.jobs[job_id] || {}; - } -} - -export async function runGhostscript(ctx: context, file_path: string, opts: options): Promise { - -} \ No newline at end of file diff --git a/src/static/checkjob.js b/src/static/checkjob.js deleted file mode 100644 index 1d0af5b..0000000 --- a/src/static/checkjob.js +++ /dev/null @@ -1 +0,0 @@ -// Every 5 seconds check job status and update DOM. \ No newline at end of file diff --git a/src/static/style.css b/src/static/style.css deleted file mode 100644 index 9c8d296..0000000 --- a/src/static/style.css +++ /dev/null @@ -1,3 +0,0 @@ -html { - width: 100vw; -} \ No newline at end of file diff --git a/src/views/processing.ejs b/src/views/processing.ejs deleted file mode 100644 index 4d69167..0000000 --- a/src/views/processing.ejs +++ /dev/null @@ -1,10 +0,0 @@ - - - - Processing - - - -

Please wait

- - \ No newline at end of file diff --git a/static/checkjob.js b/static/checkjob.js new file mode 100644 index 0000000..ed4fa60 --- /dev/null +++ b/static/checkjob.js @@ -0,0 +1,33 @@ +console.log("jobId: ", job_id) + +const fetchJobState = async () => { + try { + const response = await fetch(`/job_state?job_id=${job_id}`); + if (!response.ok) { + throw new Error('Failed to fetch job state'); + } + const data = await response.json(); + // Update UI with job state + document.getElementById('job_state').innerText = data.state; + if (data.state == "error") { + document.getElementById('job_error').innerText = data.error; + stopPolling(); + } + if (data.state == "done") { + stopPolling(); + } + } catch (error) { + console.error(error); + } +}; + +function stopPolling() { + clearInterval(pollingIntervalId) +} + +// Poll the endpoint every 5 seconds (adjust as needed) +const pollingInterval = 2000; //ms +const pollingIntervalId = setInterval(fetchJobState, pollingInterval); + +// Initial fetch +fetchJobState(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5595acd --- /dev/null +++ b/static/style.css @@ -0,0 +1,8 @@ +html { + width: 100vw; +} + +body { + text-align: center; + margin: auto; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e075f97..75c5cda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,7 +55,7 @@ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ + "outDir": "./build", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ diff --git a/src/views/index.ejs b/views/index.ejs similarity index 69% rename from src/views/index.ejs rename to views/index.ejs index 8349d64..0909691 100644 --- a/src/views/index.ejs +++ b/views/index.ejs @@ -2,6 +2,9 @@ SpeedyF - PDF Compressor + + + @@ -12,10 +15,10 @@


- +

diff --git a/views/processing.ejs b/views/processing.ejs new file mode 100644 index 0000000..eb78a0b --- /dev/null +++ b/views/processing.ejs @@ -0,0 +1,19 @@ + + + + Processing + + + + + + + +

Please wait

+

Job ID: <%= job_id %>

+
+

Job State:

+

Job Error: None

+

Download

+ + \ No newline at end of file diff --git a/src/views/upload_err.ejs b/views/upload_err.ejs similarity index 64% rename from src/views/upload_err.ejs rename to views/upload_err.ejs index 1eff624..9eaca90 100644 --- a/src/views/upload_err.ejs +++ b/views/upload_err.ejs @@ -2,6 +2,9 @@ Error - <%= code %> + + +

Error <%= code %>

@@ -10,5 +13,7 @@ <% } else if (code === 415) { %>

You must only upload PDF files

<% } %> + + Go Home