C4illin 1 anno fa
parent
commit
a68046ecd6

+ 55 - 0
.eslintrc.cjs

@@ -0,0 +1,55 @@
+/** @type {import("eslint").Linter.Config} */
+const config = {
+	root: true,
+	parser: "@typescript-eslint/parser",
+	plugins: ["isaacscript", "import"],
+	extends: [
+		"plugin:@typescript-eslint/recommended-type-checked",
+		"plugin:@typescript-eslint/stylistic-type-checked",
+		"plugin:prettier/recommended",
+	],
+	parserOptions: {
+		ecmaVersion: "latest",
+		sourceType: "module",
+		tsconfigRootDir: __dirname,
+		project: [
+			"./tsconfig.json",
+			"./cli/tsconfig.eslint.json", // separate eslint config for the CLI since we want to lint and typecheck differently due to template files
+			"./upgrade/tsconfig.json",
+			"./www/tsconfig.json",
+		],
+	},
+	overrides: [
+		// Template files don't have reliable type information
+		{
+			files: ["./cli/template/**/*.{ts,tsx}"],
+			extends: ["plugin:@typescript-eslint/disable-type-checked"],
+		},
+	],
+	rules: {
+		// These off/not-configured-the-way-we-want lint rules we like & opt into
+		"@typescript-eslint/no-explicit-any": "error",
+		"@typescript-eslint/no-unused-vars": [
+			"error",
+			{ argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" },
+		],
+		"@typescript-eslint/consistent-type-imports": [
+			"error",
+			{ prefer: "type-imports", fixStyle: "inline-type-imports" },
+		],
+		"import/consistent-type-specifier-style": ["error", "prefer-inline"],
+
+		// For educational purposes we format our comments/jsdoc nicely
+		"isaacscript/complete-sentences-jsdoc": "warn",
+		"isaacscript/format-jsdoc-comments": "warn",
+
+		// These lint rules don't make sense for us but are enabled in the preset configs
+		"@typescript-eslint/no-confusing-void-expression": "off",
+		"@typescript-eslint/restrict-template-expressions": "off",
+
+		// This rule doesn't seem to be working properly
+		"@typescript-eslint/prefer-nullish-coalescing": "off",
+	},
+};
+
+module.exports = config;

+ 3 - 1
.gitignore

@@ -42,4 +42,6 @@ package-lock.json
 **/*.bun
 /src/uploads
 /uploads
-/mydb.sqlite
+/mydb.sqlite
+/output
+/db/mydb.sqlite

+ 62 - 0
biome.json

@@ -0,0 +1,62 @@
+{
+	"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
+	"formatter": {
+		"enabled": true,
+		"formatWithErrors": true,
+		"indentStyle": "space",
+		"indentWidth": 2,
+		"lineEnding": "lf",
+		"lineWidth": 80,
+		"attributePosition": "auto"
+	},
+	"organizeImports": { "enabled": true },
+	"linter": {
+		"enabled": true,
+		"rules": {
+			"recommended": false,
+			"complexity": {
+				"noBannedTypes": "error",
+				"noUselessThisAlias": "error",
+				"noUselessTypeConstraint": "error",
+				"useArrowFunction": "off",
+				"useLiteralKeys": "error",
+				"useOptionalChain": "error"
+			},
+			"correctness": { "noPrecisionLoss": "error", "noUnusedVariables": "off" },
+			"style": {
+				"noInferrableTypes": "error",
+				"noNamespace": "error",
+				"useAsConstAssertion": "error",
+				"useBlockStatements": "off",
+				"useConsistentArrayType": "error",
+				"useForOf": "error",
+				"useImportType": "error",
+				"useShorthandFunctionType": "error"
+			},
+			"suspicious": {
+				"noEmptyBlockStatements": "error",
+				"noEmptyInterface": "error",
+				"noExplicitAny": "error",
+				"noExtraNonNullAssertion": "error",
+				"noMisleadingInstantiator": "error",
+				"noUnsafeDeclarationMerging": "error",
+				"useAwait": "error",
+				"useNamespaceKeyword": "error"
+			}
+		}
+	},
+	"javascript": {
+		"formatter": {
+			"jsxQuoteStyle": "double",
+			"quoteProperties": "asNeeded",
+			"trailingComma": "all",
+			"semicolons": "always",
+			"arrowParentheses": "always",
+			"bracketSpacing": true,
+			"bracketSameLine": false,
+			"quoteStyle": "double",
+			"attributePosition": "auto"
+		}
+	},
+	"overrides": [{ "include": ["./cli/template/**/*.{ts,tsx}"] }]
+}

