aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-05-31 10:22:07 -0400
committerkj_sh6042026-05-31 10:22:07 -0400
commit4626e9f40c4678622ef53cc0d9d3fa89768c08f0 (patch)
tree8977408c5c60ac18f8755c93ffa065d983ade936
parent97be54bb5d0ca21c6d168a3ce57a4cbb56fa484e (diff)
refactor: unused file clean-up
-rw-r--r--.dockerignore9
-rw-r--r--Dockerfile37
-rw-r--r--src/__legacy_src/app.nim525
-rw-r--r--src/__legacy_src/backend_compat.nim525
-rw-r--r--src/__legacy_src/server.nim525
5 files changed, 0 insertions, 1621 deletions
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 4bddc1b..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,9 +0,0 @@
-.git
-.gitignore
-README.md
-src/.venv
-src/__pycache__
-src/*.pyc
-src/generated/*
-src/uploads/*
-*.md
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 2ef5ac8..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,37 +0,0 @@
-FROM python:3.12-slim
-
-ENV DEBIAN_FRONTEND=noninteractive \
- PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1 \
- PORT=5001 \
- HOST=0.0.0.0 \
- LOG_LEVEL=INFO \
- TRUST_PROXY=1
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libcairo2 \
- libpango-1.0-0 \
- libpangocairo-1.0-0 \
- libgdk-pixbuf-2.0-0 \
- shared-mime-info \
- fonts-noto \
- fonts-noto-color-emoji \
- && rm -rf /var/lib/apt/lists/*
-
-WORKDIR /app
-
-RUN addgroup --system app && adduser --system --ingroup app app
-
-COPY requirements.txt .
-RUN pip install --no-cache-dir --disable-pip-version-check -r requirements.txt
-
-COPY src/ .
-
-RUN mkdir -p generated uploads
-
-RUN chown -R app:app /app
-USER app
-
-EXPOSE 5001
-
-CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--worker-class", "gthread", "--workers", "1", "--threads", "2", "--timeout", "240", "--graceful-timeout", "30", "--keep-alive", "5", "--max-requests", "300", "--max-requests-jitter", "50", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
diff --git a/src/__legacy_src/app.nim b/src/__legacy_src/app.nim
deleted file mode 100644
index 8b64793..0000000
--- a/src/__legacy_src/app.nim
+++ /dev/null
@@ -1,525 +0,0 @@
-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 = [
- "a0paper", "a1paper", "a2paper", "a3paper", "a4paper", "a5paper", "a6paper",
- "b0paper", "b1paper", "b2paper", "b3paper", "b4paper", "b5paper", "b6paper",
- "c4paper", "c5paper", "c6paper", "letterpaper", "legalpaper", "executivepaper",
- "ledgerpaper", "tabloid", "statement", "flsa",
- ]
- ValidMargins = ["0.25in", "0.5in", "0.75in", "1in", "1.25in", "1.5in", "1.75in"]
- ValidLineSpacings = ["1", "1.5", "2"]
- CustomPaperDimensions = [
- ("tabloid", "11in", "17in"),
- ("statement", "5.5in", "8.5in"),
- ("flsa", "8.5in", "13in"),
- ]
-
-const AppName = "likha-pdf"
-
-proc lookupCustomPaper(name: string): tuple[width: string, height: string] =
- for (paperName, w, h) in CustomPaperDimensions:
- if paperName == name:
- return (width: w, height: h)
- (width: "", height: "")
-
-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("<", "&lt;")
- result = result.replace(">", "&gt;")
- result = result.replace("\"", "&quot;")
- result = result.replace("'", "&#39;")
-
-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,
- lineSpacing: string,
- showPageNumbers: bool,
-): tuple[ok: bool, output: string, missingPandoc: bool] =
- let tempDir = getTempDir() / (AppName & "-" & randomHex(10))
- createDir(tempDir)
- let tempMarkdownPath = tempDir / "source.md"
- let tempRawPath = tempDir / "raw.md"
-
- try:
- # write raw markdown first
- writeFile(tempRawPath, sourceMarkdown)
-
- # preprocess markdown: convert to ascii with transliteration and normalize quotes
- let iconvCmd =
- "iconv -c -t ASCII//TRANSLIT " & quoteShell(tempRawPath) &
- " | sed 's/'\\''/'/g; s/\"\"/\"/g' > " & quoteShell(tempMarkdownPath)
- let (_, iconvExitCode) = execCmdEx(iconvCmd)
-
- if iconvExitCode != 0:
- # if preprocessing fails, fall back to original content
- writeFile(tempMarkdownPath, sourceMarkdown)
-
- var args = @[
- tempMarkdownPath,
- "--from",
- "markdown+emoji+hard_line_breaks",
- "--pdf-engine=lualatex",
- "--template",
- latexTemplatePath(),
- "-V",
- "margin=" & margin,
- "-V",
- "mainfont=" & mainFont,
- "-V",
- "linespacing=" & lineSpacing,
- "--resource-path",
- baseDir() & ":" & uploadsDir() & ":" & tempDir,
- "-o",
- outputPath,
- ]
-
- let dims = lookupCustomPaper(paperSize)
- if dims.width.len > 0:
- args.add("-V")
- args.add("paperwidth=" & dims.width)
- args.add("-V")
- args.add("paperheight=" & dims.height)
- else:
- args.add("-V")
- args.add("papersize=" & paperSize)
-
- if not showPageNumbers:
- args.add("-V")
- args.add("hidepages=true")
-
- 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(tempRawPath):
- removeFile(tempRawPath)
- 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 lineSpacing =
- pickOption(formData.getOrDefault("line_spacing", ""), "1", ValidLineSpacings)
- let showPageNumbers = formData.getOrDefault("page_numbers", "") == "on"
- let epoch = int(getTime().toUnix())
- let outputName = AppName & "_" & $epoch & "_" & randomHex(32) & ".pdf"
- let outputPath = generatedDir() / outputName
-
- let conversion = runPandoc(
- markdown, outputPath, paperSize, margin, mainFont, lineSpacing, showPageNumbers
- )
-
- 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 = "![](uploads/" & storedName & ")"
- 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:5001"
- waitFor server.serve(Port(5001), route) \ No newline at end of file
diff --git a/src/__legacy_src/backend_compat.nim b/src/__legacy_src/backend_compat.nim
deleted file mode 100644
index 8b64793..0000000
--- a/src/__legacy_src/backend_compat.nim
+++ /dev/null
@@ -1,525 +0,0 @@
-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 = [
- "a0paper", "a1paper", "a2paper", "a3paper", "a4paper", "a5paper", "a6paper",
- "b0paper", "b1paper", "b2paper", "b3paper", "b4paper", "b5paper", "b6paper",
- "c4paper", "c5paper", "c6paper", "letterpaper", "legalpaper", "executivepaper",
- "ledgerpaper", "tabloid", "statement", "flsa",
- ]
- ValidMargins = ["0.25in", "0.5in", "0.75in", "1in", "1.25in", "1.5in", "1.75in"]
- ValidLineSpacings = ["1", "1.5", "2"]
- CustomPaperDimensions = [
- ("tabloid", "11in", "17in"),
- ("statement", "5.5in", "8.5in"),
- ("flsa", "8.5in", "13in"),
- ]
-
-const AppName = "likha-pdf"
-
-proc lookupCustomPaper(name: string): tuple[width: string, height: string] =
- for (paperName, w, h) in CustomPaperDimensions:
- if paperName == name:
- return (width: w, height: h)
- (width: "", height: "")
-
-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("&", "&amp;")
- result = result.replace("<", "&lt;")
- result = result.replace(">", "&gt;")
- result = result.replace("\"", "&quot;")
- result = result.replace("'", "&#39;")
-
-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,
- lineSpacing: string,
- showPageNumbers: bool,
-): tuple[ok: bool, output: string, missingPandoc: bool] =
- let tempDir = getTempDir() / (AppName & "-" & randomHex(10))
- createDir(tempDir)
- let tempMarkdownPath = tempDir / "source.md"
- let tempRawPath = tempDir / "raw.md"
-
- try:
- # write raw markdown first
- writeFile(tempRawPath, sourceMarkdown)
-
- # preprocess markdown: convert to ascii with transliteration and normalize quotes
- let iconvCmd =
- "iconv -c -t ASCII//TRANSLIT " & quoteShell(tempRawPath) &
- " | sed 's/'\\''/'/g; s/\"\"/\"/g' > " & quoteShell(tempMarkdownPath)
- let (_, iconvExitCode) = execCmdEx(iconvCmd)
-
- if iconvExitCode != 0:
- # if preprocessing fails, fall back to original content
- writeFile(tempMarkdownPath, sourceMarkdown)
-
- var args = @[
- tempMarkdownPath,
- "--from",
- "markdown+emoji+hard_line_breaks",
- "--pdf-engine=lualatex",
- "--template",
- latexTemplatePath(),
- "-V",
- "margin=" & margin,
- "-V",
- "mainfont=" & mainFont,
- "-V",
- "linespacing=" & lineSpacing,
- "--resource-path",
- baseDir() & ":" & uploadsDir() & ":" & tempDir,
- "-o",
- outputPath,
- ]
-
- let dims = lookupCustomPaper(paperSize)
- if dims.width.len > 0:
- args.add("-V")
- args.add("paperwidth=" & dims.width)
- args.add("-V")
- args.add("paperheight=" & dims.height)
- else:
- args.add("-V")
- args.add("papersize=" & paperSize)
-
- if not showPageNumbers:
- args.add("-V")
- args.add("hidepages=true")
-
- 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(tempRawPath):
- removeFile(tempRawPath)
- 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 lineSpacing =
- pickOption(formData.getOrDefault("line_spacing", ""), "1", ValidLineSpacings)
- let showPageNumbers = formData.getOrDefault("page_numbers", "") == "on"
- let epoch = int(getTime().toUnix())
- let outputName = AppName & "_" & $epoch & "_" & randomHex(32) & ".pdf"
- let outputPath = generatedDir() / outputName
-
- let conversion = runPandoc(
- markdown, outputPath, paperSize, margin, mainFont, lineSpacing, showPageNumbers
- )
-
- 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 = "![](uploads/" & storedName & ")"
- 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:5001"
- waitFor server.serve(Port(5001), route) \ No newline at end of file
diff --git a/src/__legacy_src/server.nim b/src/__legacy_src/server.nim
deleted file mode 100644
index 8b64793..0000000
--- a/src/__legacy_src/server.nim
+++ /dev/null
@@ -1,525 +0,0 @@
-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 = [
- "a0paper", "a1paper", "a2paper", "a3paper", "a4paper", "a5paper", "a6paper",
- "b0paper", "b1paper", "b2paper", "b3paper", "b4paper", "b5paper", "b6paper",
- "c4paper", "c5paper", "c6paper", "letterpaper", "legalpaper", "executivepaper",
- "ledgerpaper", "tabloid", "statement", "flsa",
- ]
- ValidMargins = ["0.25in", "0.5in", "0.75in", "1in", "1.25in", "1.5in", "1.75in"]
- ValidLineSpacings = ["1", "1.5", "2"]
- CustomPaperDimensions = [
- ("tabloid", "11in", "17in"),
- ("statement", "5.5in", "8.5in"),
- ("flsa", "8.5in", "13in"),
- ]
-
-const AppName = "likha-pdf"
-
-proc lookupCustomPaper(name: string): tuple[width: string, height: string] =
- for (paperName, w, h) in CustomPaperDimensions:
- if paperName == name:
- return (width: w, height: h)
- (width: "", height: "")
-
-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("&", "&amp;")
- result = result.replace("<", "&lt;")
- result = result.replace(">", "&gt;")
- result = result.replace("\"", "&quot;")
- result = result.replace("'", "&#39;")
-
-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,
- lineSpacing: string,
- showPageNumbers: bool,
-): tuple[ok: bool, output: string, missingPandoc: bool] =
- let tempDir = getTempDir() / (AppName & "-" & randomHex(10))
- createDir(tempDir)
- let tempMarkdownPath = tempDir / "source.md"
- let tempRawPath = tempDir / "raw.md"
-
- try:
- # write raw markdown first
- writeFile(tempRawPath, sourceMarkdown)
-
- # preprocess markdown: convert to ascii with transliteration and normalize quotes
- let iconvCmd =
- "iconv -c -t ASCII//TRANSLIT " & quoteShell(tempRawPath) &
- " | sed 's/'\\''/'/g; s/\"\"/\"/g' > " & quoteShell(tempMarkdownPath)
- let (_, iconvExitCode) = execCmdEx(iconvCmd)
-
- if iconvExitCode != 0:
- # if preprocessing fails, fall back to original content
- writeFile(tempMarkdownPath, sourceMarkdown)
-
- var args = @[
- tempMarkdownPath,
- "--from",
- "markdown+emoji+hard_line_breaks",
- "--pdf-engine=lualatex",
- "--template",
- latexTemplatePath(),
- "-V",
- "margin=" & margin,
- "-V",
- "mainfont=" & mainFont,
- "-V",
- "linespacing=" & lineSpacing,
- "--resource-path",
- baseDir() & ":" & uploadsDir() & ":" & tempDir,
- "-o",
- outputPath,
- ]
-
- let dims = lookupCustomPaper(paperSize)
- if dims.width.len > 0:
- args.add("-V")
- args.add("paperwidth=" & dims.width)
- args.add("-V")
- args.add("paperheight=" & dims.height)
- else:
- args.add("-V")
- args.add("papersize=" & paperSize)
-
- if not showPageNumbers:
- args.add("-V")
- args.add("hidepages=true")
-
- 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(tempRawPath):
- removeFile(tempRawPath)
- 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 lineSpacing =
- pickOption(formData.getOrDefault("line_spacing", ""), "1", ValidLineSpacings)
- let showPageNumbers = formData.getOrDefault("page_numbers", "") == "on"
- let epoch = int(getTime().toUnix())
- let outputName = AppName & "_" & $epoch & "_" & randomHex(32) & ".pdf"
- let outputPath = generatedDir() / outputName
-
- let conversion = runPandoc(
- markdown, outputPath, paperSize, margin, mainFont, lineSpacing, showPageNumbers
- )
-
- 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 = "![](uploads/" & storedName & ")"
- 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:5001"
- waitFor server.serve(Port(5001), route) \ No newline at end of file