mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-09-13 02:14:32 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 129aae6ac0 | |||
| cf6d730dba | |||
| b9ee1f664e | |||
| b795d669b6 | |||
| 8c31407050 | |||
| d112ef9a1b | |||
| bc5a163fb5 | |||
| 181941717e | |||
| 2ea8e00f1e | |||
| edb21f867b | |||
| 0dea046c20 | |||
| 878876ca17 | |||
| 57ebd7f454 | |||
| 5c96086d4e | |||
| e94d037a61 | |||
| ce8637307c | |||
| acee10f8df |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,3 +50,4 @@ package-lock.json
|
||||
/Bruno
|
||||
/tsconfig.tsbuildinfo
|
||||
/public/generated.css
|
||||
/bin
|
||||
|
||||
+8
-4
@@ -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" ]
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"heif-info",
|
||||
"potrace",
|
||||
"soffice",
|
||||
"perl"
|
||||
"perl",
|
||||
"gcc"
|
||||
],
|
||||
"tailwind": {
|
||||
"entry": ["src/main.css"]
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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}.`,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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/";
|
||||
|
||||
@@ -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
@@ -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 < ?")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -279,6 +279,7 @@ export const user = new Elysia()
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user