Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/frontend-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ on:
- "package.json"
- "tests/*.test.js"
- "Launch PDF Magic.command"
- "serve.py"
- ".github/workflows/frontend-ui.yml"
pull_request:
paths:
- "web/**"
- "package.json"
- "tests/*.test.js"
- "Launch PDF Magic.command"
- "serve.py"
- ".github/workflows/frontend-ui.yml"

jobs:
Expand All @@ -27,3 +29,5 @@ jobs:
node-version: "22"
- name: Run frontend unit tests and validate the Mac launcher
run: npm test
- name: Test local file upload server
run: python3 -m unittest discover -s tests -p 'test_local_server.py' -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
__pycache__/
.google-books-browser/
*-pages/
.pdfmagic-uploads/
9 changes: 5 additions & 4 deletions Launch PDF Magic.command
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ url="http://127.0.0.1:4173/"

is_toolbench_running() {
local page
page="$(curl --silent --fail --max-time 2 "$url" 2>/dev/null)" || return 1
[[ "$page" == *"<title>PDF Magic Toolbench</title>"* ]]
page="$(curl --silent --fail --max-time 2 "${url}api/status" 2>/dev/null)" || return 1
[[ "$page" == *'"upload": true'* ]]
}

if is_toolbench_running; then
Expand All @@ -18,12 +18,13 @@ if is_toolbench_running; then
fi

if lsof -nP -iTCP:4173 -sTCP:LISTEN >/dev/null 2>&1; then
echo "Port 4173 is already in use by another service; not opening an unrelated website."
echo "Port 4173 is occupied by an older static server or another application."
echo "Stop it with Control-C, then start PDF Magic again to enable file imports."
exit 1
fi

echo "Starting PDF Magic on $url"
python3 -m http.server 4173 --bind 127.0.0.1 --directory web &
python3 serve.py &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT

Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,29 @@ The dependency-free [web interface](web/index.html) keeps Google Books export,
OCR, local AI review, and compression in separate reusable modules. Each panel
validates its own inputs and builds the corresponding local CLI command.

**Choose input files by clicking the drop zone or dragging them into it.**
Supported inputs include PDF scans, JSON tagging reports, and JSONL AI reviews.
The localhost Python web server streams an unmodified copy into
`.pdfmagic-uploads/` and substitutes the staged path into the built command.
No manual file path is needed for inputs. You can delete this ignored staging
directory when the imports are no longer needed.

The upload endpoint is bound to `127.0.0.1` and rejects cross-origin requests.
The selected documents stay on your Mac. A static deployment (for example,
Netlify) can display the toolbench but cannot provide this local upload
endpoint. The interface still builds commands for Terminal; it does not
execute PDF processing or Google Books captures itself.

```sh
npm run serve
```

Open `http://localhost:4173` in a browser. On macOS, **double-click
[`Launch PDF Magic.command`](Launch%20PDF%20Magic.command)** in Finder to start
the local server and automatically open the site in your default browser.
Alternatively, run `npm run open` from the repository root. If PDF Magic
is already serving port 4173, the launcher opens the existing instance
rather than starting another. The launcher binds its new server to localhost;
Alternatively, run `npm run open` from the repository root. If the upload-capable server is already listening on port 4173, the
launcher opens that instance instead of starting another. If an older static
server occupies the port, stop it with Control-C before relaunching. The launcher binds its new server to localhost;
leave its Terminal window open to keep the server running.

