t push origin main Merge branch 'luis-c465-searchable-formats'

This commit is contained in:
C4illin 2024-08-26 15:57:48 +02:00
commit 11fcbc3f96
12 changed files with 437 additions and 80 deletions

View file

@ -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 WORKDIR /app
# install dependencies into temp directory # install dependencies into temp directory
@ -13,6 +14,12 @@ RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/ COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production 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 # copy node_modules from temp directory
# then copy all (non-ignored) project files into the image # then copy all (non-ignored) project files into the image
# FROM base AS prerelease # FROM base AS prerelease
@ -40,12 +47,15 @@ RUN apk --no-cache add \
graphicsmagick \ graphicsmagick \
ghostscript \ ghostscript \
vips-tools \ vips-tools \
vips-poppler \
vips-jxl \
libjxl-tools libjxl-tools
# this might be needed for some latex use cases, will add it if needed. # this might be needed for some latex use cases, will add it if needed.
# texmf-dist-fontsextra \ # texmf-dist-fontsextra \
COPY --from=install /temp/prod/node_modules node_modules 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/src/index.tsx /app/src/
# COPY --from=prerelease /app/package.json . # COPY --from=prerelease /app/package.json .
COPY . . COPY . .

View file

@ -20,8 +20,9 @@ A self-hosted online file converter. Supports 831 different formats. Written wit
| Converter | Use case | Converts from | Converts to | | Converter | Use case | Converts from | Converts to |
|------------------------------------------------------------------------------|---------------|---------------|-------------| |------------------------------------------------------------------------------|---------------|---------------|-------------|
| [libjxl](https://github.com/libjxl/libjxl) | JPEG XL | 11 | 11 | | [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 | | [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 | | [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 166 | 133 | | [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 166 | 133 |
| [FFmpeg](https://ffmpeg.org/) | Video | ~473 | ~280 | | [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) - 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 - JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() by default
- HTTP_ALLOWED=false # setting this to true is unsafe, only set this to true locally - 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: volumes:
- convertx:/app/data - convertx:/app/data
``` ```

BIN
bun.lockb

Binary file not shown.

View file

@ -8,5 +8,6 @@ services:
environment: environment:
- ACCOUNT_REGISTRATION=true - ACCOUNT_REGISTRATION=true
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 - JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234
- ALLOW_UNAUTHENTICATED=true
ports: ports:
- 3000:3000 - 3000:3000

View file

@ -16,7 +16,7 @@
"@elysiajs/html": "1.0.2", "@elysiajs/html": "1.0.2",
"@elysiajs/jwt": "^1.1.0", "@elysiajs/jwt": "^1.1.0",
"@elysiajs/static": "1.0.3", "@elysiajs/static": "1.0.3",
"elysia": "^1.1.5" "elysia": "^1.1.7"
}, },
"module": "src/index.tsx", "module": "src/index.tsx",
"type": "module", "type": "module",
@ -26,18 +26,18 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.8.3",
"@eslint/compat": "^1.1.1", "@eslint/compat": "^1.1.1",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.9.0",
"@ianvs/prettier-plugin-sort-imports": "^4.3.1", "@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@kitajs/ts-html-plugin": "^4.0.2", "@kitajs/ts-html-plugin": "^4.0.2",
"@picocss/pico": "^2.0.6", "@picocss/pico": "^2.0.6",
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",
"@types/bun": "^1.1.6", "@types/bun": "^1.1.6",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/node": "^22.1.0", "@types/node": "^22.5.0",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.2.0",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.2.0",
"cpy-cli": "^5.0.0", "cpy-cli": "^5.0.0",
"eslint": "^9.8.0", "eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-eslint-comments": "^3.2.0",
@ -45,11 +45,11 @@
"eslint-plugin-isaacscript": "^3.12.2", "eslint-plugin-isaacscript": "^3.12.2",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"knip": "^5.27.0", "knip": "^5.27.3",
"npm-run-all2": "^6.2.2", "npm-run-all2": "^6.2.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.0.1" "typescript-eslint": "^8.2.0"
}, },
"trustedDependencies": ["@biomejs/biome"] "trustedDependencies": ["@biomejs/biome"]
} }

View file

