js/nojs merge

AuthorKonata <konata@posteo.jp>
Date
Commitf55e9643e41d4eb709ee97af0839fa7b7b0adb40
Parent82a92c3
10 files changed, 278 insertions(+), 267 deletions(-)
▾Massets/default.css
@@ -1,10 +1,6 @@
@import "normalize.css";
@import "sakura-vader.css";
#jsguard {
display: none;
}
body {
display: flex;
align-items: center;
@@ -45,6 +41,10 @@ form {
margin-bottom: 0.5rem;
}
input[type="radio"] {
flex-grow: 0;
}
div {
display: flex;
flex-direction: row;
▾Massets/show.css
@@ -79,12 +79,18 @@ video {
object-fit: scale-down;
}
dialog {
#decrypt-overlay {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #120c0e;
color: #d9d8dc;
border-color: #eb99a1;
/* biome-ignore lint/style/noDescendingSpecificity: targets dialog forms only, disjoint from the #sidebar form rule */
/* biome-ignore lint/style/noDescendingSpecificity: targets the overlay form only, disjoint from the #sidebar form rule */
form {
display: flex;
flex-direction: column;
▾Mbun.lock
@@ -12,13 +12,9 @@
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1",
"pkcs7-padding": "^0.1.1",
"utf-8-validate": "^6.0.5",
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/pkcs7-padding": "^0.1.3",
"@types/utf-8-validate": "^5.0.2",
"bun-types": "latest",
},
},
@@ -64,10 +60,6 @@
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/pkcs7-padding": ["@types/pkcs7-padding@0.1.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-rh4Tsp96Rj4KRBkRMHTZNsyjMBYT7lmHPr9Q6iZiRP1rrXxFzZKj3CpWP15a9ZE5O6BO3KqL0WsHzc+FGYYF1g=="],
"@types/utf-8-validate": ["@types/utf-8-validate@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-ta7cOkEiNr0RGKARljNBaI7E1GBIr3VwS9RrSoQRmbdv1RVq7Q6VhjSGmQHYNt3nHn051qZBKKrpnw7cnEMDuQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -112,12 +104,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"pkcs7-padding": ["pkcs7-padding@0.1.1", "", {}, "sha512-tM/sUOL5FdH7x6gSLHTNDFi1bAut/EXGFp/Ih8uRkVd2IdXwb5QWnxBgQXn6buadj1pLi3CYKCnrxb+XoTz+Ww=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -134,8 +122,6 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
▾Mpackage.json
@@ -15,14 +15,10 @@
"@elysiajs/static": "^1.2.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1",
"pkcs7-padding": "^0.1.1",
"utf-8-validate": "^6.0.5"
"highlight.js": "^11.11.1"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/pkcs7-padding": "^0.1.3",
"@types/utf-8-validate": "^5.0.2",
"bun-types": "latest"
},
"module": "src/index.js"
▾Msrc/client-index.ts
@@ -1,36 +1,5 @@
async function encrypt(
content: Uint8Array<ArrayBuffer>,
password: string,
): Promise<ArrayBuffer> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-512",
},
keyMaterial,
{ name: "AES-CBC", length: 256 },
false,
["encrypt"],
);
const encryptedContent = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv: iv },
key,
content,
);
return new Uint8Array([...salt, ...iv, ...new Uint8Array(encryptedContent)])
.buffer;
}
import { encrypt } from "./crypto";
import { getMode, setupModeRadios } from "./shared";
async function uploadFile() {
(
@@ -90,13 +59,25 @@ async function uploadFile() {
form.submit();
}
const jsguards = document.getElementsByClassName("jsguard");
for (let i = 0; i < jsguards.length; i++) {
jsguards[i].remove();
// Decides per submit whether to encrypt in the browser (client mode) or let the
// form POST normally so the server encrypts (server mode / no password).
function onUploadSubmit(): boolean {
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement | null;
if (getMode("encrypt_mode") === "client" && passwordInput?.value) {
uploadFile();
return false;
}
return true;
}
// reset() first: it reverts controls to their HTML defaults (incl. the radios),
// so apply the saved mode preference afterwards.
(document.getElementById("uploadForm") as HTMLFormElement | null)?.reset();
window.uploadFile = uploadFile;
setupModeRadios("encrypt_mode");
Object.assign(window, { uploadFile });
Object.assign(window, { onUploadSubmit });
//disable bfcache, otherwise dialog will stay open when navigating back
window.addEventListener("unload", () => {});
window.addEventListener("beforeunload", () => {});
▾Msrc/client-show.ts
@@ -1,51 +1,9 @@
import { fileTypeFromBuffer } from "file-type";
import hljs from "highlight.js";
import { humanFileSize } from "./shared";
import { decrypt } from "./crypto";
import { getMode, humanFileSize, isValidUTF8, setupModeRadios } from "./shared";
async function decrypt(
encryptedContent: ArrayBuffer,
password: string,
): Promise<ArrayBuffer> {
const encryptedArray = new Uint8Array(encryptedContent);
const salt = encryptedArray.slice(0, 16);
const iv = encryptedArray.slice(16, 32);
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-512",
},
keyMaterial,
{ name: "AES-CBC", length: 256 },
false,
["decrypt"],
);
const decryptedContent = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv: iv },
key,
encryptedArray.slice(32),
);
return decryptedContent;
}
function isValidUTF8(buf: ArrayBuffer) {
try {
new TextDecoder("utf-8", { fatal: true }).decode(buf);
return true;
} catch (_e) {
return false;
}
}
async function showContent(content: ArrayBuffer, filetype: string) {
async function showContent(content: Uint8Array, filetype: string) {
const filesize = document.getElementById("filesize") as HTMLDivElement | null;
if (filesize) {
filesize.textContent = `size: ${humanFileSize(content.byteLength)}`;
@@ -91,31 +49,59 @@ async function showContent(content: ArrayBuffer, filetype: string) {
mediaBox.appendChild(preview);
}
let content: ArrayBuffer;
let encrypted: Uint8Array | undefined;
let content: Uint8Array;
let filetype: string;
let filename: string;
async function onPasswordSubmit() {
const uuid = window.location.pathname
.split("/")
.filter((x) => x !== "")
.reverse()[0];
// Remembers the client-side password for this file for the rest of the browser
// session, mirroring the server-side password cookie so the prompt only appears
// once per session.
const PASSWORD_KEY = `zbin-pw-${uuid}`;
// Called from the overlay form's onsubmit. In server mode we let the form POST
// the password to /set-cookie (native submit). In client mode we intercept,
// fetch the still-encrypted bytes, and decrypt locally so the password never
// leaves the browser.
function onPasswordSubmit(): boolean {
if (getMode("decrypt_mode") === "server") {
return true;
}
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement | null;
const passwordLabel = document.getElementById(
"password-label",
) as HTMLLabelElement | null;
if (!passwordInput || !passwordLabel) {
console.error("Missing passwordInput or passwordLabel element.");
return;
if (passwordInput) {
void decryptClientSide(passwordInput.value, passwordLabel);
}
return false;
}
async function decryptClientSide(
password: string,
passwordLabel: HTMLLabelElement | null,
): Promise<boolean> {
if (!encrypted) {
const response = await fetch(`/raw/${uuid}?ignore_password=true`);
encrypted = new Uint8Array(await response.arrayBuffer());
filetype = response.headers.get("filetype") || "none";
filename = response.headers.get("filename") || "";
}
try {
content = await decrypt(content, passwordInput.value);
content = await decrypt(encrypted, password);
} catch (_e) {
passwordLabel.textContent = "Incorrect password";
return;
if (passwordLabel) passwordLabel.textContent = "Incorrect password";
return false;
}
const dialog = document.getElementById(
"password-dialog",
) as HTMLDialogElement | null;
dialog?.close();
sessionStorage.setItem(PASSWORD_KEY, password);
document.getElementById("decrypt-overlay")?.remove();
const downloadForm = document.getElementById(
"download-form",
) as HTMLFormElement | null;
@@ -130,29 +116,18 @@ async function onPasswordSubmit() {
link.click();
});
showContent(content, filetype);
return true;
}
const uuid = window.location.pathname
.split("/")
.filter((x) => x !== "")
.reverse()[0];
setupModeRadios("decrypt_mode");
fetch(`/raw/${uuid}?ignore_password=true`).then(async (response) => {
content = await response.arrayBuffer();
const mediaBox = document.getElementById("mediabox");
if (mediaBox) {
mediaBox.innerHTML = "";
}
filetype = response.headers.get("filetype") || "none";
filename = response.headers.get("filename") || "";
if (response.headers.get("encrypted") === "true") {
const dialog = document.getElementById(
"password-dialog",
) as HTMLDialogElement | null;
dialog?.showModal();
} else {
showContent(content, filetype);
}
});
// If we already decrypted this file this session, auto-decrypt with the stored
// password instead of prompting again. Drop a stale password if it no longer works.
const savedPassword = sessionStorage.getItem(PASSWORD_KEY);
if (savedPassword) {
decryptClientSide(savedPassword, null).then((ok) => {
if (!ok) sessionStorage.removeItem(PASSWORD_KEY);
});
}
Object.assign(window, { onPasswordSubmit });
▾Msrc/components.tsx
@@ -4,8 +4,7 @@ import type { PropsWithChildren } from "@kitajs/html";
import { escapeHTML } from "bun";
import { fileTypeFromBuffer } from "file-type";
import hljs from "highlight.js";
import isValidUTF8 from "utf-8-validate";
import { humanFileSize } from "./shared";
import { humanFileSize, isValidUTF8 } from "./shared";
export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
@@ -39,28 +38,17 @@ export function NotFound() {
);
}
export function Index(hostname: string, withJs: boolean) {
let body = (
<>
export function Index(hostname: string) {
return (
<Template css="/default.css">
<h1>⚡ZBin⚡</h1>
<dialog id="upload-dialog">File is being uploaded, please wait</dialog>
{withJs ? (
<div style="text-align: center">
JS version with client-side encryption, for the version without js an
with server-side encryption, go <a href="/">here</a>.
</div>
) : (
<div style="text-align: center">
JS-less version with server-side encryption, for the version with
client-side encryption, go to <a href="/js">/js</a>.
</div>
)}
<hr />
<form
action={withJs ? "/upload?withJs=true" : "/upload"}
action="/upload"
id="uploadForm"
method="post"
enctype="multipart/form-data"
onsubmit={withJs ? "uploadFile(); return false" : undefined}
onsubmit="return onUploadSubmit()"
>
<input type="reset" value="Reset form" />
<input required={true} type="file" id="file" name="file" />
@@ -108,6 +96,33 @@ export function Index(hostname: string, withJs: boolean) {
name="password"
placeholder="Password"
/>
<label for="encrypt-mode-server">Encryption mode:</label>
<small>
Only relevant when a password is set. Client-side encrypts in your
browser so the password never reaches the server (requires JavaScript).
Server-side encrypts on upload.
</small>
<div>
<label>
<input
type="radio"
id="encrypt-mode-server"
name="encrypt_mode"
value="server"
checked={true}
/>{" "}
Server-side
</label>
<label>
<input
type="radio"
name="encrypt_mode"
value="client"
disabled={true}
/>{" "}
Client-side (in your browser)
</label>
</div>
<div>
<label for="encrypted">Already encrypted:</label>
<input type="checkbox" id="encrypted" name="encrypted" />
@@ -151,37 +166,7 @@ export function Index(hostname: string, withJs: boolean) {
password.
</p>
</form>
</>
);
if (withJs) {
body = (
<>
<div class="jsguard">
JS seems to be disabled, enable it and refesh the page, or go back to
<a href="/">here</a> for the JS-less version.
</div>
<div class="jsguard">{body}</div>
<script src="/dist/client-index.js" />
</>
);
}
return (
<Template css="/default.css">
<h1>⚡ZBin⚡</h1>
{body}
</Template>
);
}
export function DecryptFile(filename: string, uuid: string) {
return (
<Template css="/default.css">
<h1>Encrypted file: {filename}</h1>
<form action={`/set-cookie/${uuid}`} method="post">
<label for="password">Enter a Password to decrypt the file</label>
<input type="password" name="password" />
<input type="submit" value="Submit" />
</form>
<script src="/dist/client-index.js" />
</Template>
);
}
@@ -225,7 +210,7 @@ export async function ShowFile(
preview = <>Please wait for the file to load</>;
} else {
preview = <>This file can't be previewed</>;
if (isValidUTF8(Buffer.from(content)) && filetype !== "blob") {
if (isValidUTF8(content) && filetype !== "blob") {
if (filetype === "none") {
preview = (
<pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre>
@@ -258,15 +243,50 @@ export async function ShowFile(
return (
<Template css="/show.css">
<dialog id="password-dialog">
<form onsubmit="onPasswordSubmit(); return false">
<label id="password-label" for="password">
File is encrypted, enter password to decrypt:
</label>
<input required type="password" id="password" />
<input type="submit" value="Submit" />
</form>
</dialog>
{content ? (
""
) : (
<div id="decrypt-overlay">
<h1>Encrypted file: {filename}</h1>
<form
id="decrypt-form"
action={`/set-cookie/${uuid}`}
method="post"
onsubmit="return onPasswordSubmit()"
>
<label id="password-label" for="password">
Enter a password to decrypt the file:
</label>
<input
required
type="password"
id="password"
name="password"
/>
<div>
<label>
<input
type="radio"
name="decrypt_mode"
value="server"
checked={true}
/>{" "}
Server-side
</label>
<label>
<input
type="radio"
name="decrypt_mode"
value="client"
disabled={true}
/>{" "}
Client-side (in your browser)
</label>
</div>
<input type="submit" value="Submit" />
</form>
</div>
)}
<div id="content">
<div id="filename">{filename}</div>
<div id="mediabox">{preview}</div>
▾Asrc/crypto.ts
@@ -0,0 +1,55 @@
// AES-256-CBC encryption with a PBKDF2-derived key, using the WebCrypto API
// (crypto.subtle) which is available both in Bun (server) and the browser
// (client), so encryption/decryption is defined once for both sides.
// Wire format: salt[16] | iv[16] | ciphertext.
async function deriveKey(
password: string,
salt: Uint8Array,
usage: KeyUsage[],
): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-512" },
material,
{ name: "AES-CBC", length: 256 },
false,
usage,
);
}
export async function encrypt(
content: Uint8Array,
password: string,
): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt, ["encrypt"]);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv },
key,
content,
);
return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]);
}
export async function decrypt(
data: Uint8Array,
password: string,
): Promise<Uint8Array> {
const salt = data.slice(0, 16);
const iv = data.slice(16, 32);
const key = await deriveKey(password, salt, ["decrypt"]);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv },
key,
data.slice(32),
);
return new Uint8Array(plaintext);
}
▾Msrc/index.ts
@@ -1,12 +1,10 @@
import { Database } from "bun:sqlite";
import crypto from "node:crypto";
import cron from "@elysiajs/cron";
import { html } from "@elysiajs/html";
import staticPlugin from "@elysiajs/static";
import { randomUUIDv7 } from "bun";
import { Elysia, StatusMap, t } from "elysia";
import {
DecryptFile,
filetypes,
Index,
NotFound,
@@ -14,6 +12,7 @@ import {
ShowFile,
WrongPassword,
} from "./components";
import { decrypt, encrypt } from "./crypto";
const db = new Database("./db/db.sqlite");
db.run("PRAGMA foreign_keys = ON");
@@ -23,25 +22,6 @@ db.run(
);
db.run("PRAGMA optimize");
function encrypt(content: Uint8Array, password: string): Buffer<ArrayBuffer> {
const iv = crypto.randomBytes(16);
const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, "sha512");
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
return Buffer.concat([salt, iv, cipher.update(content), cipher.final()]);
}
function decrypt(encrypted_content: Uint8Array, password: string): Buffer {
const salt = encrypted_content.slice(0, 16);
const iv = encrypted_content.slice(16, 32);
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, "sha512");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
return Buffer.concat([
decipher.update(encrypted_content.slice(32)),
decipher.final(),
]);
}
function stringArrayToEnum<T extends string>(
arr: readonly T[],
): { [K in T]: K } {
@@ -67,13 +47,12 @@ const app = new Elysia({
},
}),
)
.get("/", ({ server }) => Index(server?.url.toString() ?? "", false))
.get("/js", ({ server }) => Index(server?.url.toString() ?? "", true))
.get("/", ({ server }) => Index(server?.url.toString() ?? ""))
.post(
"/upload",
async ({ set, body, query }) => {
async ({ set, body }) => {
const uuid = randomUUIDv7();
let content = Buffer.from(await body.file.bytes());
let content: Uint8Array = Buffer.from(await body.file.bytes());
let encrypted = false;
let delete_at: number | null = null;
if (body.delete_in_minutes) {
@@ -83,7 +62,7 @@ const app = new Elysia({
if (body.encrypted === "on") {
encrypted = true;
} else if (body.password) {
content = encrypt(content, body.password);
content = await encrypt(content, body.password);
encrypted = true;
}
db.exec(
@@ -98,11 +77,7 @@ const app = new Elysia({
],
);
set.status = StatusMap["See Other"];
if (query.withJs) {
set.headers.location = `/show-js/${uuid}`;
} else {
set.headers.location = `/show/${uuid}`;
}
set.headers.location = `/show/${uuid}`;
return `Created with id: ${uuid}`;
},
{
@@ -113,12 +88,12 @@ const app = new Elysia({
filetype: t.Enum(stringArrayToEnum(filetypes)),
password: t.Optional(t.String()),
encrypted: t.Optional(t.String()),
encrypt_mode: t.Optional(t.String()),
delete_in_minutes: t.String({
format: "regex",
pattern: "(^$|^[0-9]+$)",
}),
}),
query: t.Object({ withJs: t.Optional(t.Boolean()) }),
},
)
.get(
@@ -143,10 +118,16 @@ const app = new Elysia({
if (result.encrypted) {
const password = cookie.password.value;
if (!password) {
return DecryptFile(result.filename, params.uuid);
return ShowFile(
result.filename,
params.uuid,
null,
result.filetype,
result.delete_at,
);
} else {
try {
result.content = decrypt(result.content, password);
result.content = await decrypt(result.content, password);
} catch (_e) {
set.status = StatusMap.Forbidden;
set.headers["set-cookie"] = [
@@ -170,35 +151,6 @@ const app = new Elysia({
cookie: t.Object({ password: t.Optional(t.String()) }),
},
)
.get(
"/show-js/:uuid",
async ({ set, params }) => {
const result =
(db
.prepare(
"SELECT filename, content, filetype, encrypted, delete_at FROM files WHERE uuid = ?",
)
.get(params.uuid) as {
filename: string;
content: Uint8Array;
filetype: string;
encrypted: boolean;
delete_at: number | null;
}) || null;
if (!result) {
set.status = StatusMap["Not Found"];
return NotFound();
}
return ShowFile(
result.filename,
params.uuid,
null,
result.filetype,
result.delete_at,
);
},
{ params: t.Object({ uuid: t.String() }) },
)
.post(
"/set-cookie/:uuid",
({ set, body, params }) => {
@@ -211,13 +163,16 @@ const app = new Elysia({
return SetCookie(params.uuid);
},
{
body: t.Object({ password: t.String() }),
body: t.Object({
password: t.String(),
decrypt_mode: t.Optional(t.String()),
}),
params: t.Object({ uuid: t.String() }),
},
)
.get(
"/raw/:uuid",
({ set, params, cookie, query }) => {
async ({ set, params, cookie, query }) => {
const result =
(db
.prepare(
@@ -239,7 +194,7 @@ const app = new Elysia({
return 'This file is encrypted, set the cookie "password" with the correct password to allow the server to decrypt it';
}
try {
result.content = decrypt(result.content, cookie.password.value);
result.content = await decrypt(result.content, cookie.password.value);
} catch (_e) {
set.status = StatusMap.Forbidden;
return "Incorrect password";
▾Msrc/shared.ts
@@ -3,3 +3,40 @@ export function humanFileSize(size: number) {
const i = Math.min(4, Math.floor(Math.log(size) / Math.log(1024)));
return `${(size / 1024 ** i).toFixed(2)} ${["B", "KiB", "MiB", "GiB", "TiB"][i]}`;
}
// TextDecoder-based UTF-8 check that works in both Bun and the browser.
export function isValidUTF8(buf: ArrayBuffer | ArrayBufferView): boolean {
try {
new TextDecoder("utf-8", { fatal: true }).decode(buf);
return true;
} catch (_e) {
return false;
}
}
const MODE_STORAGE_KEY = "zbin-mode";
// Enables the (HTML-disabled) client-side radio now that JS is running, applies
// the saved client/server preference (defaulting to client since JS is here),
// and persists changes. Shared by the index (encrypt_mode) and show (decrypt_mode) pages.
export function setupModeRadios(name: string) {
const radios = document.querySelectorAll<HTMLInputElement>(
`input[name="${name}"]`,
);
if (radios.length === 0) return;
const saved = localStorage.getItem(MODE_STORAGE_KEY) ?? "client";
for (const radio of radios) {
radio.disabled = false;
radio.checked = radio.value === saved;
radio.addEventListener("change", () => {
if (radio.checked) localStorage.setItem(MODE_STORAGE_KEY, radio.value);
});
}
}
export function getMode(name: string): string {
const checked = document.querySelector<HTMLInputElement>(
`input[name="${name}"]:checked`,
);
return checked?.value ?? "server";
}