The interface follows the system appearance by default. Use the **Dark mode /
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"test": "node --test tests/*.test.js",
"serve": "python3 -m http.server 4173 --directory web",
"serve": "python3 serve.py",
"open": "bash './Launch PDF Magic.command'"
}
}
110 changes: 110 additions & 0 deletions serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Local-only PDF Magic web toolbench and streaming file-import endpoint."""

import json
import re
import uuid
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

ROOT = Path(__file__).resolve().parent
WEB_DIR = ROOT / "web"
UPLOAD_DIR = ROOT / ".pdfmagic-uploads"
HOST = "127.0.0.1"
PORT = 4173
CHUNK_SIZE = 1024 * 1024
ALLOWED_EXTENSIONS = {".pdf", ".json", ".jsonl"}


def safe_filename(raw):
"""Avoid traversal and preserve a legible name after a random prefix."""
basename = raw.replace("\\", "/").rsplit("/", 1)[-1]
ext = Path(basename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("Only PDF, JSON, and JSONL files can be imported.")
stem = re.sub(r"[^\w .()\-]", "_", basename[: -len(ext)]).strip(" .")[:100]
return (stem or "document") + ext


class ToolbenchHandler(SimpleHTTPRequestHandler):
upload_dir = UPLOAD_DIR

def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(WEB_DIR), **kwargs)

def respond_json(self, status, value):
body = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(body)

def trusted_request(self):
host = self.headers.get("Host", "")
port = self.server.server_address[1]
return host in {f"localhost:{port}", f"127.0.0.1:{port}"}

def do_GET(self):
if urlsplit(self.path).path == "/api/status":
if not self.trusted_request():
return self.respond_json(403, {"error": "Untrusted host."})
return self.respond_json(200, {"upload": True})
return super().do_GET()

def do_POST(self):
if urlsplit(self.path).path != "/api/upload":
return self.respond_json(404, {"error": "Not found."})
port = self.server.server_address[1]
origin = self.headers.get("Origin", "")
if (not self.trusted_request() or origin not in {
f"http://localhost:{port}", f"http://127.0.0.1:{port}"
}):
return self.respond_json(403, {"error": "Local, same-origin uploads only."})
if self.headers.get("Content-Type", "").split(";")[0] != "application/octet-stream":
return self.respond_json(415, {"error": "Expected a raw file upload."})
try:
size = int(self.headers.get("Content-Length", ""))
except ValueError:
return self.respond_json(411, {"error": "Content-Length is required."})
if size <= 0:
return self.respond_json(400, {"error": "Cannot upload an empty file."})
filename = parse_qs(urlsplit(self.path).query).get("name", [""])[0]
try:
safe_name = safe_filename(filename)
except ValueError as error:
return self.respond_json(400, {"error": str(error)})

self.upload_dir.mkdir(parents=True, exist_ok=True)
destination = self.upload_dir / f"{uuid.uuid4().hex}-{safe_name}"
remaining = size
try:
with destination.open("xb") as output:
while remaining:
data = self.rfile.read(min(CHUNK_SIZE, remaining))
if not data:
raise ConnectionError("Upload interrupted.")
output.write(data)
remaining -= len(data)
except (OSError, ConnectionError):
destination.unlink(missing_ok=True)
return self.respond_json(400, {"error": "Upload failed or was interrupted."})
return self.respond_json(201, {"path": str(destination.resolve()), "name": safe_name})


def main():
server = ThreadingHTTPServer((HOST, PORT), ToolbenchHandler)
print(f"PDF Magic running at http://{HOST}:{PORT}/", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()


if __name__ == "__main__":
main()
36 changes: 36 additions & 0 deletions tests/file-inputs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ocr } from "../web/js/tools/ocr.js";
import { aiReview } from "../web/js/tools/ai-review.js";
import { compression } from "../web/js/tools/compression.js";

test("source file fields are genuine file-picker targets", () => {
for (const [tool, name, accept] of [
[ocr, "input", ".pdf"],
[aiReview, "pdf", ".pdf"],
[aiReview, "report", ".json"],
[compression, "input", ".pdf"],
[compression, "review", ".jsonl"],
]) {
assert.deepEqual(
{ type: tool.fields.find(f => f.name === name)?.type,
accept: tool.fields.find(f => f.name === name)?.accept },
{ type: "file", accept },
);
}
});

test("command builders accept staged paths with spaces and keep output editable", () => {
assert.equal(
ocr.build({ input: "/tmp/PDF Magic/scan.pdf", output: "enhanced.pdf" }),
"./ocr-scanned-pdf.sh '/tmp/PDF Magic/scan.pdf' enhanced.pdf",
);
assert.equal(
aiReview.build({ pdf: "/tmp/book.pdf", report: "/tmp/blocks.json", output: "review.jsonl" }),
"python3 ollama-review-ocr.py /tmp/book.pdf /tmp/blocks.json review.jsonl",
);
assert.equal(
compression.build({ input: "/tmp/book.pdf", output: "compressed.pdf", review: "/tmp/notes.jsonl", mode: "tagged" }),
"./compress-and-tag-ocr-pdf.sh /tmp/book.pdf compressed.pdf /tmp/notes.jsonl",
);
});
103 changes: 103 additions & 0 deletions tests/test_local_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Test local PDF Magic uploads, origin checks, and directory isolation."""