@ -25,6 +25,11 @@ import {
properties as propertiesLibjxl, properties as propertiesLibjxl,
} from "./libjxl"; } from "./libjxl";
import {
convert as convertresvg,
properties as propertiesresvg,
} from "./resvg";
import { normalizeFiletype } from "../helpers/normalizeFiletype"; 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 // 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, properties: propertiesLibjxl,
converter: convertLibjxl, converter: convertLibjxl,
}, },
resvg: {
properties: propertiesresvg,
converter: convertresvg,
},
vips: { vips: {
properties: propertiesImage, properties: propertiesImage,
converter: convertImage, converter: convertImage,

41
src/converters/resvg.ts Normal file
View file

@ -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: <explanation>
options?: any,
): Promise<string> {
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");
},
);
});
}

View file

@ -113,22 +113,29 @@ export function convert(
// .toFormat(convertTo) // .toFormat(convertTo)
// .toFile(targetPath); // .toFile(targetPath);
// } // }
let action = "copy";
if (fileType === "pdf") {
action = "pdfload";
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(`vips copy "${filePath}" "${targetPath}"`, (error, stdout, stderr) => { exec(
if (error) { `vips ${action} "${filePath}" "${targetPath}"`,
reject(`error: ${error}`); (error, stdout, stderr) => {
} if (error) {
reject(`error: ${error}`);
}
if (stdout) { if (stdout) {
console.log(`stdout: ${stdout}`); console.log(`stdout: ${stdout}`);
} }
if (stderr) { if (stderr) {
console.error(`stderr: ${stderr}`); console.error(`stderr: ${stderr}`);
} }
resolve("success"); resolve("success");
}); },
);
}); });
} }

View file