BIN
bun.lockb


+ 0 - 0
db/.gitkeep


+ 18 - 5
package.json

@@ -3,22 +3,35 @@
   "version": "1.0.50",
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "dev": "bun run --hot src/index.ts"
+    "dev": "bun run --hot src/index.tsx"
   },
   "dependencies": {
     "@elysiajs/cookie": "^0.8.0",
     "@elysiajs/html": "^1.0.2",
     "@elysiajs/jwt": "^1.0.2",
     "@elysiajs/static": "^1.0.2",
-    "elysia": "latest"
+    "elysia": "latest",
+    "sharp": "^0.33.4"
   },
-  "module": "src/index.js",
+  "module": "src/index.tsx",
   "bun-create": {
-    "start": "bun run src/index.ts"
+    "start": "bun run src/index.tsx"
   },
   "devDependencies": {
+    "@biomejs/biome": "1.7.3",
+    "@ianvs/prettier-plugin-sort-imports": "^4.2.1",
+    "@kitajs/ts-html-plugin": "^4.0.1",
+    "@total-typescript/ts-reset": "^0.5.1",
     "@types/bun": "^1.1.2",
+    "@types/eslint": "^8.56.10",
     "@types/node": "^20.12.12",
-    "bun-types": "latest"
+    "@types/ws": "^8.5.10",
+    "@typescript-eslint/eslint-plugin": "^7.9.0",
+    "@typescript-eslint/parser": "^7.9.0",
+    "bun-types": "^1.1.8",
+    "eslint-config-prettier": "^9.1.0",
+    "eslint-plugin-prettier": "^5.1.3",
+    "prettier": "^3.2.5",
+    "typescript": "^5.4.5"
   }
 }

+ 14 - 0
prettier.config.cjs

@@ -0,0 +1,14 @@
+/**
+ * @type {import('prettier').Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig}
+ */
+const config = {
+  arrowParens: "always",
+  printWidth: 80,
+  singleQuote: false,
+  semi: true,
+  trailingComma: "all",
+  tabWidth: 2,
+  plugins: ["@ianvs/prettier-plugin-sort-imports"],
+};
+
+export default config;

+ 1 - 0
reset.d.ts

@@ -0,0 +1 @@
+import "@total-typescript/ts-reset";

+ 13 - 0
src/components/base.tsx

@@ -0,0 +1,13 @@
+export const BaseHtml = ({ children, title = "ConvertX" }) => (
+  <html lang="en">
+    <head>
+      <meta charset="UTF-8" />
+      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+      <title>{title}</title>
+      <link rel="stylesheet" href="/pico.lime.min.css" />
+      <link rel="stylesheet" href="/style.css" />
+      <script src="https://unpkg.com/htmx.org@1.9.12"></script>
+    </head>
+    <body>{children}</body>
+  </html>
+);

+ 49 - 0
src/components/header.tsx

@@ -0,0 +1,49 @@
+export const Header = ({ loggedIn }: { loggedIn?: boolean }) => {
+  let rightNav: JSX.Element;
+  if (loggedIn) {
+    rightNav = (
+      <ul>
+        <li>
+          <a href="/results">History</a>
+        </li>
+        <li>
+          <a href="/logout">Logout</a>
+        </li>
+      </ul>
+    );
+  } else {
+    rightNav = (
+      <ul>
+        <li>
+          <a href="/login">Login</a>
+        </li>
+        <li>
+          <a href="/register">Register</a>
+        </li>
+      </ul>
+    );
+  }
+
+  return (
+    <header class="container-fluid">
+      <nav>
+        <ul>
+          <li>
+            <strong>
+              <a
+                href="/"
+                style={{
+                  textDecoration: "none",
+                  color: "inherit",
+                }}
+              >
+                ConvertX
+              </a>
+            </strong>
+          </li>
+        </ul>
+        {rightNav}
+      </nav>
+    </header>
+  );
+};

+ 39 - 0
src/converters/main.ts