import http.client
import json
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path

from serve import ToolbenchHandler, safe_filename


class ServerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
folder = Path(self.temp.name)

class TestHandler(ToolbenchHandler):
upload_dir = folder

def log_message(self, *_args):
pass

self.server = ThreadingHTTPServer(("127.0.0.1", 0), TestHandler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()

def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
self.temp.cleanup()

def request(self, method, path, data=None, origin=None, host=None):
client = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
headers = {"Host": host or f"127.0.0.1:{self.port}"}
if data is not None:
headers["Content-Type"] = "application/octet-stream"
if origin is not None:
headers["Origin"] = origin
client.request(method, path, body=data, headers=headers)
response = client.getresponse()
result = (response.status, json.loads(response.read()))
client.close()
return result

def test_status(self):
self.assertEqual(self.request("GET", "/api/status"), (200, {"upload": True}))

def test_upload_stages_bytes_without_changing_name_or_source(self):
payload = b"%PDF-1.7\n" + b"X" * 65550
status, body = self.request(
"POST", "/api/upload?name=My%20Book.pdf",
data=payload, origin=f"http://127.0.0.1:{self.port}",
)
self.assertEqual(status, 201)
self.assertEqual(body["name"], "My Book.pdf")
path = Path(body["path"])
self.assertEqual(path.parent, Path(self.temp.name))
self.assertEqual(path.read_bytes(), payload)

def test_uploads_never_overwrite_one_another(self):
kwargs = {"data": b"{}", "origin": f"http://127.0.0.1:{self.port}"}
first = self.request("POST", "/api/upload?name=a.json", **kwargs)
second = self.request("POST", "/api/upload?name=a.json", **kwargs)
self.assertNotEqual(first[1]["path"], second[1]["path"])

def test_cross_site_request_cannot_stage_files(self):
status, _ = self.request(
"POST", "/api/upload?name=a.pdf",
data=b"sample", origin="https://evil.example",
)
self.assertEqual(status, 403)
self.assertEqual(list(Path(self.temp.name).iterdir()), [])

def test_missing_origin_is_rejected(self):
status, _ = self.request("POST", "/api/upload?name=a.pdf", data=b"sample")
self.assertEqual(status, 403)

def test_dns_rebinding_host_is_rejected(self):
status, _ = self.request(
"POST", "/api/upload?name=a.pdf", data=b"sample",
origin=f"http://127.0.0.1:{self.port}", host="evil.example",
)
self.assertEqual(status, 403)

def test_bad_extensions_rejected(self):
status, _ = self.request(
"POST", "/api/upload?name=malicious.sh", data=b"sample",
origin=f"http://127.0.0.1:{self.port}",
)
self.assertEqual(status, 400)

def test_filename_cannot_escape_staging_directory(self):
self.assertEqual(safe_filename("../../unsafe.pdf"), "unsafe.pdf")
self.assertEqual(safe_filename(r"C:\\tmp\\scan.jsonl"), "scan.jsonl")
self.assertRaises(ValueError, safe_filename, "../script.py")


if __name__ == "__main__":
unittest.main()
Loading
Loading