@ -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) => { exec("bun -v", (error, stdout) => {
if (error) { if (error) {
console.error("Bun is not installed. wait what"); console.error("Bun is not installed. wait what");

View file

@ -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 cookie from "@elysiajs/cookie";
import { html } from "@elysiajs/html"; import { html } from "@elysiajs/html";
import { jwt } from "@elysiajs/jwt"; import { jwt, type JWTPayloadSpec } from "@elysiajs/jwt";
import { staticPlugin } from "@elysiajs/static"; import { staticPlugin } from "@elysiajs/static";
import { Database } from "bun:sqlite";
import { Elysia, t } from "elysia"; 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 { BaseHtml } from "./components/base";
import { Header } from "./components/header"; import { Header } from "./components/header";
import { import {
@ -30,6 +30,8 @@ const ACCOUNT_REGISTRATION =
process.env.ACCOUNT_REGISTRATION === "true" || false; process.env.ACCOUNT_REGISTRATION === "true" || false;
const HTTP_ALLOWED = process.env.HTTP_ALLOWED === "true" || false; const HTTP_ALLOWED = process.env.HTTP_ALLOWED === "true" || false;
const ALLOW_UNAUTHENTICATED =
process.env.ALLOW_UNAUTHENTICATED === "true" || false;
// fileNames: fileNames, // fileNames: fileNames,
// filesToConvert: fileNames.length, // filesToConvert: fileNames.length,
@ -403,25 +405,55 @@ const app = new Elysia({
return redirect("/setup", 302); return redirect("/setup", 302);
} }
if (!auth?.value) { if (!auth?.value && !ALLOW_UNAUTHENTICATED) {
return redirect("/login", 302); return redirect("/login", 302);
} }
// validate jwt // validate jwt
const user = await jwt.verify(auth.value); let user: ({ id: string } & JWTPayloadSpec) | false = false;
if (!user) { if (auth?.value) {
return redirect("/login", 302); 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 if (!user) {
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); return redirect("/login", 302);
} }
@ -473,27 +505,101 @@ const app = new Elysia({
))} ))}
</select> */} </select> */}
</article> </article>
<form method="post" action="/convert"> <form
method="post"
action="/convert"
style={{ position: "relative" }}>
<input type="hidden" name="file_names" id="file_names" /> <input type="hidden" name="file_names" id="file_names" />
<article> <article>
<select name="convert_to" aria-label="Convert to" required> <input
<option selected disabled value=""> type="search"
Convert to name="convert_to_search"
</option> placeholder="Search for conversions"
{Object.entries(getAllTargets()).map( autocomplete="off"
([converter, targets]) => ( />
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<optgroup label={converter}> <div class="select_container">
{targets.map((target) => ( <article
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation> class="convert_to_popup"
<option value={`${target},${converter}`} safe> hidden
{target} style={{
</option> flexDirection: "column",
))} display: "flex",
</optgroup> zIndex: 2,
), position: "absolute",
)} maxHeight: "50vh",
</select> width: "90vw",
overflowY: "scroll",
margin: "0px",
overflowX: "hidden",
}}>
{Object.entries(getAllTargets()).map(
([converter, targets]) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<article
class="convert_to_group"
data-converter={converter}
style={{
borderColor: "gray",
padding: "2px",
}}>
<header
style={{ fontSize: "20px", fontWeight: "bold" }}>
{converter}
</header>
<ul
class="convert_to_target"
style={{
display: "flex",
flexDirection: "row",
gap: "5px",
flexWrap: "wrap",
}}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<button
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
tabindex={0}
class="target"
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
style={{ fontSize: "15px", padding: "5px" }}
type="button">
{target}
</button>
))}
</ul>
</article>
),
)}
</article>
{/* Hidden element which determines the format to convert the file too and the converter to use */}
<select
name="convert_to"
aria-label="Convert to"
required
hidden>
<option selected disabled value="">
Convert to
</option>
{Object.entries(getAllTargets()).map(
([converter, targets]) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<optgroup label={converter}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
),
)}
</select>
</div>
</article> </article>
<input type="submit" value="Convert" /> <input type="submit" value="Convert" />
</form> </form>
@ -507,24 +613,82 @@ const app = new Elysia({
"/conversions", "/conversions",
({ body }) => { ({ body }) => {
return ( return (
<select name="convert_to" aria-label="Convert to" required> <>
<option selected disabled value=""> <article
Convert to class="convert_to_popup"
</option> hidden
{Object.entries(getPossibleTargets(body.fileType)).map( style={{
([converter, targets]) => ( flexDirection: "column",
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation> display: "flex",
<optgroup label={converter}> zIndex: 2,
{targets.map((target) => ( position: "absolute",
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation> maxHeight: "50vh",
<option value={`${target},${converter}`} safe> width: "90vw",
{target} overflowY: "scroll",
</option> margin: "0px",
))} overflowX: "hidden",
</optgroup> }}>
), {Object.entries(getPossibleTargets(body.fileType)).map(
)} ([converter, targets]) => (
</select> // biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<article
class="convert_to_group"
data-converter={converter}
style={{
borderColor: "gray",
padding: "2px",
}}>
<header style={{ fontSize: "20px", fontWeight: "bold" }}>
{converter}
</header>
<ul
class="convert_to_target"
style={{
display: "flex",
flexDirection: "row",
gap: "5px",
flexWrap: "wrap",
}}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<button
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
tabindex={0}
class="target"
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
style={{ fontSize: "15px", padding: "5px" }}
type="button">
{target}
</button>
))}
</ul>
</article>
),
)}
</article>
<select name="convert_to" aria-label="Convert to" required hidden>
<option selected disabled value="">
Convert to
</option>
{Object.entries(getPossibleTargets(body.fileType)).map(
([converter, targets]) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<optgroup label={converter}>
{targets.map((target) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
),
)}
</select>
</>
); );
}, },
{ body: t.Object({ fileType: t.String() }) }, { body: t.Object({ fileType: t.String() }) },

View file

@ -3,7 +3,73 @@ const fileInput = document.querySelector('input[type="file"]');
const fileNames = []; const fileNames = [];
let fileType; 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']"); // const convertFromSelect = document.querySelector("select[name='convert_from']");
@ -49,6 +115,7 @@ fileInput.addEventListener("change", (e) => {
.then((res) => res.text()) .then((res) => res.text())
.then((html) => { .then((html) => {
selectContainer.innerHTML = html; selectContainer.innerHTML = html;
updateSearchBar();
}) })
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} }
@ -123,3 +190,5 @@ formConvert.addEventListener("submit", (e) => {
const hiddenInput = document.querySelector("input[name='file_names']"); const hiddenInput = document.querySelector("input[name='file_names']");
hiddenInput.value = JSON.stringify(fileNames); hiddenInput.value = JSON.stringify(fileNames);
}); });
updateSearchBar();

View file

@ -12,4 +12,48 @@ div.center {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
@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;
}
}