From acee10f8df2a9a8cb8d33bbe297df9396419d88a Mon Sep 17 00:00:00 2001 From: Justin Shetty Date: Tue, 1 Sep 2026 02:11:14 -0700 Subject: [PATCH 01/12] feat(auth): autofocus login email field (#627) --- src/pages/user.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 0fd90651..d6823eb3 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -279,6 +279,7 @@ export const user = new Elysia() class="rounded-sm bg-neutral-800 p-3" placeholder="Email" autocomplete="email" + autofocus required /> From ce8637307ce2fc1826c8e3f302a6ca995c965ff1 Mon Sep 17 00:00:00 2001 From: JDubb Date: Thu, 3 Sep 2026 11:40:28 -0700 Subject: [PATCH 02/12] fix(ffmpeg): raise execFile maxBuffer so long conversions don't overflow stderr (#629) --- src/converters/ffmpeg.ts | 24 +++++++++++++++++++++--- tests/converters/ffmpeg.test.ts | 16 ++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 8f263851..47451d42 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -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 = { @@ -694,7 +711,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 { let extraArgs: string[] = []; let message = "Done"; @@ -739,6 +756,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}`); diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index 6f0afc60..bd845c31 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -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); +}); From e94d037a619d501258a8b4330a80dceffc5247c5 Mon Sep 17 00:00:00 2001 From: Justin Shetty Date: Thu, 3 Sep 2026 11:41:57 -0700 Subject: [PATCH 03/12] fix(results): hide actions for failed conversions (#625) --- src/pages/download.tsx | 10 ++++-- src/pages/results.tsx | 70 +++++++++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/pages/download.tsx b/src/pages/download.tsx index f10fec46..ffc64e9b 100644 --- a/src/pages/download.tsx +++ b/src/pages/download.tsx @@ -11,7 +11,7 @@ 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 = ?") @@ -25,7 +25,13 @@ export const download = new Elysia() const fileName = sanitize(decodeURIComponent(params.fileName)); const filePath = `${outputDir}${userId}/${jobId}/${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, diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 64dd2211..a9809f69 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -96,35 +96,47 @@ function ResultsArticle({ - {files.map((file) => ( - - - {file.output_file_name} - - {file.status} - - - - - - - - - - ))} + {files.map((file) => { + const conversionFailed = ["Failed, check logs", "File type not supported"].includes( + file.status, + ); + + return ( + + + {file.output_file_name} + + {file.status} + + {conversionFailed ? ( + Unavailable + ) : ( + <> + + + + + + + + )} + + + ); + })} From 5c96086d4eb37d4909d214170b62ae50df5afdd1 Mon Sep 17 00:00:00 2001 From: Orban Gergo Date: Wed, 9 Sep 2026 19:16:50 +0300 Subject: [PATCH 04/12] feat(ffmpeg): added m2ts, ts, vob, wmv input types, and mka output (#636) --- src/converters/ffmpeg.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 47451d42..0a118356 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -241,6 +241,7 @@ export const properties = { "lxf", "m15", "m2a", + "m2ts", "m4a", "m4b", "m4v", @@ -423,6 +424,7 @@ export const properties = { "tiff", "tmv", "truehd", + "ts", "tta", "tty", "txd", @@ -456,6 +458,7 @@ export const properties = { "vivo", "vmd", "vobsub", + "vob", "voc", "vpk", "vplayer", @@ -474,6 +477,7 @@ export const properties = { "webp", "webvtt", "wma", + "wmv", "wow", "wsaud", "wsd", @@ -606,6 +610,7 @@ export const properties = { "m4v", "mjpeg", "mjpg", + "mka", "mkv", "mlp", "mmf", From 57ebd7f4546982ec71406b5475a4ca2c5b07728d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:30:30 +0200 Subject: [PATCH 05/12] chore(deps): update actions/checkout action to v7 (#639) --- .github/workflows/check-lint.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/dockerhub-description.yml | 2 +- .github/workflows/run-bun-test.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-lint.yml b/.github/workflows/check-lint.yml index ea58ceba..99a59a64 100644 --- a/.github/workflows/check-lint.yml +++ b/.github/workflows/check-lint.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cbe3de04..f05ab6ac 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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: | diff --git a/.github/workflows/dockerhub-description.yml b/.github/workflows/dockerhub-description.yml index c24b32e6..ab312c82 100644 --- a/.github/workflows/dockerhub-description.yml +++ b/.github/workflows/dockerhub-description.yml @@ -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 diff --git a/.github/workflows/run-bun-test.yml b/.github/workflows/run-bun-test.yml index caf8e2d2..09849c8f 100644 --- a/.github/workflows/run-bun-test.yml +++ b/.github/workflows/run-bun-test.yml @@ -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 From 878876ca17ecc0fcfabc98f9806c36f57b1a38a5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:30:39 +0200 Subject: [PATCH 06/12] chore(deps): update docker/build-push-action action to v7 (#640) --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f05ab6ac..26fafc56 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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: From 0dea046c20308f6c08a6c122c98c8737906b1727 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:30:49 +0200 Subject: [PATCH 07/12] chore(deps): update docker/setup-buildx-action action to v4 (#642) --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 26fafc56..b28f4fb1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -62,7 +62,7 @@ jobs: 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 }} @@ -137,7 +137,7 @@ jobs: ${{ 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 From edb21f867b6f195301f0dc2c4e3d0ac8f38c15c3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:31:01 +0200 Subject: [PATCH 08/12] chore(deps): update docker/metadata-action action to v6 (#641) --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b28f4fb1..e56d7c1a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -57,7 +57,7 @@ jobs: - name: Docker meta default id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ghcr.io/${{ env.REPO }} @@ -130,7 +130,7 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ghcr.io/${{ env.REPO }} From 2ea8e00f1ecbdb781495eb63276159897dfbc3ab Mon Sep 17 00:00:00 2001 From: JDubb Date: Wed, 9 Sep 2026 13:34:44 -0700 Subject: [PATCH 09/12] fix(graphicsmagick): apply EXIF auto-orient so portrait images don't convert sideways (#590) (#637) --- src/converters/graphicsmagick.ts | 4 +++- tests/converters/graphicsmagick.test.ts | 29 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/converters/graphicsmagick.ts b/src/converters/graphicsmagick.ts index 4af168e5..a51a2947 100644 --- a/src/converters/graphicsmagick.ts +++ b/src/converters/graphicsmagick.ts @@ -317,8 +317,10 @@ export function convert( options?: unknown, execFile: ExecFileFn = execFileOriginal, // to make it mockable ): Promise { + // 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}`); } diff --git a/tests/converters/graphicsmagick.test.ts b/tests/converters/graphicsmagick.test.ts index 81fbfe4a..69080057 100644 --- a/tests/converters/graphicsmagick.test.ts +++ b/tests/converters/graphicsmagick.test.ts @@ -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"]); +}); From b9ee1f664e62c751b9756f77d1d963051346538b Mon Sep 17 00:00:00 2001 From: Orban Gergo Date: Thu, 10 Sep 2026 00:48:34 +0300 Subject: [PATCH 10/12] feat(pdftops): added pdftops converter (#611) --- README.md | 1 + src/converters/main.ts | 5 +++ src/converters/pdftops.ts | 47 +++++++++++++++++++ tests/converters/pdftops.test.ts | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 src/converters/pdftops.ts create mode 100644 tests/converters/pdftops.test.ts diff --git a/README.md b/README.md index 86eb72be..96511b7e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/converters/main.ts b/src/converters/main.ts index 23839d91..6980cc69 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -18,6 +18,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"; @@ -137,6 +138,10 @@ const properties: Record< properties: propertiesMarkitdown, converter: convertMarkitdown, }, + pdftops: { + properties: propertiesPdftops, + converter: convertPdftops, + }, }; function chunks(arr: T[], size: number): T[][] { diff --git a/src/converters/pdftops.ts b/src/converters/pdftops.ts new file mode 100644 index 00000000..8ac97669 --- /dev/null +++ b/src/converters/pdftops.ts @@ -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 { + 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"); + }); + }); +} diff --git a/tests/converters/pdftops.test.ts b/tests/converters/pdftops.test.ts new file mode 100644 index 00000000..ca6b525e --- /dev/null +++ b/tests/converters/pdftops.test.ts @@ -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"); +}); From 9d1c0937805b46579d491668c4071971257d089f Mon Sep 17 00:00:00 2001 From: Justin Shetty Date: Fri, 11 Sep 2026 14:30:26 -0700 Subject: [PATCH 11/12] fix(auth): redirect unauthorized page requests (#628) --- src/helpers/isHtmlPageRequest.ts | 4 ++ src/pages/user.tsx | 53 ++----------------- src/services/user.ts | 63 +++++++++++++++++++++++ tests/pages/auth.test.ts | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 50 deletions(-) create mode 100644 src/helpers/isHtmlPageRequest.ts create mode 100644 src/services/user.ts create mode 100644 tests/pages/auth.test.ts diff --git a/src/helpers/isHtmlPageRequest.ts b/src/helpers/isHtmlPageRequest.ts new file mode 100644 index 00000000..67d79f43 --- /dev/null +++ b/src/helpers/isHtmlPageRequest.ts @@ -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); +} diff --git a/src/pages/user.tsx b/src/pages/user.tsx index d6823eb3..5fa9e045 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -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 }) => { diff --git a/src/services/user.ts b/src/services/user.ts new file mode 100644 index 00000000..d71204d8 --- /dev/null +++ b/src/services/user.ts @@ -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, + }; + }, + }); diff --git a/tests/pages/auth.test.ts b/tests/pages/auth.test.ts new file mode 100644 index 00000000..1e97faea --- /dev/null +++ b/tests/pages/auth.test.ts @@ -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" }); +}); From d34c1394497b647be1768fbca54abac8ef09e0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Fri, 11 Sep 2026 23:52:00 +0200 Subject: [PATCH 12/12] ci: add autofix.ci (#644) --- .github/workflows/autofix.yml | 30 ++++++++++++++++++++++++++++++ .github/workflows/check-lint.yml | 2 +- .github/workflows/run-bun-test.yml | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/autofix.yml diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 00000000..4bf4e01e --- /dev/null +++ b/.github/workflows/autofix.yml @@ -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 diff --git a/.github/workflows/check-lint.yml b/.github/workflows/check-lint.yml index 99a59a64..12d1baf4 100644 --- a/.github/workflows/check-lint.yml +++ b/.github/workflows/check-lint.yml @@ -24,7 +24,7 @@ jobs: - 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 diff --git a/.github/workflows/run-bun-test.yml b/.github/workflows/run-bun-test.yml index 09849c8f..dd120706 100644 --- a/.github/workflows/run-bun-test.yml +++ b/.github/workflows/run-bun-test.yml @@ -24,7 +24,7 @@ jobs: - 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