refactor: split main file to pages

This commit is contained in:
C4illin 2025-06-02 17:29:13 +02:00
parent 2490c3a7e7
commit 9e759a75de
20 changed files with 1940 additions and 2189 deletions

View file

@ -0,0 +1,75 @@
import Elysia, { t } from "elysia";
import { userService } from "./user";
import { Html } from "@elysiajs/html";
import {
getPossibleTargets,
} from "../converters/main";
export const chooseConverter = new Elysia()
.use(userService)
.post(
"/conversions",
({ body }) => {
return (
<>
<article
class={`
convert_to_popup absolute z-2 m-0 hidden h-[50vh] max-h-[50vh] w-full flex-col
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
sm:h-[30vh]
`}
>
{Object.entries(getPossibleTargets(body.fileType)).map(
([converter, targets]) => (
<article
class="convert_to_group flex w-full flex-col border-b border-neutral-700 p-4"
data-converter={converter}
>
<header class="mb-2 w-full text-xl font-bold" safe>
{converter}
</header>
<ul class="convert_to_target flex flex-row flex-wrap gap-1">
{targets.map((target) => (
<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 rounded bg-neutral-700 p-1 text-base
hover:bg-neutral-600
`}
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
type="button"
safe
>
{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]) => (
<optgroup label={converter}>
{targets.map((target) => (
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
),
)}
</select>
</>
);
},
{ body: t.Object({ fileType: t.String() }) },
)

122
src/pages/convert.tsx Normal file
View file

@ -0,0 +1,122 @@
import { mkdir } from "node:fs/promises";
import { Elysia, t } from "elysia";
import sanitize from "sanitize-filename";
import { Jobs, outputDir, uploadsDir } from "..";
import { mainConverter } from "../converters/main";
import { WEBROOT } from "../helpers/env";
import db from "../db/db";
import {
normalizeFiletype,
normalizeOutputFiletype,
} from "../helpers/normalizeFiletype";
import { userService } from "./user";
export const convert = new Elysia().use(userService).post(
"/convert",
async ({ body, redirect, jwt, cookie: { auth, jobId } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.as(Jobs)
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
const userOutputDir = `${outputDir}${user.id}/${jobId.value}/`;
// create the output directory
try {
await mkdir(userOutputDir, { recursive: true });
} catch (error) {
console.error(
`Failed to create the output directory: ${userOutputDir}.`,
error,
);
}
const convertTo = normalizeFiletype(body.convert_to.split(",")[0] ?? "");
const converterName = body.convert_to.split(",")[1];
const fileNames = JSON.parse(body.file_names) as string[];
for (let i = 0; i < fileNames.length; i++) {
fileNames[i] = sanitize(fileNames[i] || "");
}
if (!Array.isArray(fileNames) || fileNames.length === 0) {
return redirect(`${WEBROOT}/`, 302);
}
db.query(
"UPDATE jobs SET num_files = ?1, status = 'pending' WHERE id = ?2",
).run(fileNames.length, jobId.value);
const query = db.query(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
// Start the conversion process in the background
Promise.all(
fileNames.map(async (fileName) => {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
const newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
const targetPath = `${userOutputDir}${newFileName}`;
const result = await mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
);
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, result);
}
}),
)
.then(() => {
// All conversions are done, update the job status to 'completed'
if (jobId.value) {
db.query("UPDATE jobs SET status = 'completed' WHERE id = ?1").run(
jobId.value,
);
}
// delete all uploaded files in userUploadsDir
// rmSync(userUploadsDir, { recursive: true, force: true });
})
.catch((error) => {
console.error("Error in conversion process:", error);
});
// Redirect the client immediately
return redirect(`${WEBROOT}/results/${jobId.value}`, 302);
},
{
body: t.Object({
convert_to: t.String(),
file_names: t.String(),
}),
},
);

39
src/pages/deleteFile.tsx Normal file
View file

@ -0,0 +1,39 @@
import { Elysia, t } from "elysia";
import { userService } from "./user";
import { unlink } from "node:fs/promises";
import { WEBROOT } from "../helpers/env";
import { uploadsDir } from "..";
import db from "../db/db";
export const deleteFile = new Elysia()
.use(userService)
.post(
"/delete",
async ({ body, redirect, jwt, cookie: { auth, jobId } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = await db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
await unlink(`${userUploadsDir}${body.filename}`);
},
{ body: t.Object({ filename: t.String() }) },
)

66
src/pages/download.tsx Normal file
View file

@ -0,0 +1,66 @@
import { Elysia } from "elysia";
import { userService } from "./user";
import { WEBROOT } from "../helpers/env";
import sanitize from "sanitize-filename";
import { outputDir } from "..";
import db from "../db/db";
export const download = new Elysia()
.use(userService)
.get(
"/download/:userId/:jobId/:fileName",
async ({ params, jwt, redirect, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.get(user.id, params.jobId);
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
// parse from url encoded string
const userId = decodeURIComponent(params.userId);
const jobId = decodeURIComponent(params.jobId);
const fileName = sanitize(decodeURIComponent(params.fileName));
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
return Bun.file(filePath);
},
)
.get(
"/zip/:userId/:jobId",
async ({ params, jwt, redirect, cookie: { auth } }) => {
// TODO: Implement zip download
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.get(user.id, params.jobId);
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
// const userId = decodeURIComponent(params.userId);
// const jobId = decodeURIComponent(params.jobId);
// const outputPath = `${outputDir}${userId}/`{jobId}/);
// return Bun.zip(outputPath);
},
)

242
src/pages/history.tsx Normal file
View file

@ -0,0 +1,242 @@
import { Elysia } from "elysia";
import { BaseHtml } from "../components/base";
import { Html } from "@elysiajs/html";
import { Header } from "../components/header";
import { userService } from "./user";
import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, WEBROOT } from "../helpers/env";
import { Filename, Jobs } from "..";
import db from "../db/db";
export const history = new Elysia()
.use(userService)
.get("/history", async ({ jwt, redirect, cookie: { auth } }) => {
if (HIDE_HISTORY) {
return redirect(`${WEBROOT}/`, 302);
}
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
let userJobs = db
.query("SELECT * FROM jobs WHERE user_id = ?")
.as(Jobs)
.all(user.id)
.reverse();
for (const job of userJobs) {
const files = db
.query("SELECT * FROM file_names WHERE job_id = ?")
.as(Filename)
.all(job.id);
job.finished_files = files.length;
job.files_detailed = files;
}
// filter out jobs with no files
userJobs = userJobs.filter((job) => job.num_files > 0);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Results">
<>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Results</h1>
<table
class={`
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
<span class="sr-only">Expand details</span>
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Time
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Files
</th>
<th
class={`
px-2 py-2
max-sm:hidden
sm:px-4
`}
>
Files Done
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
View
</th>
</tr>
</thead>
<tbody>
{userJobs.map((job) => (
<>
<tr id={`job-row-${job.id}`}>
<td
class="job-details-toggle cursor-pointer"
data-job-id={job.id}
>
<svg
id={`arrow-${job.id}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="inline-block h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</td>
<td safe>
{new Date(job.date_created).toLocaleTimeString()}
</td>
<td>{job.num_files}</td>
<td class="max-sm:hidden">{job.finished_files}</td>
<td safe>{job.status}</td>
<td>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/results/${job.id}`}
>
View
</a>
</td>
</tr>
<tr id={`details-${job.id}`} class="hidden">
<td colspan="6">
<div class="p-2 text-sm text-neutral-500">
<div class="mb-1 font-semibold">
Detailed File Information:
</div>
{job.files_detailed.map(
(file: Filename) => (
<div
class="flex items-center"
>
<span
class="w-5/12 truncate"
title={file.file_name}
safe
>
{file.file_name}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="mx-2 inline-block h-4 w-4 text-neutral-500"
>
<path
fill-rule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span
class="w-5/12 truncate"
title={file.output_file_name}
safe
>
{file.output_file_name}
</span>
</div>
),
)}
</div>
</td>
</tr>
</>
))}
</tbody>
</table>
</article>
</main>
<script>
{`
document.addEventListener('DOMContentLoaded', () => {
const toggles = document.querySelectorAll('.job-details-toggle');
toggles.forEach(toggle => {
toggle.addEventListener('click', function() {
const jobId = this.dataset.jobId;
const detailsRow = document.getElementById(\`details-\${jobId}\`);
// The arrow SVG itself has the ID arrow-\${jobId}
const arrow = document.getElementById(\`arrow-\${jobId}\`);
if (detailsRow && arrow) {
detailsRow.classList.toggle("hidden");
if (detailsRow.classList.contains("hidden")) {
// Right-facing arrow (collapsed)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />';
} else {
// Down-facing arrow (expanded)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />';
}
}
});
});
});
`}
</script>
</>
</BaseHtml>
);
})

View file

@ -0,0 +1,89 @@
import Elysia from "elysia";
import { userService } from "./user";
import { Html } from "@elysiajs/html";
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import {
getAllInputs,
getAllTargets,
} from "../converters/main";
export const listConverters = new Elysia()
.use(userService)
.get("/converters", async ({ jwt, redirect, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Converters">
<>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Converters</h1>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
[&_ul]:list-inside [&_ul]:list-disc
`}
>
<thead>
<tr>
<th class="mx-4 my-2">Converter</th>
<th class="mx-4 my-2">From (Count)</th>
<th class="mx-4 my-2">To (Count)</th>
</tr>
</thead>
<tbody>
{Object.entries(getAllTargets()).map(
([converter, targets]) => {
const inputs = getAllInputs(converter);
return (
<tr>
<td safe>{converter}</td>
<td>
Count: {inputs.length}
<ul>
{inputs.map((input) => (
<li safe>{input}</li>
))}
</ul>
</td>
<td>
Count: {targets.length}
<ul>
{targets.map((target) => (
<li safe>{target}</li>
))}
</ul>
</td>
</tr>
);
},
)}
</tbody>
</table>
</article>
</main>
</>
</BaseHtml>
);
})

324
src/pages/results.tsx Normal file
View file

@ -0,0 +1,324 @@
import { Elysia } from "elysia";
import { userService } from "./user";
import { Html } from "@elysiajs/html";
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { Filename, Jobs } from "..";
import { Header } from "../components/header";
import { BaseHtml } from "../components/base";
import db from "../db/db";
export const results = new Elysia()
.use(userService)
.get(
"/results/:jobId",
async ({ params, jwt, set, redirect, cookie: { auth, job_id } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
if (job_id?.value) {
// clear the job_id cookie since we are viewing the results
job_id.remove();
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
}
const outputPath = `${user.id}/${params.jobId}/`;
const files = db
.query("SELECT * FROM file_names WHERE job_id = ?")
.as(Filename)
.all(params.jobId);
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Result">
<>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<div>
<button
type="button"
class="float-right w-40 btn-primary"
onclick="downloadAll()"
{...(files.length !== job.num_files
? { disabled: true, "aria-busy": "true" }
: "")}
>
{files.length === job.num_files
? "Download All"
: "Converting..."}
</button>
</div>
</div>
<progress
max={job.num_files}
value={files.length}
class={`
mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full
border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500
[&::-moz-progress-bar]:bg-neutral-700 [&::-webkit-progress-value]:rounded-full
[&::-webkit-progress-value]:[background:none]
[&[value]::-webkit-progress-value]:bg-accent-500
[&[value]::-webkit-progress-value]:transition-[inline-size]
`}
/>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Converted File Name
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
View
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Download
</th>
</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>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
>
View
</a>
</td>
<td>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
download={file.output_file_name}
>
Download
</a>
</td>
</tr>
))}
</tbody>
</table>
</article>
</main>
<script src={`${WEBROOT}/results.js`} defer />
</>
</BaseHtml>
);
},
)
.post(
"/progress/:jobId",
async ({ jwt, set, params, redirect, cookie: { auth, job_id } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
if (job_id?.value) {
// clear the job_id cookie since we are viewing the results
job_id.remove();
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
}
const outputPath = `${user.id}/${params.jobId}/`;
const files = db
.query("SELECT * FROM file_names WHERE job_id = ?")
.as(Filename)
.all(params.jobId);
return (
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<div>
<button
type="button"
class="float-right w-40 btn-primary"
onclick="downloadAll()"
{...(files.length !== job.num_files
? { disabled: true, "aria-busy": "true" }
: "")}
>
{files.length === job.num_files
? "Download All"
: "Converting..."}
</button>
</div>
</div>
<progress
max={job.num_files}
value={files.length}
class={`
mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full border-0
bg-neutral-700 bg-none text-accent-500 accent-accent-500
[&::-moz-progress-bar]:bg-neutral-700 [&::-webkit-progress-value]:rounded-full
[&::-webkit-progress-value]:[background:none]
[&[value]::-webkit-progress-value]:bg-accent-500
[&[value]::-webkit-progress-value]:transition-[inline-size]
`}
/>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
[&_td]:p-4
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Converted File Name
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
View
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Download
</th>
</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>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
>
View
</a>
</td>
<td>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
download={file.output_file_name}
>
Download
</a>
</td>
</tr>
))}
</tbody>
</table>
</article>
);
},
)

248
src/pages/root.tsx Normal file
View file

@ -0,0 +1,248 @@
import { Elysia } from "elysia";
import { Html } from "@elysiajs/html";
import { FIRST_RUN, User, userService } from "./user";
import { ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED, HIDE_HISTORY, HTTP_ALLOWED, WEBROOT } from "../helpers/env";
import { JWTPayloadSpec } from "@elysiajs/jwt";
import { randomInt } from "node:crypto";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import { getAllTargets } from "../converters/main";
import db from "../db/db";
export const root = new Elysia()
.use(userService)
.get("/", async ({ jwt, redirect, cookie: { auth, jobId } }) => {
if (!ALLOW_UNAUTHENTICATED) {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
}
// validate jwt
let user: ({ id: string } & JWTPayloadSpec) | false = false;
if (ALLOW_UNAUTHENTICATED) {
const newUserId = String(
randomInt(
2 ** 24,
Math.min(2 ** 48 + 2 ** 24 - 1, 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: 24 * 60 * 60,
sameSite: "strict",
});
} else 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(`${WEBROOT}/login`, 302);
}
}
}
}
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
// create a new job
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
user.id,
new Date().toISOString(),
);
const id = (
db
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
.get(user.id) as { id: number }
).id;
if (!jobId) {
return { message: "Cookies should be enabled to use this app." };
}
jobId.set({
value: id,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});
console.log("jobId set to:", id);
return (
<BaseHtml webroot={WEBROOT}>
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Convert</h1>
<div class="mb-4 scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
id="file-list"
class={`
w-full table-auto rounded bg-neutral-900
[&_td]:p-4 [&_td]:first:max-w-[30vw] [&_td]:first:truncate
[&_tr]:rounded-sm [&_tr]:border-b [&_tr]:border-neutral-800
`}
/>
</div>
<div
id="dropzone"
class={`
relative flex h-48 w-full items-center justify-center rounded border border-dashed
border-neutral-700 transition-all
hover:border-neutral-600
[&.dragover]:border-4 [&.dragover]:border-neutral-500
`}
>
<span>
<b>Choose a file</b> or drag it here
</span>
<input
type="file"
name="file"
multiple
class="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</div>
</article>
<form
method="post"
action={`${WEBROOT}/convert`}
class="relative mx-auto mb-[35vh] w-full max-w-4xl"
>
<input type="hidden" name="file_names" id="file_names" />
<article class="article w-full">
<input
type="search"
name="convert_to_search"
placeholder="Search for conversions"
autocomplete="off"
class="w-full rounded-sm bg-neutral-800 p-4"
/>
<div class="select_container relative">
<article
class={`
convert_to_popup absolute z-2 m-0 hidden h-[30vh] max-h-[50vh] w-full flex-col
overflow-x-hidden overflow-y-auto rounded bg-neutral-800
sm:h-[30vh]
`}
>
{Object.entries(getAllTargets()).map(
([converter, targets]) => (
<article
class={`
convert_to_group flex w-full flex-col border-b border-neutral-700 p-4
`}
data-converter={converter}
>
<header class="mb-2 w-full text-xl font-bold" safe>
{converter}
</header>
<ul class="convert_to_target flex flex-row flex-wrap gap-1">
{targets.map((target) => (
<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 rounded bg-neutral-700 p-1 text-base
hover:bg-neutral-600
`}
data-value={`${target},${converter}`}
data-target={target}
data-converter={converter}
type="button"
safe
>
{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]) => (
<optgroup label={converter}>
{targets.map((target) => (
<option value={`${target},${converter}`} safe>
{target}
</option>
))}
</optgroup>
),
)}
</select>
</div>
</article>
<input
class={`
w-full btn-primary opacity-100
disabled:cursor-not-allowed disabled:opacity-50
`}
type="submit"
value="Convert"
disabled
/>
</form>
</main>
<script src="script.js" defer />
</>
</BaseHtml>
);
})

51
src/pages/upload.tsx Normal file
View file

@ -0,0 +1,51 @@
import { Elysia, t } from "elysia";
import { userService } from "./user";
import { WEBROOT } from "../helpers/env";
import { uploadsDir } from "../index";
import db from "../db/db";
export const upload = new Elysia()
.use(userService)
.post(
"/upload",
async ({ body, redirect, jwt, cookie: { auth, jobId } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
if (!jobId?.value) {
return redirect(`${WEBROOT}/`, 302);
}
const existingJob = await db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.get(jobId.value, user.id);
if (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
if (body?.file) {
if (Array.isArray(body.file)) {
for (const file of body.file) {
await Bun.write(`${userUploadsDir}${file.name}`, file);
}
} else {
await Bun.write(`${userUploadsDir}${body.file["name"]}`, body.file);
}
}
return {
message: "Files uploaded successfully.",
};
},
{ body: t.Object({ file: t.Files() }) },
)

550
src/pages/user.tsx Normal file
View file

@ -0,0 +1,550 @@
import { Elysia, t } from "elysia";
import { Html } from "@elysiajs/html";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import { jwt } from "@elysiajs/jwt";
import { randomUUID } from "node:crypto";
import { ACCOUNT_REGISTRATION, WEBROOT, HIDE_HISTORY, ALLOW_UNAUTHENTICATED, HTTP_ALLOWED } from "../helpers/env";
import db from "../db/db";
export class User {
id!: number;
email!: string;
password!: string;
}
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()
}),
})
.macro({
isSignIn(enabled: boolean) {
if (!enabled) return
return {
async beforeHandle({
status,
jwt,
cookie: { auth },
}) {
if (auth?.value) {
const user = await jwt.verify(auth.value);
return {
success: true,
user
}
}
return status(401, {
success: false,
message: 'Unauthorized'
})
}
}
}
})
export const user = new Elysia()
.use(userService)
.get("/setup", ({ redirect }) => {
if (!FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml title="ConvertX | Setup" webroot={WEBROOT}>
<main
class={`
mx-auto w-full max-w-4xl flex-1 px-2
sm:px-4
`}
>
<h1 class="my-8 text-3xl">Welcome to ConvertX!</h1>
<article class="article p-0">
<header class="w-full bg-neutral-800 p-4">
Create your account
</header>
<form method="post" action={`${WEBROOT}/register`} class="p-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input type="submit" value="Create account" class="btn-primary" />
</form>
<footer class="p-4">
Report any issues on{" "}
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href="https://github.com/C4illin/ConvertX"
>
GitHub
</a>
.
</footer>
</article>
</main>
</BaseHtml>
);
})
.get("/register", ({ redirect }) => {
if (!ACCOUNT_REGISTRATION) {
return redirect(`${WEBROOT}/login`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Register">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<input
type="submit"
value="Register"
class="w-full btn-primary"
/>
</form>
</article>
</main>
</>
</BaseHtml>
);
})
.post(
"/register",
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
if (!ACCOUNT_REGISTRATION && !FIRST_RUN) {
return redirect(`${WEBROOT}/login`, 302);
}
if (FIRST_RUN) {
FIRST_RUN = false;
}
const existingUser = await db
.query("SELECT * FROM users WHERE email = ?")
.get(email);
if (existingUser) {
set.status = 400;
return {
message: "Email already in use.",
};
}
const savedPassword = await Bun.password.hash(password);
db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(
email,
savedPassword,
);
const user = db
.query("SELECT * FROM users WHERE email = ?")
.as(User)
.get(email);
if (!user) {
set.status = 500;
return {
message: "Failed to create user.",
};
}
const accessToken = await jwt.sign({
id: String(user.id),
});
if (!auth) {
set.status = 500;
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 * 7,
sameSite: "strict",
});
return redirect(`${WEBROOT}/`, 302);
},
{ body: 'signIn' },
)
.get("/login", async ({ jwt, redirect, cookie: { auth } }) => {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}
// if already logged in, redirect to home
if (auth?.value) {
const user = await jwt.verify(auth.value);
if (user) {
return redirect(`${WEBROOT}/`, 302);
}
auth.remove();
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
{ACCOUNT_REGISTRATION ? (
<a
href={`${WEBROOT}/register`}
role="button"
class="w-full btn-secondary text-center"
>
Register
</a>
) : null}
<input
type="submit"
value="Login"
class="w-full btn-primary"
/>
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
})
.post(
"/login",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
const existingUser = db
.query("SELECT * FROM users WHERE email = ?")
.as(User)
.get(body.email);
if (!existingUser) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const validPassword = await Bun.password.verify(
body.password,
existingUser.password,
);
if (!validPassword) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const accessToken = await jwt.sign({
id: String(existingUser.id),
});
if (!auth) {
set.status = 500;
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 * 7,
sameSite: "strict",
});
return redirect(`${WEBROOT}/`, 302);
},
{ body: 'signIn' },
)
.get("/logoff", ({ redirect, cookie: { auth } }) => {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
})
.post("/logoff", ({ redirect, cookie: { auth } }) => {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
})
.get("/account", async ({ jwt, redirect, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/`);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/`, 302);
}
const userData = db
.query("SELECT * FROM users WHERE id = ?")
.as(User)
.get(user.id);
if (!userData) {
return redirect(`${WEBROOT}/`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<form method="post" class="flex flex-col gap-4">
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="email"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
value={userData.email}
required
/>
</label>
<label class="flex flex-col gap-1">
Password (leave blank for unchanged)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
/>
</label>
<label class="flex flex-col gap-1">
Current Password
<input
type="password"
name="password"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="current-password"
required
/>
</label>
</fieldset>
<div role="group">
<input
type="submit"
value="Update"
class="w-full btn-primary"
/>
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
})
.post(
"/account",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const existingUser = db
.query("SELECT * FROM users WHERE id = ?")
.as(User)
.get(user.id);
if (!existingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
const validPassword = await Bun.password.verify(
body.password,
existingUser.password,
);
if (!validPassword) {
set.status = 403;
return {
message: "Invalid credentials.",
};
}
const fields = [];
const values = [];
if (body.email) {
const existingUser = await db
.query("SELECT id FROM users WHERE email = ?")
.as(User)
.get(body.email);
if (existingUser && existingUser.id.toString() !== user.id) {
set.status = 409;
return { message: "Email already in use." };
}
fields.push("email");
values.push(body.email);
}
if (body.newPassword) {
fields.push("password");
values.push(await Bun.password.hash(body.newPassword));
}
if (fields.length > 0) {
db.query(
`UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`,
).run(...values, user.id);
}
return redirect(`${WEBROOT}/`, 302);
},
{
body: t.Object({
email: t.MaybeEmpty(t.String()),
newPassword: t.MaybeEmpty(t.String()),
password: t.String(),
}),
},
)