@@ -0,0 +1,39 @@
+import { properties, convert } from "./sharp";
+
+export async function mainConverter(
+	inputFilePath: string,
+	fileType: string,
+	convertTo: string,
+	targetPath: string,
+	// biome-ignore lint/suspicious/noExplicitAny: <explanation>
+	options?: any,
+) {
+	// Check if the fileType and convertTo are supported by the sharp converter
+	if (properties.from.includes(fileType) && properties.to.includes(convertTo)) {
+		// Use the sharp converter
+		try {
+			await convert(inputFilePath, fileType, convertTo, targetPath, options);
+			console.log(
+				`Converted ${inputFilePath} from ${fileType} to ${convertTo} successfully.`,
+			);
+		} catch (error) {
+			console.error(
+				`Failed to convert ${inputFilePath} from ${fileType} to ${convertTo}.`,
+				error,
+			);
+		}
+	} else {
+		console.log(
+			`The sharp converter does not support converting from ${fileType} to ${convertTo}.`,
+		);
+	}
+}
+
+export function possibleConversions(fileType: string) {
+	// Check if the fileType is supported by the sharp converter
+	if (properties.from.includes(fileType)) {
+		return properties.to;
+	}
+
+	return [];
+}

+ 37 - 0
src/converters/sharp.ts

@@ -0,0 +1,37 @@
+import sharp from "sharp";
+
+// declare possible conversions
+export const properties = {
+  from: ["jpeg", "png", "webp", "gif", "avif", "tiff", "svg"],
+  to: ["jpeg", "png", "webp", "gif", "avif", "tiff"],
+  options: {
+    svg: {
+      scale: {
+        description: "Scale the image up or down",
+        type: "number",
+        default: 1,
+      },
+    }
+  }
+}
+
+export async function convert(filePath: string, fileType: string, convertTo: string, targetPath: string, options?: any) {
+  if (fileType === "svg") {
+    const scale = options.scale || 1;
+    const metadata = await sharp(filePath).metadata();
+
+    if (!metadata || !metadata.width || !metadata.height) {
+      throw new Error("Could not get metadata from image");
+    }
+
+    const newWidth = Math.round(metadata.width * scale)
+    const newHeight = Math.round(metadata.height * scale)
+
+    return await sharp(filePath)
+					.resize(newWidth, newHeight)
+					.toFormat(convertTo)
+					.toFile(targetPath);
+  }
+
+  return await sharp(filePath).toFormat(convertTo).toFile(targetPath);
+}

+ 12 - 0
src/helpers/normalizeFiletype.ts

@@ -0,0 +1,12 @@
+export const normalizeFiletype = (filetype: string): string => {
+  const lowercaseFiletype = filetype.toLowerCase();
+  
+  switch (lowercaseFiletype) {
+    case "jpg":
+      return "jpeg";
+    case "htm":
+      return "html";
+    default:
+      return lowercaseFiletype;
+  }
+}

+ 0 - 247
src/index.ts

