mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-11-06 05:24:20 +01:00
Merge remote-tracking branch 'origin/main' into sync-branch
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
name: autofix.ci
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: ".bun-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Format code
|
||||
run: |
|
||||
bun run format:eslint || true
|
||||
bun run format:prettier
|
||||
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a
|
||||
@@ -19,12 +19,12 @@ 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
|
||||
with:
|
||||
bun-version: 1.2.2
|
||||
bun-version-file: ".bun-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -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,12 +19,12 @@ 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
|
||||
with:
|
||||
bun-version: 1.2.2
|
||||
bun-version-file: ".bun-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { ExecFileFn } from "./types";
|
||||
import {
|
||||
execFile as execFileOriginal,
|
||||
type ChildProcess,
|
||||
type ExecFileOptions,
|
||||
} from "node:child_process";
|
||||
|
||||
// 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, which is why the shared ExecFileFn type cannot carry it.
|
||||
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB
|
||||
|
||||
type FfmpegExecFile = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => ChildProcess | void;
|
||||
|
||||
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
|
||||
export const properties = {
|
||||
@@ -224,6 +241,7 @@ export const properties = {
|
||||
"lxf",
|
||||
"m15",
|
||||
"m2a",
|
||||
"m2ts",
|
||||
"m4a",
|
||||
"m4b",
|
||||
"m4v",
|
||||
@@ -406,6 +424,7 @@ export const properties = {
|
||||
"tiff",
|
||||
"tmv",
|
||||
"truehd",
|
||||
"ts",
|
||||
"tta",
|
||||
"tty",
|
||||
"txd",
|
||||
@@ -439,6 +458,7 @@ export const properties = {
|
||||
"vivo",
|
||||
"vmd",
|
||||
"vobsub",
|
||||
"vob",
|
||||
"voc",
|
||||
"vpk",
|
||||
"vplayer",
|
||||
@@ -457,6 +477,7 @@ export const properties = {
|
||||
"webp",
|
||||
"webvtt",
|
||||
"wma",
|
||||
"wmv",
|
||||
"wow",
|
||||
"wsaud",
|
||||
"wsd",
|
||||
@@ -589,6 +610,7 @@ export const properties = {
|
||||
"m4v",
|
||||
"mjpeg",
|
||||
"mjpg",
|
||||
"mka",
|
||||
"mkv",
|
||||
"mlp",
|
||||
"mmf",
|
||||
@@ -694,7 +716,7 @@ export async function convert(
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||
execFile: FfmpegExecFile = execFileOriginal as FfmpegExecFile, // to make it mockable
|
||||
): Promise<string> {
|
||||
let extraArgs: string[] = [];
|
||||
let message = "Done";
|
||||
@@ -739,6 +761,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}`);
|
||||
|
||||
@@ -317,8 +317,10 @@ export function convert(
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal, // 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}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { convert as convertLibjxl, properties as propertiesLibjxl } from "./libj
|
||||
import { convert as convertLibreOffice, properties as propertiesLibreOffice } from "./libreoffice";
|
||||
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 { convert as convertImage, properties as propertiesImage } from "./vips";
|
||||
@@ -140,6 +141,10 @@ const properties: Record<
|
||||
properties: propertiesMarkitdown,
|
||||
converter: convertMarkitdown,
|
||||
},
|
||||
pdftops: {
|
||||
properties: propertiesPdftops,
|
||||
converter: convertPdftops,
|
||||
},
|
||||
};
|
||||
|
||||
function chunks<T>(arr: T[], size: number): T[][] {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function isHtmlPageRequest(request: Request): boolean {
|
||||
const acceptsHtml = request.headers.get("accept")?.toLowerCase().includes("text/html");
|
||||
return (request.method === "GET" || request.method === "HEAD") && Boolean(acceptsHtml);
|
||||
}
|
||||
+12
-5
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { Elysia } from "elysia";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { outputDir } from "..";
|
||||
import db from "../db/db";
|
||||
@@ -10,8 +10,8 @@ import { userService } from "./user";
|
||||
export const download = new Elysia()
|
||||
.use(userService)
|
||||
.get(
|
||||
"/download/:userId/:jobId/*",
|
||||
async ({ params, redirect, user }) => {
|
||||
"/download/:userId/:jobId/:fileName",
|
||||
async ({ params, redirect, set, user }) => {
|
||||
const userId = user.id;
|
||||
const job = await db
|
||||
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
|
||||
@@ -23,11 +23,18 @@ export const download = new Elysia()
|
||||
// parse from URL encoded string
|
||||
const jobId = decodeURIComponent(params.jobId);
|
||||
const jobPath = `${outputDir}${userId}/${jobId}/`;
|
||||
const filePath = `${jobPath}${decodeURIComponent(params["*"])}`;
|
||||
const filePath = `${jobPath}${decodeURIComponent(params.fileName)}`;
|
||||
if (!isSafePath(jobPath, filePath)) {
|
||||
throw new Error("Unsafe filename");
|
||||
}
|
||||
return Bun.file(filePath);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
set.status = 404;
|
||||
return { message: "Converted file not found." };
|
||||
}
|
||||
|
||||
return file;
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
|
||||
+55
-43
@@ -1,15 +1,15 @@
|
||||
import { Elysia } from "elysia";
|
||||
import mime from "mime";
|
||||
import { BaseHtml } from "../components/base";
|
||||
import { Header } from "../components/header";
|
||||
import db from "../db/db";
|
||||
import { Filename, Jobs } from "../db/types";
|
||||
import { buildDownloadUrl } from "../helpers/buildDownloadUrl";
|
||||
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
|
||||
import { DownloadIcon } from "../icons/download";
|
||||
import { DeleteIcon } from "../icons/delete";
|
||||
import { DownloadIcon } from "../icons/download";
|
||||
import { EyeIcon } from "../icons/eye";
|
||||
import { ShareIcon } from "../icons/share";
|
||||
import mime from "mime";
|
||||
import { userService } from "./user";
|
||||
|
||||
function ResultsArticle({
|
||||
@@ -98,47 +98,59 @@ 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>
|
||||
<button
|
||||
class={`
|
||||
share-btn text-accent-500 underline
|
||||
hover:text-accent-400
|
||||
`}
|
||||
data-filename={file.output_file_name}
|
||||
data-download-url={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
|
||||
data-mime-type={mime.getType(file.output_file_name) || "application/octet-stream"}
|
||||
style="display: none;"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
</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>
|
||||
<button
|
||||
class={`
|
||||
share-btn text-accent-500 underline
|
||||
hover:text-accent-400
|
||||
`}
|
||||
data-filename={file.output_file_name}
|
||||
data-download-url={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
|
||||
data-mime-type={mime.getType(file.output_file_name) || "application/octet-stream"}
|
||||
style="display: none;"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
+4
-50
@@ -1,5 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { BaseHtml } from "../components/base";
|
||||
import { Header } from "../components/header";
|
||||
@@ -12,57 +10,12 @@ import {
|
||||
HTTP_ALLOWED,
|
||||
WEBROOT,
|
||||
} from "../helpers/env";
|
||||
import { userService } from "../services/user";
|
||||
|
||||
export { userService } from "../services/user";
|
||||
|
||||
export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
|
||||
|
||||
export const userService = new Elysia({ name: "user/service" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
schema: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
secret: process.env.JWT_SECRET ?? randomUUID(),
|
||||
exp: "7d",
|
||||
}),
|
||||
)
|
||||
.model({
|
||||
signIn: t.Object({
|
||||
email: t.String(),
|
||||
password: t.String(),
|
||||
}),
|
||||
session: t.Cookie({
|
||||
auth: t.String(),
|
||||
jobId: t.Optional(t.String()),
|
||||
}),
|
||||
optionalSession: t.Cookie({
|
||||
auth: t.Optional(t.String()),
|
||||
jobId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.macro("auth", {
|
||||
cookie: "session",
|
||||
async resolve({ status, jwt, cookie: { auth } }) {
|
||||
if (!auth.value) {
|
||||
return status(401, {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
});
|
||||
}
|
||||
const user = await jwt.verify(auth.value);
|
||||
if (!user) {
|
||||
return status(401, {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
});
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const user = new Elysia()
|
||||
.use(userService)
|
||||
.get("/setup", ({ redirect }) => {
|
||||
@@ -279,6 +232,7 @@ export const user = new Elysia()
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { WEBROOT } from "../helpers/env";
|
||||
import { isHtmlPageRequest } from "../helpers/isHtmlPageRequest";
|
||||
|
||||
export const userService = new Elysia({ name: "user/service" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
schema: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
secret: process.env.JWT_SECRET ?? randomUUID(),
|
||||
exp: "7d",
|
||||
}),
|
||||
)
|
||||
.model({
|
||||
signIn: t.Object({
|
||||
email: t.String(),
|
||||
password: t.String(),
|
||||
}),
|
||||
session: t.Cookie({
|
||||
auth: t.String(),
|
||||
jobId: t.Optional(t.String()),
|
||||
}),
|
||||
optionalSession: t.Cookie({
|
||||
auth: t.Optional(t.String()),
|
||||
jobId: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.macro("auth", {
|
||||
cookie: "optionalSession",
|
||||
async resolve({ request, set, status, jwt, cookie: { auth } }) {
|
||||
const unauthorized = () => {
|
||||
if (isHtmlPageRequest(request)) {
|
||||
set.headers.location = `${WEBROOT}/login`;
|
||||
return status(302, {
|
||||
success: false,
|
||||
message: "Redirecting to login",
|
||||
});
|
||||
}
|
||||
|
||||
return status(401, {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
});
|
||||
};
|
||||
|
||||
if (!auth.value) {
|
||||
return unauthorized();
|
||||
}
|
||||
const user = await jwt.verify(auth.value);
|
||||
if (!user) {
|
||||
auth.remove();
|
||||
return unauthorized();
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Elysia } from "elysia";
|
||||
import { isHtmlPageRequest } from "../../src/helpers/isHtmlPageRequest";
|
||||
import { userService } from "../../src/services/user";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(userService)
|
||||
.get("/protected", () => "protected", { auth: true })
|
||||
.post("/protected", () => "protected", { auth: true });
|
||||
|
||||
test("identifies HTML page navigation requests", () => {
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
headers: { accept: "TEXT/HTML" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
method: "HEAD",
|
||||
headers: { accept: "text/html,application/xhtml+xml" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not identify API requests as HTML page navigation", () => {
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
method: "POST",
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
headers: { accept: "application/json" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isHtmlPageRequest(
|
||||
new Request("http://localhost/protected", {
|
||||
headers: { accept: "*/*" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("redirects unauthorized HTML requests to login", async () => {
|
||||
const response = await app.handle(
|
||||
new Request("http://localhost/protected", {
|
||||
headers: { accept: "text/html" },
|
||||
redirect: "manual",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("returns JSON 401 for unauthorized API requests", async () => {
|
||||
const response = await app.handle(
|
||||
new Request("http://localhost/protected", {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
|
||||
});
|
||||
Reference in New Issue
Block a user