Nearly there

This commit is contained in:
Fred Boniface 2024-05-01 13:39:29 +01:00
parent 83792b80cd
commit ce28efde7c
19 changed files with 440 additions and 85 deletions

4
.gitignore vendored
View File

@ -7,6 +7,10 @@ yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Dev build
build/
uploads/
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

107
index.ts Normal file
View File

@ -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}`)
})

119
jobs.ts Normal file
View File

@ -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<void> {
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")
}

78
package-lock.json generated
View File

@ -9,12 +9,16 @@
"version": "0.0.1", "version": "0.0.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@types/ejs": "^3.1.5",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2" "express": "^4.19.2",
"express-fileupload": "^1.5.0",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-fileupload": "^1.5.0",
"@types/uuid": "^9.0.8",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
}, },
@ -28,6 +32,15 @@
"@types/node": "*" "@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": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -40,7 +53,8 @@
"node_modules/@types/ejs": { "node_modules/@types/ejs": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "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": { "node_modules/@types/express": {
"version": "4.17.21", "version": "4.17.21",
@ -54,6 +68,16 @@
"@types/serve-static": "*" "@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": { "node_modules/@types/express-serve-static-core": {
"version": "4.19.0", "version": "4.19.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz",
@ -120,6 +144,12 @@
"@types/send": "*" "@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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -193,6 +223,17 @@
"concat-map": "0.0.1" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -428,6 +469,17 @@
"node": ">= 0.10.0" "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": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -900,6 +952,14 @@
"node": ">= 0.8" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -966,6 +1026,18 @@
"node": ">= 0.4.0" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -5,7 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "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": { "repository": {
"type": "git", "type": "git",
@ -18,11 +19,15 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.19.2" "express": "^4.19.2",
"express-fileupload": "^1.5.0",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-fileupload": "^1.5.0",
"@types/uuid": "^9.0.8",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

20
sh/monochrome.sh Executable file
View File

@ -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"

18
sh/print.sh Executable file
View File

@ -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"

19
sh/screen.sh Executable file
View File

@ -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"

View File

@ -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}`)
})

View File

@ -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<void> {
}

View File

@ -1 +0,0 @@
// Every 5 seconds check job status and update DOM.

View File

@ -1,3 +0,0 @@
html {
width: 100vw;
}

View File

@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Processing</title>
<script>const job_id = "<%= job_id %>"</script>
</head>
<body>
<h1>Please wait</h1>
</body>
</html>

33
static/checkjob.js Normal file
View File

@ -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();

8
static/style.css Normal file
View File

@ -0,0 +1,8 @@
html {
width: 100vw;
}
body {
text-align: center;
margin: auto;
}

View File

@ -55,7 +55,7 @@
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "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. */ // "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. */ // "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */ // "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. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */

View File

@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>SpeedyF - PDF Compressor</title> <title>SpeedyF - PDF Compressor</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
@ -12,10 +15,10 @@
<form action="/new_job" method="post" enctype="multipart/form-data"> <form action="/new_job" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="file" accept=".pdf"><br><br> <input type="file" name="file" id="file" accept=".pdf"><br><br>
<label for="category">Select Category:</label> <label for="quality">Select Quality:</label>
<select name="quality" id="quality"> <select name="quality" id="quality">
<option value="print">Print</option> <option value="print">Print</option>
<option value="screen">Screen</option> <option value="screen" selected>Screen</option>
<option value="monochrome">Monochrome</option> <option value="monochrome">Monochrome</option>
</select><br><br> </select><br><br>
<input type="submit" value="Upload File" name="submit"> <input type="submit" value="Upload File" name="submit">

19
views/processing.ejs Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Processing</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css">
<script>const job_id = "<%= job_id %>"</script>
<script src="/checkjob.js"></script>
</head>
<body>
<h1>Please wait</h1>
<p>Job ID: <%= job_id %></p>
<br>
<p>Job State: <span id="job_state"></span></p>
<p>Job Error: <span id="job_error">None</span></p>
<p><a href="/download?job_id=<%= job_id %>">Download</a></p>
</body>
</html>

View File

@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Error - <%= code %></title> <title>Error - <%= code %></title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<h1>Error <%= code %></h1> <h1>Error <%= code %></h1>
@ -10,5 +13,7 @@
<% } else if (code === 415) { %> <% } else if (code === 415) { %>
<p>You must only upload PDF files</p> <p>You must only upload PDF files</p>
<% } %> <% } %>
<a href="/">Go Home</a>
</body> </body>
</html> </html>