@@ -1,247 +0,0 @@
-import { Elysia, t } from "elysia";
-import { staticPlugin } from "@elysiajs/static";
-import { html } from "@elysiajs/html";
-import { Database } from "bun:sqlite";
-import cookie from "@elysiajs/cookie";
-import { unlink } from "node:fs/promises";
-import { randomUUID } from "node:crypto";
-import { jwt } from "@elysiajs/jwt";
-
-const db = new Database("./mydb.sqlite");
-const uploadsDir = "./uploads/";
-
-// init db
-db.exec(`
-CREATE TABLE IF NOT EXISTS users (
-	id INTEGER PRIMARY KEY AUTOINCREMENT,
-	email TEXT NOT NULL,
-	password TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS jobs (
-	id INTEGER PRIMARY KEY AUTOINCREMENT,
-	user_id INTEGER NOT NULL,
-	job_id TEXT NOT NULL,
-	date_created TEXT NOT NULL
-);`);
-
-const app = new Elysia()
-	.use(cookie())
-	.use(
-		jwt({
-			name: "jwt",
-			schema: t.Object({
-				id: t.String(),
-			}),
-			secret: "secret",
-			exp: "7d",
-		}),
-	)
-	.use(html())
-	.use(
-		staticPlugin({
-			assets: "src/public/",
-			prefix: "/",
-		}),
-	)
-	.get("/register", async () => {
-		return Bun.file("src/pages/register.html");
-	})
-	.post(
-		"/register",
-		async function handler({ body, set, jwt, cookie: { auth } }) {
-			const existingUser = await db
-				.query("SELECT * FROM users WHERE email = ?")
-				.get(body.email);
-			if (existingUser) {
-				set.status = 400;
-				return {
-					message: "Email already in use.",
-				};
-			}
-			const savedPassword = await Bun.password.hash(body.password);
-
-			db.run(
-				"INSERT INTO users (email, password) VALUES (?, ?)",
-				body.email,
-				savedPassword,
-			);
-
-			const user = await db
-				.query("SELECT * FROM users WHERE email = ?")
-				.get(body.email);
-
-			const accessToken = await jwt.sign({
-				id: String(user.id),
-			});
-
-			// set cookie
-			auth.set({
-				value: accessToken,
-				httpOnly: true,
-				secure: true,
-				maxAge: 60 * 60 * 24 * 7,
-				sameSite: "strict",
-			});
-
-			// redirect to home
-			set.status = 302;
-			set.headers = {
-				Location: "/",
-			};
-		},
-	)
-	.get("/login", async () => {
-		return Bun.file("src/pages/login.html");
-	})
-	.post("/login", async function handler({ body, set, jwt, cookie: { auth } }) {
-		const existingUser = await db
-			.query("SELECT * FROM users WHERE email = ?")
-			.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),
-		});
-
-		// set cookie
-		// set cookie
-		auth.set({
-			value: accessToken,
-			httpOnly: true,
-			secure: true,
-			maxAge: 60 * 60 * 24 * 7,
-			sameSite: "strict",
-		});
-
-		// redirect to home
-		set.status = 302;
-		set.headers = {
-			Location: "/",
-		};
-	})
-	.post("/logout", async ({ set, cookie: { auth } }) => {
-		auth.remove();
-		set.status = 302;
-		set.headers = {
-			Location: "/login",
-		};
-	})
-	.get("/", async ({ jwt, set, cookie: { auth, jobId } }) => {
-		// validate jwt
-		const user = await jwt.verify(auth.value);
-		if (!user) {
-			// redirect to login
-			set.status = 302;
-			set.headers = {
-				Location: "/login",
-			};
-			return;
-		}
-
-		// make sure user exists in db
-		const existingUser = await db
-			.query("SELECT * FROM users WHERE id = ?")
-			.get(user.id);
-
-		if (!existingUser) {
-			// redirect to login and clear cookie
-			auth.remove();
-			set.status = 302;
-			set.headers = {
-				Location: "/login",
-			};
-			return;
-		}
-
-		// create a unique job id
-		jobId.set({
-			value: randomUUID(),
-			httpOnly: true,
-			secure: true,
-			maxAge: 24 * 60 * 60,
-			sameSite: "strict",
-		});
-
-		// insert job id into db
-		db.run(
-			"INSERT INTO jobs (user_id, job_id, date_created) VALUES (?, ?, ?)",
-			user.id,
-			jobId.value,
-			new Date().toISOString(),
-		);
-
-		return Bun.file("src/pages/index.html");
-	})
-	.post("/upload", async ({ body, set, jwt, cookie: { auth, jobId } }) => {
-		// validate jwt
-		const user = await jwt.verify(auth.value);
-		if (!user) {
-			// redirect to login
-			set.status = 302;
-			set.headers = {
-				Location: "/login",
-			};
-			return;
-		}
-
-		// let filesUploaded = [];
-
-		const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
-
-		if (body?.file) {
-			await Bun.write(`${userUploadsDir}${body.file.name}`, body.file);
-			// filesUploaded.push(body.file.name);
-		} else if (body?.files) {
-			if (Array.isArray(body.files)) {
-				for (const file of body.files) {
-					console.log(file);
-					await Bun.write(`${userUploadsDir}${file.name}`, file);
-					// filesUploaded.push(file.name);
-				}
-			} else {
-				await Bun.write(`${userUploadsDir}${body.files.name}`, body.files);
-				// filesUploaded.push(body.files.name);
-			}
-		}
-	})
-	.post("/delete", async ({ body, set, jwt, cookie: { auth, jobId } }) => {
-		const user = await jwt.verify(auth.value);
-		if (!user) {
-			// redirect to login
-			set.status = 302;
-			set.headers = {
-				Location: "/login",
-			};
-			return;
-		}
-
-		const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
-
-		await unlink(`${userUploadsDir}${body.filename}`);
-	})
-	.post("/convert", async (ctx) => {
-		console.log(ctx.body);
-	})
-	.listen(3000);
-
-console.log(
-	`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`,
-);

+ 460 - 0
src/index.tsx

