diff --git a/Dockerfile b/Dockerfile index cfba78b..b920f7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM oven/bun:1.1.24-alpine as base +FROM oven/bun:1.1.25-alpine AS base +LABEL org.opencontainers.image.source="https://github.com/C4illin/ConvertX" WORKDIR /app # install dependencies into temp directory @@ -13,6 +14,12 @@ RUN mkdir -p /temp/prod COPY package.json bun.lockb /temp/prod/ RUN cd /temp/prod && bun install --frozen-lockfile --production +FROM base AS builder +RUN apk --no-cache add curl gcc +ENV PATH=/root/.cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +RUN cargo install resvg + # copy node_modules from temp directory # then copy all (non-ignored) project files into the image # FROM base AS prerelease @@ -40,12 +47,15 @@ RUN apk --no-cache add \ graphicsmagick \ ghostscript \ vips-tools \ + vips-poppler \ + vips-jxl \ libjxl-tools # this might be needed for some latex use cases, will add it if needed. # texmf-dist-fontsextra \ COPY --from=install /temp/prod/node_modules node_modules +COPY --from=builder /root/.cargo/bin/resvg /usr/local/bin/resvg # COPY --from=prerelease /app/src/index.tsx /app/src/ # COPY --from=prerelease /app/package.json . COPY . . diff --git a/README.md b/README.md index 92e8efd..f95ae4e 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ A self-hosted online file converter. Supports 831 different formats. Written wit | Converter | Use case | Converts from | Converts to | |------------------------------------------------------------------------------|---------------|---------------|-------------| | [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 | +| [resvg](https://github.com/RazrFalcon/resvg) | SVG | 1 | 1 | | [Vips](https://github.com/libvips/libvips) | Images | 45 | 23 | -| [XeLaTeX](https://tug.org/xetex/) | Documents | 1 | 1 | +| [XeLaTeX](https://tug.org/xetex/) | LaTeX | 1 | 1 | | [Pandoc](https://pandoc.org/) | Documents | 43 | 65 | | [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 166 | 133 | | [FFmpeg](https://ffmpeg.org/) | Video | ~473 | ~280 | @@ -45,6 +46,7 @@ services: - ACCOUNT_REGISTRATION=false # true or false, doesn't matter for the first account (e.g. keep this to false if you only want one account) - JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() by default - HTTP_ALLOWED=false # setting this to true is unsafe, only set this to true locally + - ALLOW_UNAUTHENTICATED=false # allows anyone to use the service without logging in, only set this to true locally volumes: - convertx:/app/data ``` diff --git a/bun.lockb b/bun.lockb index 9ec4073..03d6652 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/compose.yaml b/compose.yaml index 5b870f5..ef4cf7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,5 +8,6 @@ services: environment: - ACCOUNT_REGISTRATION=true - JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 + - ALLOW_UNAUTHENTICATED=true ports: - 3000:3000 diff --git a/package.json b/package.json index f06e408..c472147 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@elysiajs/html": "1.0.2", "@elysiajs/jwt": "^1.1.0", "@elysiajs/static": "1.0.3", - "elysia": "^1.1.5" + "elysia": "^1.1.7" }, "module": "src/index.tsx", "type": "module", @@ -26,18 +26,18 @@ "devDependencies": { "@biomejs/biome": "1.8.3", "@eslint/compat": "^1.1.1", - "@eslint/js": "^9.8.0", + "@eslint/js": "^9.9.0", "@ianvs/prettier-plugin-sort-imports": "^4.3.1", "@kitajs/ts-html-plugin": "^4.0.2", "@picocss/pico": "^2.0.6", "@total-typescript/ts-reset": "^0.5.1", "@types/bun": "^1.1.6", "@types/eslint": "^9.6.0", - "@types/node": "^22.1.0", - "@typescript-eslint/eslint-plugin": "^8.0.1", - "@typescript-eslint/parser": "^8.0.1", + "@types/node": "^22.5.0", + "@typescript-eslint/eslint-plugin": "^8.2.0", + "@typescript-eslint/parser": "^8.2.0", "cpy-cli": "^5.0.0", - "eslint": "^9.8.0", + "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-eslint-comments": "^3.2.0", @@ -45,11 +45,11 @@ "eslint-plugin-isaacscript": "^3.12.2", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-simple-import-sort": "^12.1.1", - "knip": "^5.27.0", + "knip": "^5.27.3", "npm-run-all2": "^6.2.2", "prettier": "^3.3.3", "typescript": "^5.5.4", - "typescript-eslint": "^8.0.1" + "typescript-eslint": "^8.2.0" }, "trustedDependencies": ["@biomejs/biome"] } diff --git a/src/converters/main.ts b/src/converters/main.ts index 076c03e..6b86fea 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -25,6 +25,11 @@ import { properties as propertiesLibjxl, } from "./libjxl"; +import { + convert as convertresvg, + properties as propertiesresvg, +} from "./resvg"; + import { normalizeFiletype } from "../helpers/normalizeFiletype"; // This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular @@ -59,6 +64,10 @@ const properties: { properties: propertiesLibjxl, converter: convertLibjxl, }, + resvg: { + properties: propertiesresvg, + converter: convertresvg, + }, vips: { properties: propertiesImage, converter: convertImage, diff --git a/src/converters/resvg.ts b/src/converters/resvg.ts new file mode 100644 index 0000000..e971378 --- /dev/null +++ b/src/converters/resvg.ts @@ -0,0 +1,41 @@ +import { exec } from "node:child_process"; + +export const properties = { + from: { + images: ["svg"], + }, + to: { + images: ["png"], + }, +}; + + +export function convert( + filePath: string, + fileType: string, + convertTo: string, + targetPath: string, + // biome-ignore lint/suspicious/noExplicitAny: + options?: any, +): Promise { + return new Promise((resolve, reject) => { + exec( + `resvg "${filePath}" "${targetPath}"`, + (error, stdout, stderr) => { + if (error) { + reject(`error: ${error}`); + } + + if (stdout) { + console.log(`stdout: ${stdout}`); + } + + if (stderr) { + console.error(`stderr: ${stderr}`); + } + + resolve("success"); + }, + ); + }); +} \ No newline at end of file diff --git a/src/converters/vips.ts b/src/converters/vips.ts index 5522d44..09f3d68 100644 --- a/src/converters/vips.ts +++ b/src/converters/vips.ts @@ -113,22 +113,29 @@ export function convert( // .toFormat(convertTo) // .toFile(targetPath); // } + let action = "copy"; + if (fileType === "pdf") { + action = "pdfload"; + } return new Promise((resolve, reject) => { - exec(`vips copy "${filePath}" "${targetPath}"`, (error, stdout, stderr) => { - if (error) { - reject(`error: ${error}`); - } + exec( + `vips ${action} "${filePath}" "${targetPath}"`, + (error, stdout, stderr) => { + if (error) { + reject(`error: ${error}`); + } - if (stdout) { - console.log(`stdout: ${stdout}`); - } + if (stdout) { + console.log(`stdout: ${stdout}`); + } - if (stderr) { - console.error(`stderr: ${stderr}`); - } + if (stderr) { + console.error(`stderr: ${stderr}`); + } - resolve("success"); - }); + resolve("success"); + }, + ); }); } diff --git a/src/helpers/printVersions.ts b/src/helpers/printVersions.ts index a36da86..51f7433 100644 --- a/src/helpers/printVersions.ts +++ b/src/helpers/printVersions.ts @@ -73,6 +73,16 @@ if (process.env.NODE_ENV === "production") { } }); + exec("resvg -V", (error, stdout) => { + if (error) { + console.error("resvg is not installed"); + } + + if (stdout) { + console.log(`resvg v${stdout.split("\n")[0]}`); + } + }); + exec("bun -v", (error, stdout) => { if (error) { console.error("Bun is not installed. wait what"); diff --git a/src/index.tsx b/src/index.tsx index d0ca44c..a48c854 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,12 @@ -import { Database } from "bun:sqlite"; -import { randomUUID } from "node:crypto"; -import { rmSync } from "node:fs"; -import { mkdir, unlink } from "node:fs/promises"; import cookie from "@elysiajs/cookie"; import { html } from "@elysiajs/html"; -import { jwt } from "@elysiajs/jwt"; +import { jwt, type JWTPayloadSpec } from "@elysiajs/jwt"; import { staticPlugin } from "@elysiajs/static"; +import { Database } from "bun:sqlite"; import { Elysia, t } from "elysia"; +import { randomInt, randomUUID } from "node:crypto"; +import { rmSync } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; import { BaseHtml } from "./components/base"; import { Header } from "./components/header"; import { @@ -30,6 +30,8 @@ const ACCOUNT_REGISTRATION = process.env.ACCOUNT_REGISTRATION === "true" || false; const HTTP_ALLOWED = process.env.HTTP_ALLOWED === "true" || false; +const ALLOW_UNAUTHENTICATED = + process.env.ALLOW_UNAUTHENTICATED === "true" || false; // fileNames: fileNames, // filesToConvert: fileNames.length, @@ -403,25 +405,55 @@ const app = new Elysia({ return redirect("/setup", 302); } - if (!auth?.value) { + if (!auth?.value && !ALLOW_UNAUTHENTICATED) { return redirect("/login", 302); } + // validate jwt - const user = await jwt.verify(auth.value); - if (!user) { - return redirect("/login", 302); + let user: ({ id: string } & JWTPayloadSpec) | false = false; + if (auth?.value) { + user = await jwt.verify(auth.value); + + if (user !== false && user.id) { + if (Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED) { + // make sure user exists in db + const existingUser = db + .query("SELECT * FROM users WHERE id = ?") + .as(User) + .get(user.id); + + if (!existingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect("/login", 302); + } + } + } + } else if (ALLOW_UNAUTHENTICATED) { + const newUserId = String(randomInt(2 ** 24, Number.MAX_SAFE_INTEGER)); + const accessToken = await jwt.sign({ + id: newUserId, + }); + + user = { id: newUserId }; + if (!auth) { + return { + message: "No auth cookie, perhaps your browser is blocking cookies.", + }; + } + + // set cookie + auth.set({ + value: accessToken, + httpOnly: true, + secure: !HTTP_ALLOWED, + maxAge: 60 * 60 * 24 * 1, + sameSite: "strict", + }); } - // make sure user exists in db - const existingUser = db - .query("SELECT * FROM users WHERE id = ?") - .as(User) - .get(user.id); - - if (!existingUser) { - if (auth?.value) { - auth.remove(); - } + if (!user) { return redirect("/login", 302); } @@ -473,27 +505,101 @@ const app = new Elysia({ ))} */} -
+
- + + +
+ + + {/* Hidden element which determines the format to convert the file too and the converter to use */} + +
@@ -507,24 +613,82 @@ const app = new Elysia({ "/conversions", ({ body }) => { return ( - + <> + + + + ); }, { body: t.Object({ fileType: t.String() }) }, diff --git a/src/public/script.js b/src/public/script.js index 0838f65..cc14108 100644 --- a/src/public/script.js +++ b/src/public/script.js @@ -3,7 +3,73 @@ const fileInput = document.querySelector('input[type="file"]'); const fileNames = []; let fileType; -const selectContainer = document.querySelector("form > article"); +const selectContainer = document.querySelector("form .select_container"); + +const updateSearchBar = () => { + const convertToInput = document.querySelector( + "input[name='convert_to_search']", + ); + const convertToPopup = document.querySelector(".convert_to_popup"); + const convertToGroupElements = document.querySelectorAll(".convert_to_group"); + const convertToGroups = {}; + const convertToElement = document.querySelector("select[name='convert_to']"); + + const showMatching = (search) => { + for (const [targets, groupElement] of Object.values(convertToGroups)) { + let matchingTargetsFound = 0; + for (const target of targets) { + if (target.dataset.target.includes(search)) { + matchingTargetsFound++; + target.hidden = false; + } else { + target.hidden = true; + } + } + + if (matchingTargetsFound === 0) { + groupElement.hidden = true; + } else { + groupElement.hidden = false; + } + } + }; + + for (const groupElement of convertToGroupElements) { + const groupName = groupElement.dataset.converter; + + const targetElements = groupElement.querySelectorAll(".target"); + const targets = Array.from(targetElements); + + for (const target of targets) { + target.onmousedown = () => { + convertToElement.value = target.dataset.value; + convertToInput.value = `${target.dataset.target} using ${target.dataset.converter}`; + showMatching(""); + }; + } + + convertToGroups[groupName] = [targets, groupElement]; + } + + convertToInput.addEventListener("input", (e) => { + showMatching(e.target.value.toLowerCase()); + }); + + convertToInput.addEventListener("blur", (e) => { + // Keep the popup open even when clicking on a target button + // for a split second to allow the click to go through + if (e?.relatedTarget?.classList?.contains("target")) { + convertToPopup.hidden = true; + return; + } + + convertToPopup.hidden = true; + }); + + convertToInput.addEventListener("focus", () => { + convertToPopup.hidden = false; + }); +}; // const convertFromSelect = document.querySelector("select[name='convert_from']"); @@ -49,6 +115,7 @@ fileInput.addEventListener("change", (e) => { .then((res) => res.text()) .then((html) => { selectContainer.innerHTML = html; + updateSearchBar(); }) .catch((err) => console.log(err)); } @@ -123,3 +190,5 @@ formConvert.addEventListener("submit", (e) => { const hiddenInput = document.querySelector("input[name='file_names']"); hiddenInput.value = JSON.stringify(fileNames); }); + +updateSearchBar(); diff --git a/src/public/style.css b/src/public/style.css index 41c1065..cff90bd 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -12,4 +12,48 @@ div.center { display: flex; justify-content: center; align-items: center; -} \ No newline at end of file +} + +@media (max-width: 99999999999px) { + .convert_to_popup { + width: 50vw !important; + height: 50vh; + } +} + +@media (max-width: 850px) { + .convert_to_popup { + width: 60vw !important; + height: 60vh; + } +} + +@media (max-width: 575px) { + .convert_to_popup { + width: 80vw !important; + height: 75vh; + } +} + +@media (max-height: 1000px) { + .convert_to_popup { + height: 40vh; + } +} +@media (max-height: 650px) { + .convert_to_popup { + height: 30vh; + } +} + +@media (max-height: 500px) { + .convert_to_popup { + height: 25vh; + } +} + +@media (max-height: 400px) { + .convert_to_popup { + height: 15vh; + } +}