diff options
| author | Kyle Javier [kj_sh604] | 2026-02-16 03:06:31 -0500 |
|---|---|---|
| committer | GitHub | 2026-02-16 03:06:31 -0500 |
| commit | 56f77847378fc92fa79b5303ca86ff71b5767c42 (patch) | |
| tree | 34017664c04c2fecb137685a0bdc65ba5d9884ef /src | |
| parent | af1eeceecae9667f8ea069f57d2baf508a08e8de (diff) | |
| parent | 1272877be9f9c263273fdd0b2c564ef6bd73afbd (diff) | |
[merge] feat: NimLang Backend Re-write
Diffstat (limited to 'src')
| -rw-r--r-- | src/.gitignore | 1 | ||||
| -rw-r--r-- | src/app.nim | 422 | ||||
| -rw-r--r-- | src/app.py | 166 | ||||
| -rw-r--r-- | src/requirements.txt | 1 | ||||
| -rw-r--r-- | src/templates/index.html | 15 |
5 files changed, 431 insertions, 174 deletions
diff --git a/src/.gitignore b/src/.gitignore index fb97398..7128cad 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.pyc .venv/ +likha-pdf generated/ uploads/
\ No newline at end of file diff --git a/src/app.nim b/src/app.nim new file mode 100644 index 0000000..930e592 --- /dev/null +++ b/src/app.nim @@ -0,0 +1,422 @@ +import std/[asynchttpserver, asyncdispatch, os, osproc, streams, strutils, tables, times, uri, random] + +# tiny backend in nimlang, may be stupid, but this was fun + +const + AllowedImageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg"] + ValidPaperSizes = ["a4paper", "letterpaper", "legalpaper"] + ValidMargins = ["0.75in", "1in", "1.25in", "1.5in"] + +const + AppName = "likha-pdf" + +proc baseDir(): string {.inline.} = + getAppDir() + +proc generatedDir(): string {.inline.} = + baseDir() / "generated" + +proc uploadsDir(): string {.inline.} = + baseDir() / "uploads" + +proc latexTemplatePath(): string {.inline.} = + baseDir() / "latex" / "template.tex" + +proc templatesDir(): string {.inline.} = + baseDir() / "templates" + +proc partialsDir(): string {.inline.} = + templatesDir() / "partials" + +proc staticDir(): string {.inline.} = + baseDir() / "static" + +type MultipartPart = object + name: string + filename: string + contentType: string + content: string + +# helpers +proc htmlEscape(value: string): string = + result = value + result = result.replace("&", "&") + result = result.replace("<", "<") + result = result.replace(">", ">") + result = result.replace("\"", """) + result = result.replace("'", "'") + +proc randomHex(length: int): string = + const hexChars = "0123456789abcdef" + result = newStringOfCap(length) + for _ in 0 ..< length: + result.add(hexChars[rand(15)]) + +proc renderTemplate(filePath: string; replacements: openArray[(string, string)]): string = + result = readFile(filePath) + for (token, replacement) in replacements: + result = result.replace(token, replacement) + +proc decodeFormComponent(value: string): string = + decodeUrl(value.replace("+", " ")) + +proc parseUrlEncoded(body: string): Table[string, string] = + result = initTable[string, string]() + if body.len == 0: + return + + for pair in body.split("&"): + if pair.len == 0: + continue + let separator = pair.find('=') + if separator < 0: + result[decodeFormComponent(pair)] = "" + else: + let key = decodeFormComponent(pair[0 ..< separator]) + let value = decodeFormComponent(pair[separator + 1 .. ^1]) + result[key] = value + +# "options" are optional, defaults are forever. +proc pickOption(value: string; fallback: string; options: openArray[string]): string = + for option in options: + if option == value: + return value + fallback + +proc sanitizeFilename(filename: string): string = + result = newStringOfCap(filename.len) + for ch in filename: + if (ch >= 'a' and ch <= 'z') or + (ch >= 'A' and ch <= 'Z') or + (ch >= '0' and ch <= '9') or + (ch in {'-', '_', '.'}): + result.add(ch) + elif ch == ' ': + result.add('_') + +proc baseFilename(value: string): string = + var normalized = value.replace("\\", "/") + let index = normalized.rfind('/') + if index >= 0 and index < normalized.high: + normalized = normalized[index + 1 .. ^1] + elif index == normalized.high: + normalized = "" + normalized + +proc isAllowedImage(filename: string): bool = + let dot = filename.rfind('.') + if dot < 1 or dot == filename.high: + return false + let extension = filename[dot + 1 .. ^1].toLowerAscii() + for allowed in AllowedImageExtensions: + if extension == allowed: + return true + false + +proc tailText(value: string; maxLen: int = 1200): string = + if value.len <= maxLen: + return value + value[value.len - maxLen .. ^1] + +proc extractBoundary(contentType: string): string = + for part in contentType.split(';'): + let token = part.strip() + if token.toLowerAscii().startsWith("boundary="): + return token[9 .. ^1].strip(chars = {'\"', '\''}) + "" + +proc stripTrailingCrlf(value: string): string = + result = value + if result.len >= 2 and result.endsWith("\r\n"): + result.setLen(result.len - 2) + +# hand-rolled multipart parsing, yes i am aware that this is "eh" +proc parseMultipart(body: string; boundary: string): seq[MultipartPart] = + let delimiter = "--" & boundary + for rawChunk in body.split(delimiter): + var chunk = rawChunk + if chunk.len == 0: + continue + if chunk == "--" or chunk == "--\r\n": + continue + if chunk.startsWith("\r\n"): + chunk = chunk[2 .. ^1] + + chunk = stripTrailingCrlf(chunk) + + if chunk.len == 2 and chunk == "--": + continue + + let splitIndex = chunk.find("\r\n\r\n") + if splitIndex < 0: + continue + + let headerBlock = chunk[0 ..< splitIndex] + var content = chunk[splitIndex + 4 .. ^1] + content = stripTrailingCrlf(content) + + var name = "" + var filename = "" + var contentType = "application/octet-stream" + + for line in headerBlock.split("\r\n"): + let separator = line.find(':') + if separator <= 0: + continue + let headerName = line[0 ..< separator].strip().toLowerAscii() + let headerValue = line[separator + 1 .. ^1].strip() + + if headerName == "content-disposition": + for part in headerValue.split(';'): + let token = part.strip() + if token.startsWith("name="): + name = token[5 .. ^1].strip(chars = {'\"', '\''}) + elif token.startsWith("filename="): + filename = token[9 .. ^1].strip(chars = {'\"', '\''}) + elif headerName == "content-type": + contentType = headerValue + + if name.len > 0: + result.add(MultipartPart(name: name, filename: filename, contentType: contentType, content: content)) + +proc isSafeRelativePath(pathPart: string): bool = + pathPart.len > 0 and + not pathPart.contains("..") and + not pathPart.contains('\\') and + not pathPart.startsWith("/") + +proc fileContentType(filePath: string): string = + let lowered = filePath.toLowerAscii() + if lowered.endsWith(".js"): + return "application/javascript; charset=utf-8" + if lowered.endsWith(".css"): + return "text/css; charset=utf-8" + if lowered.endsWith(".html"): + return "text/html; charset=utf-8" + if lowered.endsWith(".png"): + return "image/png" + if lowered.endsWith(".jpg") or lowered.endsWith(".jpeg"): + return "image/jpeg" + if lowered.endsWith(".gif"): + return "image/gif" + if lowered.endsWith(".webp"): + return "image/webp" + if lowered.endsWith(".svg"): + return "image/svg+xml" + if lowered.endsWith(".pdf"): + return "application/pdf" + "application/octet-stream" + +# response wrappers +proc respondHtml(req: Request; code: HttpCode; content: string) {.async.} = + let headers = newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}) + await req.respond(code, content, headers) + +proc respondText(req: Request; code: HttpCode; content: string) {.async.} = + let headers = newHttpHeaders({"Content-Type": "text/plain; charset=utf-8"}) + await req.respond(code, content, headers) + +proc respondFile(req: Request; filePath: string; asAttachment: bool = false; attachmentName: string = "") {.async.} = + if not fileExists(filePath): + await respondText(req, Http404, "Not found") + return + + var headers = newHttpHeaders() + headers["Content-Type"] = fileContentType(filePath) + if asAttachment and attachmentName.len > 0: + headers["Content-Disposition"] = "attachment; filename=\"" & attachmentName & "\"" + + await req.respond(Http200, readFile(filePath), headers) + +# pandoc does the heavy lifting +proc runPandoc(sourceMarkdown: string; outputPath: string; paperSize: string; margin: string; mainFont: string): tuple[ok: bool, output: string, missingPandoc: bool] = + let tempDir = getTempDir() / (AppName & "-" & randomHex(10)) + createDir(tempDir) + let tempMarkdownPath = tempDir / "source.md" + + try: + writeFile(tempMarkdownPath, sourceMarkdown) + + let args = @[ + tempMarkdownPath, + "--from", "markdown+emoji", + "--pdf-engine=lualatex", + "--template", latexTemplatePath(), + "-V", "papersize=" & paperSize, + "-V", "margin=" & margin, + "-V", "mainfont=" & mainFont, + "--resource-path", baseDir() & ":" & uploadsDir() & ":" & tempDir, + "-o", outputPath + ] + + var process: Process + try: + process = startProcess("pandoc", args = args, options = {poUsePath, poStdErrToStdOut}) + except OSError: + return (ok: false, output: "Pandoc is not installed or not in PATH.", missingPandoc: true) + + let output = process.outputStream.readAll() + let exitCode = process.waitForExit() + process.close() + + if exitCode == 0: + return (ok: true, output: "", missingPandoc: false) + return (ok: false, output: output, missingPandoc: false) + finally: + try: + if fileExists(tempMarkdownPath): + removeFile(tempMarkdownPath) + if dirExists(tempDir): + removeDir(tempDir) + except OSError: + discard + +# app endpoint: strict inputs, loud errors. +proc handleConvert(req: Request) {.async.} = + let formData = parseUrlEncoded(req.body) + let markdown = formData.getOrDefault("markdown", "").strip() + + if markdown.len == 0: + let html = renderTemplate(partialsDir() / "error.html", [("{{ message }}", "Markdown content is required.")]) + await respondHtml(req, Http400, html) + return + + let paperSize = pickOption(formData.getOrDefault("paper_size", ""), "a4paper", ValidPaperSizes) + let margin = pickOption(formData.getOrDefault("margin", ""), "1in", ValidMargins) + + var mainFontFamily = formData.getOrDefault("main_font", "serif") + if mainFontFamily != "serif" and mainFontFamily != "sans": + mainFontFamily = "serif" + + let mainFont = if mainFontFamily == "sans": "TeX Gyre Heros" else: "TeX Gyre Pagella" + let epoch = int(getTime().toUnix()) + let outputName = AppName & "_" & $epoch & "_" & randomHex(32) & ".pdf" + let outputPath = generatedDir() / outputName + + let conversion = runPandoc(markdown, outputPath, paperSize, margin, mainFont) + + if not conversion.ok: + let message = if conversion.missingPandoc: + conversion.output + else: + let stderr = conversion.output.strip() + if stderr.len > 0: tailText(stderr) else: "PDF conversion failed." + + let html = renderTemplate(partialsDir() / "error.html", [("{{ message }}", htmlEscape(message))]) + let code = if conversion.missingPandoc: Http500 else: Http400 + await respondHtml(req, code, html) + return + + let html = renderTemplate( + partialsDir() / "result.html", + [ + ("{{ filename }}", htmlEscape(outputName)), + ("{{ download_url }}", "/download/" & encodeUrl(outputName)) + ] + ) + await respondHtml(req, Http200, html) + +# upload endpoint. accepts image, returns markdown snippet +proc handleUploadImage(req: Request) {.async.} = + let contentType = req.headers.getOrDefault("Content-Type") + let boundary = extractBoundary(contentType) + + if boundary.len == 0: + let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "image file is required.")]) + await respondHtml(req, Http400, html) + return + + let parts = parseMultipart(req.body, boundary) + var imagePart: MultipartPart + var foundImage = false + for part in parts: + if part.name == "image": + imagePart = part + foundImage = true + break + + if not foundImage or imagePart.filename.strip().len == 0: + let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "image file is required.")]) + await respondHtml(req, Http400, html) + return + + let originalName = sanitizeFilename(baseFilename(imagePart.filename)) + if originalName.len == 0 or not isAllowedImage(originalName): + let html = renderTemplate(partialsDir() / "upload_error.html", [("{{ message }}", "unsupported image type.")]) + await respondHtml(req, Http400, html) + return + + let extensionStart = originalName.rfind('.') + let extension = originalName[extensionStart + 1 .. ^1].toLowerAscii() + + let epoch = int(getTime().toUnix()) + let storedName = "img_" & $epoch & "_" & randomHex(32) & "." & extension + let imagePath = uploadsDir() / storedName + + writeFile(imagePath, imagePart.content) + + let markdownSnippet = "" + let html = renderTemplate( + partialsDir() / "upload_result.html", + [ + ("{{ filename }}", htmlEscape(storedName)), + ("{{ markdown_snippet }}", htmlEscape(markdownSnippet)), + ("{{ preview_url }}", "/uploads/" & encodeUrl(storedName)) + ] + ) + await respondHtml(req, Http200, html) + +# router table +proc route(req: Request) {.async.} = + let path = req.url.path + + if req.reqMethod == HttpGet and path == "/": + await respondFile(req, templatesDir() / "index.html") + return + + if req.reqMethod == HttpGet and path.startsWith("/static/"): + let relativePath = decodeUrl(path[8 .. ^1]) + if not isSafeRelativePath(relativePath): + await respondText(req, Http400, "Invalid path") + return + await respondFile(req, staticDir() / relativePath) + return + + if req.reqMethod == HttpGet and path.startsWith("/uploads/"): + let relativePath = decodeUrl(path[9 .. ^1]) + if not isSafeRelativePath(relativePath): + await respondText(req, Http400, "Invalid path") + return + await respondFile(req, uploadsDir() / relativePath) + return + + if req.reqMethod == HttpGet and path.startsWith("/download/"): + let relativePath = decodeUrl(path[10 .. ^1]) + if not isSafeRelativePath(relativePath): + await respondText(req, Http400, "Invalid path") + return + await respondFile(req, generatedDir() / relativePath, asAttachment = true, attachmentName = relativePath) + return + + if req.reqMethod == HttpPost and path == "/convert": + await handleConvert(req) + return + + if req.reqMethod == HttpPost and path == "/upload-image": + await handleUploadImage(req) + return + + await respondText(req, Http404, "Not found") + +# server boot, then we let htmx do htmx things. +when isMainModule: + randomize() + + if not dirExists(generatedDir()): + createDir(generatedDir()) + if not dirExists(uploadsDir()): + createDir(uploadsDir()) + + let server = newAsyncHttpServer() + echo "listening on http://localhost:5000" + waitFor server.serve(Port(5000), route)
\ No newline at end of file diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 6b5a45a..0000000 --- a/src/app.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import subprocess -import tempfile -import time -import uuid -from pathlib import Path - -from flask import Flask, render_template, request, send_from_directory, url_for -from werkzeug.utils import secure_filename - -app = Flask(__name__) - -BASE_DIR = Path(__file__).resolve().parent -GENERATED_DIR = BASE_DIR / "generated" -UPLOADS_DIR = BASE_DIR / "uploads" -LATEX_TEMPLATE = BASE_DIR / "latex" / "template.tex" - -GENERATED_DIR.mkdir(parents=True, exist_ok=True) -UPLOADS_DIR.mkdir(parents=True, exist_ok=True) - -ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "svg"} - -PAPER_SIZES = { - "a4paper": "A4", - "letterpaper": "US Letter", - "legalpaper": "US Legal", -} - -MARGINS = { - "0.75in": "Narrow (0.75in)", - "1in": "Normal (1in)", - "1.25in": "Comfort (1.25in)", - "1.5in": "Wide (1.5in)", -} - -MAIN_FONTS = { - "serif": "TeX Gyre Pagella", - "sans": "TeX Gyre Heros", -} - - -def _pick(options: dict[str, str], key: str, default_key: str) -> str: - if key in options: - return key - return default_key - - -def _is_allowed_image(filename: str) -> bool: - if "." not in filename: - return False - extension = filename.rsplit(".", 1)[1].lower() - return extension in ALLOWED_IMAGE_EXTENSIONS - - -@app.get("/") -def index(): - return render_template( - "index.html", - paper_sizes=PAPER_SIZES, - margins=MARGINS, - ) - - -@app.post("/convert") -def convert_markdown(): - markdown = request.form.get("markdown", "").strip() - if not markdown: - return render_template("partials/error.html", message="Markdown content is required."), 400 - - paper_size_key = _pick(PAPER_SIZES, request.form.get("paper_size", ""), "a4paper") - margin_key = _pick(MARGINS, request.form.get("margin", ""), "1in") - main_family_key = request.form.get("main_font", "serif") - - if main_family_key not in MAIN_FONTS: - main_family_key = "serif" - - epoch = int(time.time()) - unique_id = uuid.uuid4().hex - output_name = f"likha-pdf_{epoch}_{unique_id}.pdf" - output_path = GENERATED_DIR / output_name - - with tempfile.TemporaryDirectory() as tmp_dir: - temp_markdown = Path(tmp_dir) / "source.md" - temp_markdown.write_text(markdown, encoding="utf-8") - - command = [ - "pandoc", - str(temp_markdown), - "--from", - "markdown+emoji", - "--pdf-engine=lualatex", - "--template", - str(LATEX_TEMPLATE), - "-V", - f"papersize={paper_size_key}", - "-V", - f"margin={margin_key}", - "-V", - f"mainfont={MAIN_FONTS[main_family_key]}", - "--resource-path", - f"{BASE_DIR}:{UPLOADS_DIR}:{tmp_dir}", - "-o", - str(output_path), - ] - - try: - subprocess.run(command, check=True, capture_output=True, text=True) - except FileNotFoundError: - return ( - render_template( - "partials/error.html", - message="Pandoc is not installed or not in PATH.", - ), - 500, - ) - except subprocess.CalledProcessError as exc: - stderr = (exc.stderr or "").strip() - error_message = stderr[-1200:] if stderr else "PDF conversion failed." - return render_template("partials/error.html", message=error_message), 400 - - return render_template( - "partials/result.html", - download_url=url_for("download_pdf", filename=output_name), - filename=output_name, - ) - - -@app.post("/upload-image") -def upload_image(): - image = request.files.get("image") - if image is None or image.filename is None or not image.filename.strip(): - return render_template("partials/upload_error.html", message="image file is required."), 400 - - original_name = secure_filename(image.filename) - if not original_name or not _is_allowed_image(original_name): - return render_template("partials/upload_error.html", message="unsupported image type."), 400 - - extension = original_name.rsplit(".", 1)[1].lower() - epoch = int(time.time()) - unique_id = uuid.uuid4().hex - stored_name = f"img_{epoch}_{unique_id}.{extension}" - image_path = UPLOADS_DIR / stored_name - image.save(image_path) - - markdown_snippet = f" / stored_name).as_posix()})" - return render_template( - "partials/upload_result.html", - filename=stored_name, - markdown_snippet=markdown_snippet, - preview_url=url_for("uploaded_image", filename=stored_name), - ) - - -@app.get("/uploads/<path:filename>") -def uploaded_image(filename: str): - return send_from_directory(UPLOADS_DIR, filename) - - -@app.get("/download/<path:filename>") -def download_pdf(filename: str): - return send_from_directory(GENERATED_DIR, filename, as_attachment=True) - - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index 4a4d074..0000000 --- a/src/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Flask>=3.0.0,<4.0.0 diff --git a/src/templates/index.html b/src/templates/index.html index 42f5f5c..e67f582 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -61,16 +61,17 @@ <label for="paper_size">Paper size</label> <select id="paper_size" name="paper_size"> - {% for key, label in paper_sizes.items() %} - <option value="{{ key }}">{{ label }}</option> - {% endfor %} + <option value="a4paper">A4</option> + <option value="letterpaper">US Letter</option> + <option value="legalpaper">US Legal</option> </select> <label for="margin">Margins</label> <select id="margin" name="margin"> - {% for key, label in margins.items() %} - <option value="{{ key }}" {% if key == "1in" %}selected{% endif %}>{{ label }}</option> - {% endfor %} + <option value="0.75in">Narrow (0.75in)</option> + <option value="1in" selected>Normal (1in)</option> + <option value="1.25in">Comfort (1.25in)</option> + <option value="1.5in">Wide (1.5in)</option> </select> <fieldset> @@ -91,6 +92,6 @@ <section id="result" aria-live="polite"></section> </main> - <script src="{{ url_for('static', filename='main.js') }}"></script> + <script src="/static/main.js"></script> </body> </html> |
