Compare commits

...

17 Commits

Author SHA1 Message Date
Emrik Östling 129aae6ac0 Merge remote-tracking branch 'origin/main' into feature/landlock 2026-09-11 19:07:49 +02:00
Emrik Östling cf6d730dba fix test 2026-09-11 19:07:37 +02:00
Orban Gergo b9ee1f664e feat(pdftops): added pdftops converter (#611) 2026-09-09 23:48:34 +02:00
Emrik Östling b795d669b6 add libc 2026-09-09 23:43:55 +02:00
Emrik Östling 8c31407050 fix imports 2026-09-09 23:43:55 +02:00
Emrik Östling d112ef9a1b fix symlink in downloads 2026-09-09 23:43:55 +02:00
Emrik Östling bc5a163fb5 fix for libreoffice 2026-09-09 23:43:55 +02:00
Emrik Östling 181941717e feat: implement landlock 2026-09-09 23:43:55 +02:00
JDubb 2ea8e00f1e fix(graphicsmagick): apply EXIF auto-orient so portrait images don't convert sideways (#590) (#637) 2026-09-09 22:34:44 +02:00
renovate[bot] edb21f867b chore(deps): update docker/metadata-action action to v6 (#641) 2026-09-09 22:31:01 +02:00
renovate[bot] 0dea046c20 chore(deps): update docker/setup-buildx-action action to v4 (#642) 2026-09-09 22:30:49 +02:00
renovate[bot] 878876ca17 chore(deps): update docker/build-push-action action to v7 (#640) 2026-09-09 22:30:39 +02:00
renovate[bot] 57ebd7f454 chore(deps): update actions/checkout action to v7 (#639) 2026-09-09 22:30:30 +02:00
Orban Gergo 5c96086d4e feat(ffmpeg): added m2ts, ts, vob, wmv input types, and mka output (#636) 2026-09-09 18:16:50 +02:00
Justin Shetty e94d037a61 fix(results): hide actions for failed conversions (#625) 2026-09-03 20:41:57 +02:00
JDubb ce8637307c fix(ffmpeg): raise execFile maxBuffer so long conversions don't overflow stderr (#629) 2026-09-03 20:40:28 +02:00
Justin Shetty acee10f8df feat(auth): autofocus login email field (#627) 2026-09-01 11:11:14 +02:00
48 changed files with 1227 additions and 177 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Bun
uses: oven-sh/setup-bun@v2
+6 -6
View File
@@ -49,7 +49,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: downcase REPO
run: |
@@ -57,12 +57,12 @@ jobs:
- name: Docker meta default
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ env.REPO }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
platforms: ${{ matrix.platform }}
@@ -76,7 +76,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
env:
DOCKER_BUILDKIT: 1
with:
@@ -130,14 +130,14 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.REPO }}
${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
dockerHubDescription:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v5
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Bun
uses: oven-sh/setup-bun@v2
+1
View File
@@ -50,3 +50,4 @@ package-lock.json
/Bruno
/tsconfig.tsbuildinfo
/public/generated.css
/bin
+8 -4
View File
@@ -12,9 +12,9 @@ RUN apt-get update && apt-get install -y \
# if architecture is arm64, use the arm64 version of bun
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
else \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
fi
RUN unzip -j bun-linux-*.zip -d /usr/local/bin && \
@@ -35,6 +35,7 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM base AS prerelease
WORKDIR /app
RUN apt-get update && apt-get install -y gcc libc6-dev --no-install-recommends && rm -rf /var/lib/apt/lists/*
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
@@ -52,6 +53,7 @@ RUN apt-get update && apt-get install -y \
dcraw \
dvisvgm \
ffmpeg \
fonts-liberation \
ghostscript \
graphicsmagick \
imagemagick-7.q16 \
@@ -90,9 +92,9 @@ ENV PATH="/root/.local/bin:${PATH}"
# Install VTracer binary
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
else \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
fi && \
curl -L -o /tmp/vtracer.tar.gz "https://github.com/visioncortex/vtracer/releases/download/0.6.4/${VTRACER_ASSET}" && \
tar -xzf /tmp/vtracer.tar.gz -C /tmp/ && \
@@ -103,6 +105,7 @@ RUN ARCH=$(uname -m) && \
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /app/public/ /app/public/
COPY --from=prerelease /app/dist /app/dist
COPY --from=prerelease /app/bin/landlock-runner /usr/local/bin/landlock-runner
# COPY . .
RUN mkdir data
@@ -110,5 +113,6 @@ RUN mkdir data
EXPOSE 3000/tcp
# used for calibre
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
ENV SANDBOX_STRICT="true"
ENV NODE_ENV=production
ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
+1
View File
@@ -47,6 +47,7 @@ A self-hosted online file converter. Supports over a thousand different formats.
| [Potrace](https://potrace.sourceforge.net/) | Raster to vector | 4 | 11 |
| [VTracer](https://github.com/visioncortex/vtracer) | Raster to vector | 8 | 1 |
| [Markitdown](https://github.com/microsoft/markitdown) | Documents | 6 | 1 |
| [pdftops](https://poppler.freedesktop.org/) | Documents | 1 | 2 |
<!-- many ffmpeg fileformats are duplicates -->
+2 -1
View File
@@ -18,7 +18,8 @@
"heif-info",
"potrace",
"soffice",
"perl"
"perl",
"gcc"
],
"tailwind": {
"entry": ["src/main.css"]
+2 -1
View File
@@ -8,7 +8,8 @@
"format:eslint": "eslint --fix .",
"format:prettier": "prettier --write .",
"build:js": "tsc",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:js",
"build:runner": "mkdir -p bin && gcc -O2 -Wall -Wextra src/native/landlock-runner.c -o bin/landlock-runner || true",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:runner && bun run build:js",
"lint": "npm-run-all 'lint:*'",
"lint:tsc": "tsc --noEmit",
"lint:knip": "knip",
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -118,7 +117,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -65,7 +64,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("ebook-convert", [filePath, targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,6 +1,5 @@
import fs from "fs";
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -21,7 +20,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
const args = buildDaselArgs(filePath, fileType, convertTo);
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
const inputArgs: string[] = [];
if (fileType === "eps") {
+15 -3
View File
@@ -1,5 +1,11 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
// ffmpeg streams continuous progress to stderr, so a long conversion overflows
// execFile's 1 MB default maxBuffer and fails with "stderr maxBuffer length
// exceeded" (issue #565). Raise it well above that. The options object must be
// passed before the callback: execFile ignores an options argument placed after
// the callback.
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB
import { defaultExecFile, ExecFileFn } from "./types";
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
export const properties = {
@@ -224,6 +230,7 @@ export const properties = {
"lxf",
"m15",
"m2a",
"m2ts",
"m4a",
"m4b",
"m4v",
@@ -406,6 +413,7 @@ export const properties = {
"tiff",
"tmv",
"truehd",
"ts",
"tta",
"tty",
"txd",
@@ -439,6 +447,7 @@ export const properties = {
"vivo",
"vmd",
"vobsub",
"vob",
"voc",
"vpk",
"vplayer",
@@ -457,6 +466,7 @@ export const properties = {
"webp",
"webvtt",
"wma",
"wmv",
"wow",
"wsaud",
"wsd",
@@ -589,6 +599,7 @@ export const properties = {
"m4v",
"mjpeg",
"mjpg",
"mka",
"mkv",
"mlp",
"mmf",
@@ -694,7 +705,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let extraArgs: string[] = [];
let message = "Done";
@@ -739,6 +750,7 @@ export async function convert(
execFile(
"ffmpeg",
[...ffmpegArgs, "-i", filePath, ...ffmpegOutputArgs, ...extraArgs, targetPath],
{ maxBuffer: FFMPEG_MAX_BUFFER },
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
+5 -4
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -315,10 +314,12 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
// Apply EXIF orientation so photos (e.g. from phones) don't end up sideways
// when converted to formats where the orientation tag is lost or ignored
return new Promise((resolve, reject) => {
execFile("gm", ["convert", filePath, targetPath], (error, stdout, stderr) => {
execFile("gm", ["convert", filePath, "-auto-orient", targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -447,7 +446,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let outputArgs: string[] = [];
let inputArgs: string[] = [];
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -34,7 +33,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("inkscape", [filePath, "-o", targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("heif-convert", [filePath, targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -19,7 +18,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let tool = "";
if (fileType === "jxl") {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -162,7 +161,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
+79 -32
View File
@@ -1,7 +1,10 @@
import { Cookie } from "elysia";
import { rmSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import db from "../db/db";
import { MAX_CONVERT_PROCESS } from "../helpers/env";
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
import { createSandboxedExec } from "../helpers/sandbox";
import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertDasel, properties as propertiesDasel } from "./dasel";
@@ -16,15 +19,17 @@ import { convert as convertInkscape, properties as propertiesInkscape } from "./
import { convert as convertLibheif, properties as propertiesLibheif } from "./libheif";
import { convert as convertLibjxl, properties as propertiesLibjxl } from "./libjxl";
import { convert as convertLibreOffice, properties as propertiesLibreOffice } from "./libreoffice";
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
import { convert as convertMsgconvert, properties as propertiesMsgconvert } from "./msgconvert";
import { convert as convertPandoc, properties as propertiesPandoc } from "./pandoc";
import { convert as convertPdftops, properties as propertiesPdftops } from "./pdftops";
import { convert as convertPotrace, properties as propertiesPotrace } from "./potrace";
import { convert as convertresvg, properties as propertiesresvg } from "./resvg";
import { ExecFileFn } from "./types";
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
import { convert as convertImage, properties as propertiesImage } from "./vips";
import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer";
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
@@ -51,8 +56,8 @@ const properties: Record<
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile?: ExecFileFn,
) => unknown;
}
> = {
@@ -137,6 +142,10 @@ const properties: Record<
properties: propertiesMarkitdown,
converter: convertMarkitdown,
},
pdftops: {
properties: propertiesPdftops,
converter: convertPdftops,
},
};
function chunks<T>(arr: T[], size: number): T[][] {
@@ -160,37 +169,67 @@ export async function handleConvert(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for (const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
let newFileName: string;
if (fileTypeOrig === "") {
newFileName = `${fileName}.${newFileExt}`;
} else {
newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
const effectiveJobId = jobId.value ?? `convertx_${Date.now()}`;
const tempJobDir = `/tmp/convertx_${effectiveJobId}/`;
try {
await mkdir(tempJobDir, { recursive: true });
} catch (err) {
console.error(`Failed to create temp directory ${tempJobDir}:`, err);
}
const sandboxedExec = createSandboxedExec({
inputDir: userUploadsDir,
outputDir: userOutputDir,
tempDir: tempJobDir,
});
try {
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for (const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
let newFileName: string;
if (fileTypeOrig === "") {
newFileName = `${fileName}.${newFileExt}`;
} else {
newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
}
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
sandboxedExec,
)
.then((r) => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
})
.catch((c) => reject(c));
}),
);
}
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
.then((r) => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
})
.catch((c) => reject(c));
}),
);
await Promise.all(toProcess);
}
} finally {
try {
rmSync(tempJobDir, { recursive: true, force: true });
} catch {
// Ignore cleanup error if temp directory was already removed
}
await Promise.all(toProcess);
}
}
@@ -201,6 +240,7 @@ async function mainConverter(
targetPath: string,
options?: unknown,
converterName?: string,
execFile?: ExecFileFn,
) {
const fileType = normalizeFiletype(fileTypeOriginal);
@@ -235,7 +275,14 @@ async function mainConverter(
}
try {
const result = await converterFunc(inputFilePath, fileType, convertTo, targetPath, options);
const result = await converterFunc(
inputFilePath,
fileType,
convertTo,
targetPath,
options,
execFile,
);
console.log(
`Converted ${inputFilePath} from ${fileType} to ${convertTo} successfully using ${converterName}.`,
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("markitdown", [filePath, "-o", targetPath], (err, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
if (fileType === "msg" && convertTo === "eml") {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -126,7 +125,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
// set xelatex here
const xelatex = ["pdf", "latex"];
+47
View File
@@ -0,0 +1,47 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
document: ["pdf"],
},
to: {
document: ["eps", "ps"],
},
};
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const args: string[] = [];
if (convertTo === "eps") {
args.push("-eps");
}
args.push(filePath, targetPath);
return new Promise((resolve, reject) => {
execFile("pdftops", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -28,7 +27,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("potrace", [filePath, "-o", targetPath, "-b", convertTo], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("resvg", [filePath, targetPath], (error, stdout, stderr) => {
+11 -3
View File
@@ -1,12 +1,20 @@
import type { ChildProcess, ExecFileOptions } from "child_process";
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
export type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
export type ExecFileFn = (
cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
options?: ExecFileOptions,
...argsOrOptions:
[callback: ExecFileCallback] | [options: ExecFileOptions, callback: ExecFileCallback]
) => ChildProcess | void;
export const defaultExecFile: ExecFileFn = execFileOriginal as unknown as ExecFileFn;
export type ConvertFnWithExecFile = (
filePath: string,
fileType: string,
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -96,7 +95,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
// if (fileType === "svg") {
// const scale = options.scale || 1;
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -30,7 +29,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
// Build vtracer arguments
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
// const fileName: string = (targetPath.split("/").pop() as string).replace(".pdf", "")
+3
View File
@@ -25,3 +25,6 @@ export const UNAUTHENTICATED_USER_SHARING =
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;
export const TIMEZONE = process.env.TZ || undefined;
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
+179
View File
@@ -0,0 +1,179 @@
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type { ExecFileCallback, ExecFileFn } from "../converters/types";
export interface SandboxConfig {
inputDir: string;
outputDir: string;
tempDir: string;
allowNet?: boolean;
}
let cachedRunnerPath: string | null | undefined = undefined;
/**
* Locate the landlock-runner binary if available.
*/
export function getLandlockRunnerPath(): string | null {
if (cachedRunnerPath !== undefined) {
return cachedRunnerPath;
}
if (process.env.SANDBOX_DISABLED === "true") {
cachedRunnerPath = null;
return null;
}
if (process.platform !== "linux") {
cachedRunnerPath = null;
return null;
}
if (process.env.LANDLOCK_RUNNER_PATH && existsSync(process.env.LANDLOCK_RUNNER_PATH)) {
cachedRunnerPath = process.env.LANDLOCK_RUNNER_PATH;
return cachedRunnerPath;
}
const candidatePaths = [
"/usr/local/bin/landlock-runner",
resolve(process.cwd(), "bin/landlock-runner"),
];
for (const candidate of candidatePaths) {
if (existsSync(candidate)) {
cachedRunnerPath = candidate;
return cachedRunnerPath;
}
}
cachedRunnerPath = null;
return null;
}
/**
* Reset the cached runner path (primarily for testing).
*/
export function resetCachedRunnerPath(): void {
cachedRunnerPath = undefined;
}
/**
* Returns true if Landlock sandboxing is supported and enabled.
*/
export function isSandboxAvailable(): boolean {
return getLandlockRunnerPath() !== null;
}
const DEFAULT_SYSTEM_RO = "/usr:/lib:/lib64:/bin:/sbin:/etc:/proc:/var";
/**
* Create a sandboxed execFile function configured for a specific conversion job.
*/
export function createSandboxedExec(config: SandboxConfig): ExecFileFn {
const runnerPath = getLandlockRunnerPath();
const isStrict = process.env.SANDBOX_STRICT === "true";
const resolvedInput = resolve(config.inputDir);
const resolvedOutput = resolve(config.outputDir);
const resolvedTemp = resolve(config.tempDir);
const sandboxedExec = ((
cmd: string,
args: string[],
optionsOrCallback?: ExecFileOptions | ExecFileCallback,
callbackOrOptions?: ExecFileOptions | ExecFileCallback,
): ChildProcess | void => {
let options: ExecFileOptions = {};
let callback: ExecFileCallback | undefined;
if (typeof optionsOrCallback === "function") {
callback = optionsOrCallback;
if (typeof callbackOrOptions === "object" && callbackOrOptions !== null) {
options = callbackOrOptions;
}
} else {
if (typeof optionsOrCallback === "object" && optionsOrCallback !== null) {
options = optionsOrCallback;
}
if (typeof callbackOrOptions === "function") {
callback = callbackOrOptions;
}
}
const cb: ExecFileCallback = callback ?? (() => {});
// Provide isolated per-job TMPDIR, HOME, and XDG directories
const sandboxedEnv = {
...process.env,
...options.env,
TMPDIR: resolvedTemp,
TEMP: resolvedTemp,
TMP: resolvedTemp,
HOME: resolvedTemp,
XDG_CONFIG_HOME: `${resolvedTemp}/.config`,
XDG_CACHE_HOME: `${resolvedTemp}/.cache`,
XDG_DATA_HOME: `${resolvedTemp}/.local/share`,
};
const sandboxedOptions: ExecFileOptions = {
...options,
env: sandboxedEnv,
};
if (!runnerPath) {
if (isStrict) {
const error = new Error(
"SANDBOX_STRICT is enabled, but landlock-runner is not available on this system.",
);
cb(error, "", error.message);
return;
}
return (
execFileOriginal as (
c: string,
a: string[],
o: ExecFileOptions,
f: ExecFileCallback,
) => ChildProcess
)(cmd, args, sandboxedOptions, cb);
}
// Build landlock-runner arguments
const runnerArgs: string[] = [
"--ro",
DEFAULT_SYSTEM_RO,
"--ro",
resolvedInput,
"--rw",
resolvedOutput,
"--rw",
`${resolvedTemp}:/tmp:/dev`,
];
if (!config.allowNet) {
runnerArgs.push("--no-net");
}
if (isStrict) {
runnerArgs.push("--strict");
}
runnerArgs.push("--", cmd, ...args);
return (
execFileOriginal as (
c: string,
a: string[],
o: ExecFileOptions,
f: ExecFileCallback,
) => ChildProcess
)(runnerPath, runnerArgs, sandboxedOptions, cb);
}) as unknown as ExecFileFn;
return sandboxedExec;
}
+13 -7
View File
@@ -1,26 +1,24 @@
import { rmSync } from "node:fs";
import { html } from "@elysiajs/html";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import "./helpers/printVersions";
import { rmSync } from "node:fs";
import db from "./db/db";
import { Jobs } from "./db/types";
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
import { AUTO_DELETE_EVERY_N_HOURS, outputDir, uploadsDir, WEBROOT } from "./helpers/env";
import "./helpers/printVersions";
import { getLandlockRunnerPath, isSandboxAvailable } from "./helpers/sandbox";
import { chooseConverter } from "./pages/chooseConverter";
import { convert } from "./pages/convert";
import { deleteFile } from "./pages/deleteFile";
import { deleteJob } from "./pages/deleteJob";
import { download } from "./pages/download";
import { healthcheck } from "./pages/healthcheck";
import { history } from "./pages/history";
import { listConverters } from "./pages/listConverters";
import { results } from "./pages/results";
import { root } from "./pages/root";
import { upload } from "./pages/upload";
import { user } from "./pages/user";
import { healthcheck } from "./pages/healthcheck";
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
// Fix for Elysia issue with Bun, (see https://github.com/oven-sh/bun/issues/12161)
process.getBuiltinModule = require;
@@ -73,6 +71,14 @@ app.listen(process.env.PORT || 3000);
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
if (isSandboxAvailable()) {
console.log(`🔒 Landlock sandbox: ENABLED (${getLandlockRunnerPath()})`);
} else if (process.env.SANDBOX_STRICT === "true") {
console.warn("⚠️ Landlock sandbox: STRICT mode enabled but runner unavailable!");
} else {
console.log("️ Landlock sandbox: NOT AVAILABLE (running unsandboxed)");
}
const clearJobs = () => {
const jobs = db
.query("SELECT * FROM jobs WHERE date_created < ?")
+324
View File
@@ -0,0 +1,324 @@
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <linux/prctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#ifndef landlock_create_ruleset
static inline int landlock_create_ruleset(
const struct landlock_ruleset_attr *const attr,
const size_t size, const __u32 flags) {
return syscall(__NR_landlock_create_ruleset, attr, size, flags);
}
#endif
#ifndef landlock_add_rule
static inline int landlock_add_rule(
const int ruleset_fd, const enum landlock_rule_type rule_type,
const void *const rule_attr, const __u32 flags) {
return syscall(__NR_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags);
}
#endif
#ifndef landlock_restrict_self
static inline int landlock_restrict_self(
const int ruleset_fd, const __u32 flags) {
return syscall(__NR_landlock_restrict_self, ruleset_fd, flags);
}
#endif
#ifndef LANDLOCK_RULE_PATH_BENEATH
#define LANDLOCK_RULE_PATH_BENEATH 1
#endif
#ifndef LANDLOCK_ACCESS_FS_EXECUTE
#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0)
#define LANDLOCK_ACCESS_FS_WRITE_FILE (1ULL << 1)
#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2)
#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3)
#define LANDLOCK_ACCESS_FS_REMOVE_DIR (1ULL << 4)
#define LANDLOCK_ACCESS_FS_REMOVE_FILE (1ULL << 5)
#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6)
#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7)
#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8)
#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9)
#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10)
#define LANDLOCK_ACCESS_FS_MAKE_BLOCK (1ULL << 11)
#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12)
#endif
#ifndef LANDLOCK_ACCESS_FS_REFER
#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13)
#endif
#ifndef LANDLOCK_ACCESS_FS_TRUNCATE
#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14)
#endif
#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV
#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15)
#endif
#ifndef LANDLOCK_ACCESS_NET_BIND_TCP
#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0)
#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
#endif
struct path_node {
char *path;
struct path_node *next;
};
static void add_node(struct path_node **head, const char *path) {
if (!path || path[0] == '\0') return;
struct path_node *node = malloc(sizeof(struct path_node));
if (!node) return;
node->path = strdup(path);
node->next = *head;
*head = node;
}
static void add_colon_separated(struct path_node **head, const char *path_list) {
if (!path_list || path_list[0] == '\0') return;
char *copy = strdup(path_list);
if (!copy) return;
char *saveptr = NULL;
char *token = strtok_r(copy, ":", &saveptr);
while (token != NULL) {
if (strlen(token) > 0) {
add_node(head, token);
}
token = strtok_r(NULL, ":", &saveptr);
}
free(copy);
}
static void free_nodes(struct path_node *head) {
while (head) {
struct path_node *next = head->next;
free(head->path);
free(head);
head = next;
}
}
static void add_path_rule(int ruleset_fd, const char *path, __u64 allowed_access, int abi) {
if (!path || path[0] == '\0') return;
int fd = open(path, O_PATH | O_CLOEXEC);
if (fd < 0) {
// Path does not exist or cannot be opened, ignore non-fatal missing paths
return;
}
struct stat st;
if (fstat(fd, &st) < 0) {
close(fd);
return;
}
__u64 access = allowed_access;
if (!S_ISDIR(st.st_mode)) {
// Character devices, regular files, sockets, etc. cannot take directory-only access flags
__u64 file_mask = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_WRITE_FILE;
if (abi >= 2) file_mask |= LANDLOCK_ACCESS_FS_REFER;
if (abi >= 3) file_mask |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) file_mask |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
access &= file_mask;
}
struct landlock_path_beneath_attr path_beneath = {
.parent_fd = fd,
.allowed_access = access,
};
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
close(fd);
}
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s [options] -- <command> [args...]\n\n"
"Options:\n"
" --ro <paths> Colon-separated list of read-only paths (can be repeated)\n"
" --rw <paths> Colon-separated list of read-write paths (can be repeated)\n"
" --no-net Disable all network operations (TCP connect & bind)\n"
" --strict Fail if Landlock is unsupported instead of falling back\n"
" --version Print Landlock ABI version and exit\n"
" --help Print this help message\n",
prog);
}
int main(int argc, char *argv[]) {
struct path_node *ro_head = NULL;
struct path_node *rw_head = NULL;
bool no_net = false;
bool strict = false;
int cmd_idx = -1;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) {
cmd_idx = i + 1;
break;
} else if (strcmp(argv[i], "--version") == 0) {
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) {
printf("Landlock unsupported (%s)\n", strerror(errno));
return 1;
}
printf("Landlock ABI v%d\n", abi);
return 0;
} else if (strcmp(argv[i], "--help") == 0) {
print_usage(argv[0]);
return 0;
} else if (strcmp(argv[i], "--ro") == 0) {
if (++i >= argc) {
fprintf(stderr, "landlock-runner: --ro requires an argument\n");
return 1;
}
add_colon_separated(&ro_head, argv[i]);
} else if (strcmp(argv[i], "--rw") == 0) {
if (++i >= argc) {
fprintf(stderr, "landlock-runner: --rw requires an argument\n");
return 1;
}
add_colon_separated(&rw_head, argv[i]);
} else if (strcmp(argv[i], "--no-net") == 0) {
no_net = true;
} else if (strcmp(argv[i], "--strict") == 0) {
strict = true;
} else {
fprintf(stderr, "landlock-runner: unrecognized option '%s'\n", argv[i]);
print_usage(argv[0]);
return 1;
}
}
if (cmd_idx < 0 || cmd_idx >= argc) {
fprintf(stderr, "landlock-runner: no command specified after '--'\n");
free_nodes(ro_head);
free_nodes(rw_head);
return 1;
}
// Query Landlock ABI version
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 1) {
free_nodes(ro_head);
free_nodes(rw_head);
if (strict) {
fprintf(stderr, "landlock-runner error: Landlock LSM is not supported or disabled on this kernel (errno=%d: %s)\n",
errno, strerror(errno));
return 1;
}
// Fallback: run command without Landlock
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
// Handled filesystem access rights in ruleset
__u64 handled_fs_access = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM;
if (abi >= 2) handled_fs_access |= LANDLOCK_ACCESS_FS_REFER;
if (abi >= 3) handled_fs_access |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) handled_fs_access |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
// Build supported filesystem access rights for read-only paths
__u64 fs_ro_access = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR;
if (abi >= 2) fs_ro_access |= LANDLOCK_ACCESS_FS_REFER;
// Build supported filesystem access rights for read-write paths
// NOTE: Intentionally exclude LANDLOCK_ACCESS_FS_MAKE_SYM, MAKE_CHAR, MAKE_BLOCK, MAKE_FIFO
// to prevent creation of symlinks (CWE-59 sandbox escape) and device nodes.
__u64 fs_rw_access = fs_ro_access |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK;
if (abi >= 3) fs_rw_access |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) fs_rw_access |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
struct landlock_ruleset_attr attr = {
.handled_access_fs = handled_fs_access,
};
if (no_net && abi >= 4) {
attr.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP;
}
int ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (ruleset_fd < 0) {
free_nodes(ro_head);
free_nodes(rw_head);
if (strict) {
fprintf(stderr, "landlock-runner error: failed to create ruleset: %s\n", strerror(errno));
return 1;
}
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
// Add read-only paths
for (struct path_node *curr = ro_head; curr; curr = curr->next) {
add_path_rule(ruleset_fd, curr->path, fs_ro_access, abi);
}
free_nodes(ro_head);
// Add read-write paths
for (struct path_node *curr = rw_head; curr; curr = curr->next) {
add_path_rule(ruleset_fd, curr->path, fs_rw_access, abi);
}
free_nodes(rw_head);
// Prevent processes from gaining new privileges
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
if (strict) {
perror("landlock-runner: prctl(PR_SET_NO_NEW_PRIVS)");
close(ruleset_fd);
return 1;
}
}
// Apply the Landlock sandbox to the current process and all future children
if (landlock_restrict_self(ruleset_fd, 0) < 0) {
if (strict) {
perror("landlock-runner: landlock_restrict_self");
close(ruleset_fd);
return 1;
}
}
close(ruleset_fd);
// Execute the target program
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
+2 -3
View File
@@ -1,11 +1,10 @@
import { mkdir } from "node:fs/promises";
import { Elysia, t } from "elysia";
import { mkdir } from "node:fs/promises";
import sanitize from "sanitize-filename";
import { outputDir, uploadsDir } from "..";
import { handleConvert } from "../converters/main";
import db from "../db/db";
import { Jobs } from "../db/types";
import { WEBROOT } from "../helpers/env";
import { outputDir, uploadsDir, WEBROOT } from "../helpers/env";
import { normalizeFiletype } from "../helpers/normalizeFiletype";
import { userService } from "./user";
+5 -6
View File
@@ -1,11 +1,10 @@
import { unlink } from "node:fs/promises";
import { Elysia, t } from "elysia";
import { uploadsDir } from "..";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { userService } from "./user";
import sanitize from "sanitize-filename";
import { unlink } from "node:fs/promises";
import path from "node:path";
import sanitize from "sanitize-filename";
import db from "../db/db";
import { uploadsDir, WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const deleteFile = new Elysia().use(userService).post(
"/delete",
+3 -4
View File
@@ -1,10 +1,9 @@
import { rmSync } from "node:fs";
import { Elysia, t } from "elysia";
import { outputDir, uploadsDir } from "..";
import { rmSync } from "node:fs";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { userService } from "./user";
import { Jobs } from "../db/types";
import { outputDir, uploadsDir, WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const deleteJob = new Elysia()
.use(userService)
+38 -9
View File
@@ -1,17 +1,17 @@
import path from "node:path";
import { Elysia } from "elysia";
import { lstat, realpath } from "node:fs/promises";
import path from "node:path";
import sanitize from "sanitize-filename";
import * as tar from "tar";
import { outputDir } from "..";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { outputDir, WEBROOT } from "../helpers/env";
import { userService } from "./user";
export const download = new Elysia()
.use(userService)
.get(
"/download/:userId/:jobId/:fileName",
async ({ params, redirect, user }) => {
async ({ params, redirect, set, user }) => {
const userId = user.id;
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@@ -24,8 +24,29 @@ export const download = new Elysia()
const jobId = decodeURIComponent(params.jobId);
const fileName = sanitize(decodeURIComponent(params.fileName));
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
return Bun.file(filePath);
const jobOutputDir = path.join(outputDir, userId, jobId);
const filePath = path.join(jobOutputDir, fileName);
try {
const stat = await lstat(filePath);
if (stat.isSymbolicLink()) {
set.status = 403;
return { message: "Access denied." };
}
const resolvedBase = await realpath(jobOutputDir);
const resolvedFile = await realpath(filePath);
if (!resolvedFile.startsWith(resolvedBase + path.sep)) {
set.status = 403;
return { message: "Access denied." };
}
} catch {
set.status = 404;
return { message: "Converted file not found." };
}
const file = Bun.file(filePath);
return file;
},
{
auth: true,
@@ -44,15 +65,23 @@ export const download = new Elysia()
}
const jobId = decodeURIComponent(params.jobId);
const outputPath = `${outputDir}${userId}/${jobId}`;
const outputPath = path.join(outputDir, userId, jobId);
const outputTar = path.join(outputPath, `converted_files_${jobId}.tar`);
await tar.create(
{
file: outputTar,
cwd: outputPath,
filter: (path) => {
return !path.match(".*\\.tar");
filter: (entryPath, stat) => {
const isSymlink =
("isSymbolicLink" in stat &&
typeof stat.isSymbolicLink === "function" &&
stat.isSymbolicLink()) ||
("type" in stat && (stat as { type?: string }).type === "SymbolicLink");
if (entryPath.endsWith(".tar") || isSymlink) {
return false;
}
return true;
},
},
["."],
+41 -29
View File
@@ -96,35 +96,47 @@ function ResultsArticle({
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
download={file.output_file_name}
>
<DownloadIcon />
</a>
</td>
</tr>
))}
{files.map((file) => {
const conversionFailed = ["Failed, check logs", "File type not supported"].includes(
file.status,
);
return (
<tr>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
{conversionFailed ? (
<span class="text-neutral-500">Unavailable</span>
) : (
<>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
download={file.output_file_name}
>
<DownloadIcon />
</a>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</article>
+3 -3
View File
@@ -1,9 +1,9 @@
import { Elysia, t } from "elysia";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { uploadsDir } from "../index";
import { userService } from "./user";
import { uploadsDir, WEBROOT } from "../helpers/env";
import sanitize from "sanitize-filename";
import { userService } from "./user";
export const upload = new Elysia().use(userService).post(
"/upload",
+1
View File
@@ -279,6 +279,7 @@ export const user = new Elysia()
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
autofocus
required
/>
</label>
+16
View File
@@ -1,14 +1,18 @@
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
import type { ExecFileOptions } from "node:child_process";
let calls: string[][] = [];
let lastOptions: ExecFileOptions | undefined;
function mockExecFile(
_cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
calls.push(args);
lastOptions = options;
if (args.includes("fail.mov")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
@@ -18,6 +22,7 @@ function mockExecFile(
beforeEach(() => {
calls = [];
lastOptions = undefined;
delete process.env.FFMPEG_ARGS;
});
@@ -168,6 +173,7 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
_options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
@@ -179,3 +185,13 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
expect(loggedMessage).toBe("stderr: Only stderr output");
});
test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => {
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
// execFile's default maxBuffer is 1 MB; ffmpeg's progress output on a long
// encode exceeds it and the conversion fails with "stderr maxBuffer length
// exceeded". Lock in the raised buffer (must match FFMPEG_MAX_BUFFER in
// ffmpeg.ts) so a regression to a smaller-but-still-over-1-MB value is caught.
expect(lastOptions?.maxBuffer).toBe(1024 * 1024 * 64);
});
+27 -2
View File
@@ -1,7 +1,32 @@
import { test } from "bun:test";
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { convert } from "../../src/converters/graphicsmagick";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("convert applies EXIF auto-orient", async () => {
let command = "";
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = cmd;
calls.push(args);
callback(null, "", "");
};
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, mockExecFile);
expect(result).toBe("Done");
expect(command).toBe("gm");
expect(calls[0]).toEqual(["convert", "input.jpg", "-auto-orient", "output.pdf"]);
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/pdftops";
import { runCommonTests } from "./helpers/commonTests";
runCommonTests(convert);
let calls: string[][] = [];
function mockExecFile(
_cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
calls.push(args);
if (args.includes("fail.pdf")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
callback(null, "Fake stdout", "");
}
}
beforeEach(() => {
calls = [];
});
test("converts a normal file to ps", async () => {
const originalConsoleLog = console.log;
let loggedMessage = "";
console.log = (msg) => {
loggedMessage = msg;
};
const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, mockExecFile);
console.log = originalConsoleLog;
expect(result).toBe("Done");
expect(calls[0]).toEqual(["in.pdf", "out.ps"]);
expect(loggedMessage).toBe("stdout: Fake stdout");
});
test("adds -eps flag for eps output", async () => {
const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, mockExecFile);
expect(result).toBe("Done");
expect(calls[0]).toEqual(["-eps", "in.pdf", "out.eps"]);
});
test("fails on exec error", async () => {
expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, mockExecFile)).rejects.toMatch(
/error: Error: mock failure/,
);
});
test("logs stderr when execFile returns only stderr and no error", async () => {
const originalConsoleError = console.error;
let loggedMessage = "";
console.error = (msg) => {
loggedMessage = msg;
};
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
};
await convert("input.pdf", "pdf", "ps", "output.ps", undefined, mockExecFileStderrOnly);
console.error = originalConsoleError;
expect(loggedMessage).toBe("stderr: Only stderr output");
});
+3 -6
View File
@@ -1,6 +1,6 @@
import { test, expect, beforeEach, afterEach, afterAll } from "bun:test";
import { Database } from "bun:sqlite";
import { unlinkSync, existsSync, mkdirSync } from "node:fs";
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
// set environment variable to ensure the test database is used instead of production data
process.env.DB_PATH = "./data/test-isolated.sqlite";
@@ -98,10 +98,7 @@ afterEach(() => {
});
afterAll(() => {
// Close the module-level default database before cleanup to prevent file lock errors
if (defaultDb) {
defaultDb.close();
}
// Do not close defaultDb as subsequent test files in the test suite share the db instance
// Cleanup of the isolated test database after the test run
if (existsSync("./data/test-isolated.sqlite")) {
unlinkSync("./data/test-isolated.sqlite");
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createSandboxedExec,
getLandlockRunnerPath,
isSandboxAvailable,
resetCachedRunnerPath,
} from "../../src/helpers/sandbox";
const originalEnv = { ...process.env };
beforeEach(() => {
resetCachedRunnerPath();
delete process.env.SANDBOX_DISABLED;
delete process.env.SANDBOX_STRICT;
delete process.env.LANDLOCK_RUNNER_PATH;
});
afterEach(() => {
resetCachedRunnerPath();
process.env = { ...originalEnv };
});
test("isSandboxAvailable returns false when SANDBOX_DISABLED is true", () => {
process.env.SANDBOX_DISABLED = "true";
expect(isSandboxAvailable()).toBe(false);
expect(getLandlockRunnerPath()).toBeNull();
});
test("getLandlockRunnerPath respects LANDLOCK_RUNNER_PATH if it exists", () => {
const runner = getLandlockRunnerPath();
if (runner) {
process.env.LANDLOCK_RUNNER_PATH = runner;
resetCachedRunnerPath();
expect(getLandlockRunnerPath()).toBe(runner);
}
});
test("createSandboxedExec sets TMPDIR and HOME in environment", async () => {
const testDir = join(tmpdir(), `sandbox-test-env-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
const inputDir = join(testDir, "input");
const outputDir = join(testDir, "output");
const tempDir = join(testDir, "temp");
mkdirSync(inputDir);
mkdirSync(outputDir);
mkdirSync(tempDir);
const sandboxedExec = createSandboxedExec({
inputDir,
outputDir,
tempDir,
});
const stdout = await new Promise<string>((resolve, reject) => {
sandboxedExec("/bin/sh", ["-c", 'echo "$TMPDIR $HOME"'], (err, out) => {
if (err) {
reject(err);
} else {
resolve(String(out));
}
});
});
expect(stdout.trim()).toBe(`${tempDir} ${tempDir}`);
rmSync(testDir, { recursive: true, force: true });
});
test("createSandboxedExec handles SANDBOX_STRICT when runner is missing", async () => {
process.env.SANDBOX_DISABLED = "true";
process.env.SANDBOX_STRICT = "true";
const sandboxedExec = createSandboxedExec({
inputDir: "/tmp/in",
outputDir: "/tmp/out",
tempDir: "/tmp/temp",
});
let errorCaptured: Error | null = null;
sandboxedExec("some-cmd", ["arg"], (err) => {
errorCaptured = err;
});
expect(errorCaptured).not.toBeNull();
expect(errorCaptured?.message).toContain("SANDBOX_STRICT is enabled");
});
test("createSandboxedExec passes options before callback correctly", async () => {
const testDir = join(tmpdir(), `sandbox-test-opts-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
const sandboxedExec = createSandboxedExec({
inputDir: testDir,
outputDir: testDir,
tempDir: testDir,
});
let called = false;
await new Promise<void>((resolve) => {
sandboxedExec("echo", ["hi"], { maxBuffer: 1024 }, () => {
called = true;
resolve();
});
});
expect(called).toBe(true);
rmSync(testDir, { recursive: true, force: true });
});
test("landlock-runner enforces real sandboxing when available", async () => {
const runnerPath = getLandlockRunnerPath();
if (!runnerPath) {
// Skip if landlock is not built/available on host
return;
}
const testDir = join(tmpdir(), `landlock-real-test-${Date.now()}`);
const inputDir = join(testDir, "input");
const outputDir = join(testDir, "output");
const tempDir = join(testDir, "temp");
mkdirSync(inputDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
mkdirSync(tempDir, { recursive: true });
const inputFile = join(inputDir, "sample.txt");
const outputFile = join(outputDir, "out.txt");
writeFileSync(inputFile, "content from input");
const sandboxedExec = createSandboxedExec({
inputDir,
outputDir,
tempDir,
});
// 1. Reading from inputDir and writing to outputDir must SUCCEED
const success = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/sh", ["-c", `cat "${inputFile}" > "${outputFile}"`], (err) => {
resolve(!err);
});
});
expect(success).toBe(true);
expect(existsSync(outputFile)).toBe(true);
// 2. Writing to unpermitted directory outside /tmp (e.g. workspace root) must FAIL
const forbiddenFile = join(process.cwd(), `test-forbidden-${Date.now()}.txt`);
const failure = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/sh", ["-c", `echo "should fail" > "${forbiddenFile}"`], (err) => {
resolve(!!err);
});
});
expect(failure).toBe(true);
expect(existsSync(forbiddenFile)).toBe(false);
// 3. Creating symlinks in outputDir must be blocked (Permission denied)
const symlinkTarget = "/etc/passwd";
const symlinkFile = join(outputDir, "symlink-test");
const symlinkFailed = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/ln", ["-s", symlinkTarget, symlinkFile], (err) => {
resolve(!!err);
});
});
expect(symlinkFailed).toBe(true);
expect(existsSync(symlinkFile)).toBe(false);
// 4. Network operations must be blocked (Permission denied)
const netBlocked = await new Promise<boolean>((resolve) => {
sandboxedExec(
"/usr/bin/python3",
["-c", "import socket; s = socket.socket(); s.connect(('127.0.0.1', 80))"],
(err) => {
resolve(!!err);
},
);
});
expect(netBlocked).toBe(true);
rmSync(testDir, { recursive: true, force: true });
});
+90
View File
@@ -0,0 +1,90 @@
import { jwt } from "@elysiajs/jwt";
import { expect, test } from "bun:test";
import { Elysia } from "elysia";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import * as tar from "tar";
import db from "../../src/db/db";
import { download } from "../../src/pages/download";
test("download route blocks symlinks and serves normal files", async () => {
const testUserId = 998;
const testUserEmail = `download-test-${Date.now()}@example.com`;
db.query("INSERT INTO users (id, email, password) VALUES (?, ?, ?)").run(
testUserId,
testUserEmail,
"hashed_pwd",
);
const res = db
.query(
"INSERT INTO jobs (user_id, date_created, status, num_files) VALUES (?, ?, ?, ?) RETURNING id",
)
.get(testUserId, new Date().toISOString(), "completed", 1) as { id: number };
const jobId = String(res.id);
const jobDir = `./data/output/${testUserId}/${jobId}`;
mkdirSync(jobDir, { recursive: true });
writeFileSync(`${jobDir}/normal.txt`, "safe content");
symlinkSync("/etc/passwd", `${jobDir}/symlink.txt`);
try {
const tokenApp = new Elysia()
.use(jwt({ name: "jwt", secret: process.env.JWT_SECRET ?? "secret", exp: "7d" }))
.get("/token", async ({ jwt }) => jwt.sign({ id: String(testUserId) }));
const tokenRes = await tokenApp.handle(new Request("http://localhost/token"));
const token = await tokenRes.text();
const app = new Elysia().use(download);
// 1. Downloading normal file must succeed (200)
const normalRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/normal.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(normalRes.status).toBe(200);
expect(await normalRes.text()).toBe("safe content");
// 2. Downloading a symlink must be blocked (403)
const symlinkRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/symlink.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(symlinkRes.status).toBe(403);
const body = (await symlinkRes.json()) as { message?: string };
expect(body.message).toBe("Access denied.");
// 3. Downloading non-existent file must return 404
const notFoundRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/nonexistent.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(notFoundRes.status).toBe(404);
// 4. Archive tarball must exclude symlinks
const archiveRes = await app.handle(
new Request(`http://localhost/archive/${jobId}`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(archiveRes.status).toBe(200);
const tarEntries: string[] = [];
await tar.t({
file: `${jobDir}/converted_files_${jobId}.tar`,
onReadEntry: (e) => {
tarEntries.push(e.path);
},
});
expect(tarEntries).toContain("./normal.txt");
expect(tarEntries).not.toContain("./symlink.txt");
} finally {
rmSync(`./data/output/${testUserId}`, { recursive: true, force: true });
db.query("DELETE FROM jobs WHERE id = ?").run(jobId);
db.query("DELETE FROM users WHERE id = ?").run(testUserId);
}
});