Merge remote-tracking branch 'origin/main' into sync-branch

This commit is contained in:
Emrik Östling committed 2026-09-12 02:26:21 +02:00
commit 3e87190f4e
19 files changed
+468 -115

No files matched your search

+26 -3
View File
@@ -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}`);
+3 -1
View File
@@ -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}`);
}
+5
View File
@@ -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[][] {
+47
View File
@@ -0,0 +1,47 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
export const properties = {
from: {
document: ["pdf"],
},
to: {
document: ["eps", "ps"],
},
};
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
const args: string[] = [];
if (convertTo === "eps") {
args.push("-eps");
}
args.push(filePath, targetPath);
return new Promise((resolve, reject) => {
execFile("pdftops", args, (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
return;
}
if (stdout) {
console.log(`stdout: ${stdout}`);
}
if (stderr) {
console.error(`stderr: ${stderr}`);
}
resolve("Done");
});
});
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+63
View File
@@ -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,
};
},
});