@@ -0,0 +1,460 @@
+import { randomUUID } from "node:crypto";
+import { mkdir, unlink } from "node:fs/promises";
+import cookie from "@elysiajs/cookie";
+import { html } from "@elysiajs/html";
+import { jwt } from "@elysiajs/jwt";
+import { staticPlugin } from "@elysiajs/static";
+import { Database } from "bun:sqlite";
+import { Elysia, t } from "elysia";
+import { BaseHtml } from "./components/base";
+import { Header } from "./components/header";
+import { mainConverter, possibleConversions } from "./converters/main";
+import { normalizeFiletype } from "./helpers/normalizeFiletype";
+
+const db = new Database("./db/mydb.sqlite");
+const uploadsDir = "./uploads/";
+const outputDir = "./output/";
+
+const jobs = {};
+
+// init db
+db.exec(`
+CREATE TABLE IF NOT EXISTS users (
+	id INTEGER PRIMARY KEY AUTOINCREMENT,
+	email TEXT NOT NULL,
+	password TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS jobs (
+	id INTEGER PRIMARY KEY AUTOINCREMENT,
+	user_id INTEGER NOT NULL,
+	job_id TEXT NOT NULL,
+	date_created TEXT NOT NULL,
+  status TEXT DEFAULT 'pending'
+);`);
+
+const app = new Elysia()
+  .use(cookie())
+  .use(html())
+  .use(
+    jwt({
+      name: "jwt",
+      schema: t.Object({
+        id: t.String(),
+      }),
+      secret: "secret",
+      exp: "7d",
+    }),
+  )
+  .use(
+    staticPlugin({
+      assets: "src/public/",
+      prefix: "/",
+    }),
+  )
+  .get("/register", () => {
+    return (
+      <BaseHtml title="ConvertX | Register">
+        <Header />
+        <main class="container-fluid">
+          <form method="post">
+            <input type="email" name="email" placeholder="Email" required />
+            <input
+              type="password"
+              name="password"
+              placeholder="Password"
+              required
+            />
+            <input type="submit" value="Register" />
+          </form>
+        </main>
+      </BaseHtml>
+    );
+  })
+  .post(
+    "/register",
+    async function handler({ body, set, jwt, cookie: { auth } }) {
+      const existingUser = await db
+        .query("SELECT * FROM users WHERE email = ?")
+        .get(body.email);
+      if (existingUser) {
+        set.status = 400;
+        return {
+          message: "Email already in use.",
+        };
+      }
+      const savedPassword = await Bun.password.hash(body.password);
+
+      db.run(
+        "INSERT INTO users (email, password) VALUES (?, ?)",
+        body.email,
+        savedPassword,
+      );
+
+      const user = await db
+        .query("SELECT * FROM users WHERE email = ?")
+        .get(body.email);
+
+      const accessToken = await jwt.sign({
+        id: String(user.id),
+      });
+
+      // set cookie
+      auth.set({
+        value: accessToken,
+        httpOnly: true,
+        secure: true,
+        maxAge: 60 * 60 * 24 * 7,
+        sameSite: "strict",
+      });
+
+      // redirect to home
+      set.status = 302;
+      set.headers = {
+        Location: "/",
+      };
+    },
+  )
+  .get("/login", () => {
+    return (
+      <BaseHtml title="ConvertX | Login">
+        <Header />
+        <main class="container-fluid">
+          <form method="post">
+            <input type="email" name="email" placeholder="Email" required />
+            <input
+              type="password"
+              name="password"
+              placeholder="Password"
+              required
+            />
+            <div role="group">
+              <a href="/register" role="button" class="secondary">
+                Register an account
+              </a>
+              <input type="submit" value="Login" />
+            </div>
+          </form>
+        </main>
+      </BaseHtml>
+    );
+  })
+  .post("/login", async function handler({ body, set, jwt, cookie: { auth } }) {
+    const existingUser = await db
+      .query("SELECT * FROM users WHERE email = ?")
+      .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),
+    });
+
+    // set cookie
+    // set cookie
+    auth.set({
+      value: accessToken,
+      httpOnly: true,
+      secure: true,
+      maxAge: 60 * 60 * 24 * 7,
+      sameSite: "strict",
+    });
+
+    // redirect to home
+    set.status = 302;
+    set.headers = {
+      Location: "/",
+    };
+  })
+  .get("/logout", ({ redirect, cookie: { auth } }) => {
+    if (auth?.value) {
+      auth.remove();
+    }
+    return redirect("/login");
+  })
+  .post("/logout", ({ redirect, cookie: { auth } }) => {
+    if (auth?.value) {
+      auth.remove();
+    }
+
+    return redirect("/login");
+  })
+  .get("/", async ({ jwt, redirect, cookie: { auth, jobId } }) => {
+    // validate jwt
+    const user = await jwt.verify(auth.value);
+    if (!user) {
+      return redirect("/login");
+    }
+
+    // make sure user exists in db
+    const existingUser = await db
+      .query("SELECT * FROM users WHERE id = ?")
+      .get(user.id);
+
+    if (!existingUser) {
+      if (auth?.value) {
+        auth.remove();
+      }
+      return redirect("/login");
+    }
+
+    // create a unique job id
+    jobId.set({
+      value: randomUUID(),
+      httpOnly: true,
+      secure: true,
+      maxAge: 24 * 60 * 60,
+      sameSite: "strict",
+    });
+
+    // insert job id into db
+    db.run(
+      "INSERT INTO jobs (user_id, job_id, date_created) VALUES (?, ?, ?)",
+      user.id,
+      jobId.value,
+      new Date().toISOString(),
+    );
+
+    return (
+      <BaseHtml>
+        <Header loggedIn />
+        <main class="container-fluid">
+          <article>
+            <table id="file-list" />
+            <input type="file" name="file" multiple />
+          </article>
+          <form method="post" action="/convert">
+            <input type="hidden" name="file_names" id="file_names" />
+            <article>
+              <select name="convert_to" aria-label="Convert to" required>
+                <option selected disabled value="">
+                  Convert to
+                </option>
+                <option>JPG</option>
+                <option>PNG</option>
+                <option>SVG</option>
+                <option>PDF</option>
+                <option>DOCX</option>
+                <option>Yaml</option>
+              </select>
+            </article>
+            <input type="submit" value="Convert" />
+          </form>
+        </main>
+        <script src="script.js" defer />
+      </BaseHtml>
+    );
+  })
+  .post("/upload", async ({ body, redirect, jwt, cookie: { auth, jobId } }) => {
+    // validate jwt
+    if (!auth?.value) {
+      // redirect to login
+      return redirect("/login");
+    }
+
+    const user = await jwt.verify(auth.value);
+    if (!user) {
+      return redirect("/login");
+    }
+
+    // let filesUploaded = [];
+
+    const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
+
+    if (body?.file) {
+      if (Array.isArray(body.file)) {
+        for (const file of body.file) {
+          console.log(file);
+          await Bun.write(`${userUploadsDir}${file.name}`, file);
+        }
+      } else {
+        await Bun.write(`${userUploadsDir}${body.file.name}`, body.file);
+      }
+    }
+
+    return {
+      message: "Files uploaded successfully.",
+    };
+  })
+  .post("/delete", async ({ body, set, jwt, cookie: { auth, jobId } }) => {
+    const user = await jwt.verify(auth.value);
+    if (!user) {
+      // redirect to login
+      set.status = 302;
+      set.headers = {
+        Location: "/login",
+      };
+      return;
+    }
+
+    const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
+
+    await unlink(`${userUploadsDir}${body.filename}`);
+  })
+  .post(
+    "/convert",
+    async ({ body, set, redirect, jwt, cookie: { auth, jobId } }) => {
+      const user = await jwt.verify(auth.value);
+      if (!user) {
+        // redirect to login
+        set.status = 302;
+        set.headers = {
+          Location: "/login",
+        };
+        return;
+      }
+
+      if (!jobId?.value) {
+        return redirect("/");
+      }
+
+      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);
+      const fileNames = JSON.parse(body.file_names);
+
+      jobs[jobId.value] = {
+        fileNames: fileNames,
+        filesToConvert: fileNames.length,
+        convertedFiles: 0,
+        outputFiles: [],
+      };
+
+      for (const fileName of fileNames) {
+        const filePath = `${userUploadsDir}${fileName}`;
+        const fileTypeOrig = fileName.split(".").pop();
+        const fileType = normalizeFiletype(fileTypeOrig);
+        const newFileName = fileName.replace(fileTypeOrig, convertTo);
+        const targetPath = `${userOutputDir}${newFileName}`;
+
+        await mainConverter(filePath, fileType, convertTo, targetPath);
+        jobs[jobId.value].convertedFiles++;
+        jobs[jobId.value].outputFiles.push(newFileName);
+      }
+
+      console.log(
+        "sending to results page...",
+        `http://${app.server?.hostname}:${app.server?.port}/results/${jobId.value}`,
+      );
+
+      // redirect to results
+      set.status = 302;
+      set.headers = {
+        Location: `/results/${jobId.value}`,
+      };
+    },
+  )
+  .get("/results", async ({ params, jwt, set, redirect, cookie: { auth } }) => {
+    if (!auth?.value) {
+      return redirect("/login");
+    }
+    const user = await jwt.verify(auth.value);
+    if (!user) {
+      return redirect("/login");
+    }
+
+    const userJobs = await db
+      .query("SELECT * FROM jobs WHERE user_id = ?")
+      .all(user.id);
+    
+    return (
+      <BaseHtml title="ConvertX | Results">
+        <Header loggedIn />
+        <main class="container-fluid">
+          <article>
+            <h1>Results</h1>
+            <ul>
+              {userJobs.map((job) => (
+                <li>
+                  <a href={`/results/${job.job_id}`}>{job.job_id}</a>
+                </li>
+              ))}
+            </ul>
+          </article>
+        </main>
+      </BaseHtml>
+    );
+
+
+    // list all jobs belonging to the user
+  })
+  .get(
+    "/results/:jobId",
+    async ({ params, jwt, set, redirect, cookie: { auth } }) => {
+      if (!auth?.value) {
+        return redirect("/login");
+      }
+
+      const user = await jwt.verify(auth.value);
+      if (!user) {
+        return redirect("/login");
+      }
+
+      const job = await db
+        .query("SELECT * FROM jobs WHERE user_id = ? AND job_id = ?")
+        .get(user.id, params.jobId);
+
+      if (!job) {
+        set.status = 404;
+        return {
+          message: "Job not found.",
+        };
+      }
+
+      return (
+        <BaseHtml>
+          <Header loggedIn />
+          <main class="container-fluid">
+            <article>
+              <h1>Results</h1>
+              <ul>
+                {jobs[params.jobId].outputFiles.map((file: string) => (
+                  <li>
+                    <a href={`/output/${user.id}/${params.jobId}/${file}`}>
+                      {file}
+                    </a>
+                  </li>
+                ))}
+              </ul>
+            </article>
+          </main>
+        </BaseHtml>
+      );
+    },
+  )
+  .onError(({ code, error, request }) => {
+    // log.error(` ${request.method} ${request.url}`, code, error);
+    console.error(error);
+  })
+  .listen(3000);
+
+console.log(
+  `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`,
+);

+ 0 - 59
src/pages/index.html

@@ -1,59 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>ConvertX</title>
-  <link rel="stylesheet" href="pico.lime.min.css">
-  <link rel="stylesheet" href="style.css">
-  <script src="script.js" defer></script>
-</head>
-
-<body>
-  <header class="container-fluid">
-    <nav>
-      <ul>
-        <li><a href="/">ConvertX</a></strong></li>
-      </ul>
-      <ul>
-        <li><a href="#">About</a></li>
-        <li><a href="#">Services</a></li>
-        <li><button class="secondary">Products</button></li>
-      </ul>
-    </nav>
-  </header>
-
-  <main class="container-fluid">
-
-    <!-- File upload -->
-
-
-    <article>
-      <table id="file-list">
-      </table>
-      <input type="file" name="file" multiple />
-
-    </article>
-    <!-- <div class="icon">></div> -->
-    <form method="post"></form>
-    <article>
-      <select name="to" aria-label="Convert to" required>
-        <option selected disabled value="">Convert to</option>
-        <option>JPG</option>
-        <option>PNG</option>
-        <option>SVG</option>
-        <option>PDF</option>
-        <option>DOCX</option>
-        <option>Yaml</option>
-      </select>
-    </article>
-      <input type="submit" value="Convert">
-      <!-- <button type="submit">Convert</button> -->
-    <!-- </div> -->
-    </form>
-  </main>
-  <footer></footer>
-</body>
-
-</html>

+ 0 - 40
src/pages/login.html

@@ -1,40 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-
-<head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>ConvertX | Login</title>
-  <link rel="stylesheet" href="pico.lime.min.css">
-  <link rel="stylesheet" href="style.css">
-</head>
-
-<body>
-  <header class="container-fluid">
-    <nav>
-      <ul>
-        <li><a href="/">ConvertX</a></strong></li>
-      </ul>
-      <ul>
-        <li><a href="#">About</a></li>
-        <li><a href="#">Services</a></li>
-        <li><button class="secondary">Products</button></li>
-      </ul>
-    </nav>
-  </header>
-
-  <main class="container-fluid">
-    <form method="post">
-      <input type="email" name="email" placeholder="Email" required>
-      <input type="password" name="password" placeholder="Password" required>
-      <div role="group">
-        <a href="/register" role="button" class="secondary">Register an account</a>
-        <input type="submit" value="Login">
-      </div>
-    </form>
-
-  </main>
-
-</body>
-
-</html>

+ 21 - 6
src/public/script.js

@@ -1,7 +1,7 @@
 // Select the file input element
 const fileInput = document.querySelector('input[type="file"]');
+const fileNames = [];
 
-const filesToUpload = [];
 
 // Add a 'change' event listener to the file input element
 fileInput.addEventListener("change", (e) => {
@@ -13,17 +13,20 @@ fileInput.addEventListener("change", (e) => {
 	const fileList = document.querySelector("#file-list");
 
 	// Loop through the selected files
-	for (let i = 0; i < files.length; i++) {
+	for (const file of files) {
 		// Create a new table row for each file
 		const row = document.createElement("tr");
 		row.innerHTML = `
-      <td>${files[i].name}</td>
-      <td>${(files[i].size / 1024 / 1024).toFixed(2)} MB</td>
+      <td>${file.name}</td>
+      <td>${(file.size / 1024 / 1024).toFixed(2)} MB</td>
       <td><button class="secondary" onclick="deleteRow(this)">x</button></td>
     `;
 
 		// Append the row to the file-list table
 		fileList.appendChild(row);
+
+		// Append the file to the hidden input
+		fileNames.push(file.name);
 	}
 
 	uploadFiles(files);
@@ -35,6 +38,10 @@ const deleteRow = (target) => {
 	const row = target.parentElement.parentElement;
 	row.remove();
 
+	// remove from fileNames
+	const index = fileNames.indexOf(filename);
+	fileNames.splice(index, 1);
+
 	fetch("/delete", {
 		method: "POST",
 		body: JSON.stringify({ filename: filename }),
@@ -52,8 +59,8 @@ const deleteRow = (target) => {
 const uploadFiles = (files) => {
 	const formData = new FormData();
 
-	for (let i = 0; i < files.length; i++) {
-		formData.append("files", files[i], files[i].name);
+	for (const file of files) {
+		formData.append("file", file, file.name);
 	}
 
 	fetch("/upload", {
@@ -66,3 +73,11 @@ const uploadFiles = (files) => {
 		})
 		.catch((err) => console.log(err));
 };
+
+
+const formConvert = document.querySelector("form[action='/convert']");
+
+formConvert.addEventListener("submit", (e) => {
+	const hiddenInput = document.querySelector("input[name='file_names']");
+	hiddenInput.value = JSON.stringify(fileNames);
+});

+ 30 - 100
tsconfig.json

@@ -1,103 +1,33 @@
 {
   "compilerOptions": {
-    /* Visit https://aka.ms/tsconfig to read more about this file */
-
-    /* Projects */
-    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
-    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
-    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
-    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
-    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
-    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
-
-    /* Language and Environment */
-    "target": "ES2021",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
-    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
-    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
-    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
-    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
-    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
-    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
-    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
-    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
-    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
-    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
-    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
-
-    /* Modules */
-    "module": "ES2022",                                /* Specify what module code is generated. */
-    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
-    "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
-    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
-    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
-    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
-    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
-    "types": ["bun-types"],                                      /* Specify type package names to be included without being referenced in a source file. */
-    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
-    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
-    // "resolveJsonModule": true,                        /* Enable importing .json files. */
-    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
-
-    /* JavaScript Support */
-    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
-    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
-    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
-
-    /* Emit */
-    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
-    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
-    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
-    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
-    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
-    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
-    // "removeComments": true,                           /* Disable emitting comments. */
-    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
-    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
-    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */
-    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
-    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
-    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
-    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
-    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
-    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
-    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
-    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
-    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
-    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
-    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
-    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
-
-    /* Interop Constraints */
-    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
-    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
-    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
-    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
-    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
-
-    /* Type Checking */
-    "strict": true,                                      /* Enable all strict type-checking options. */
-    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
-    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
-    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
-    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
-    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
-    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
-    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
-    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
-    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
-    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
-    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
-    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
-    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
-    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
-    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
-    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
-    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
-    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
-
-    /* Completeness */
-    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
-    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
+    "lib": ["ESNext"],
+    "module": "esnext",
+    "target": "esnext",
+    "moduleResolution": "bundler",
+    "moduleDetection": "force",
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "composite": true,
+    "strict": true,
+    "downlevelIteration": true,
+    "skipLibCheck": true,
+    "jsx": "react",
+    "jsxFactory": "Html.createElement",
+    "jsxFragmentFactory": "Html.Fragment",
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "allowJs": true,
+    "types": [
+      "bun-types" // add Bun global
+    ],
+    // non bun init
+    // "plugins": [{ "name": "@kitajs/ts-html-plugin" }],
+    "noUncheckedIndexedAccess": true,
+    // "noUnusedLocals": true,
+    // "noUnusedParameters": true,
+    "exactOptionalPropertyTypes": true,
+    "noFallthroughCasesInSwitch": true,
+    "noImplicitOverride": true
+    // "noImplicitReturns": true
   }
-}
+}