components.tsx
Raw
1// biome-ignore lint/correctness/noUnusedImports: Html is the JSX factory (tsconfig jsxFactory: Html.createElement)
2import { Html } from "@elysiajs/html";
3import type { PropsWithChildren } from "@kitajs/html";
4import { escapeHTML } from "bun";
5import { fileTypeFromBuffer } from "file-type";
6import hljs from "highlight.js";
7import { config } from "./config";
8import { humanFileSize, isValidUTF8 } from "./shared";
9
10export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
11
12function Template(props: PropsWithChildren<{ css: string }>) {
13 return (
14 <>
15 {"<!doctype html>"}
16 <html lang="en">
17 <head>
18 <title>⚡ZBin⚡</title>
19 <meta charset="UTF-8" />
20 <meta name="viewport" content="width=device-width, initial-scale=1" />
21 <link rel="stylesheet" href={props.css} />
22 <link
23 rel="icon"
24 href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📂</text></svg>"
25 />
26 </head>
27 <body>{props.children}</body>
28 </html>
29 </>
30 );
31}
32
33export function NotFound() {
34 return (
35 <Template css="/default.css">
36 <h1>404</h1>
37 <p>File not found</p>
38 </Template>
39 );
40}
41
42// Surfaces the server's configured abuse limits so users know the rules before
43// uploading. Each line only shows when the corresponding limit is actually set.
44function ServerLimits() {
45 return (
46 <div id="server-limits">
47 <small>Server limits:</small>
48 <ul>
49 <li>
50 <small>Max upload size: {humanFileSize(config.maxUploadBytes)}</small>
51 </li>
52 {config.maxAgeMinutes !== null ? (
53 <li>
54 <small>
55 Files are kept for at most{" "}
56 {humanReadableTime(config.maxAgeMinutes)} before being deleted
57 </small>
58 </li>
59 ) : (
60 ""
61 )}
62 {config.uploadCooldownSeconds > 0 ? (
63 <li>
64 <small>
65 You can upload once every {config.uploadCooldownSeconds} second
66 {config.uploadCooldownSeconds > 1 ? "s" : ""} from the same address
67 </small>
68 </li>
69 ) : (
70 ""
71 )}
72 </ul>
73 </div>
74 );
75}
76
77export function Index(hostname: string) {
78 return (
79 <Template css="/default.css">
80 <h1>⚡ZBin⚡</h1>
81 <ServerLimits />
82 <dialog id="upload-dialog">File is being uploaded, please wait</dialog>
83 <form
84 action="/upload"
85 id="uploadForm"
86 method="post"
87 enctype="multipart/form-data"
88 onsubmit="return onUploadSubmit()"
89 >
90 <input type="reset" value="Reset form" />
91 <input required={true} type="file" id="file" name="file" />
92 <label for="filename">File name override:</label>
93 <small>Optional. If empty, will use the uploaded file name.</small>
94 <input
95 type="text"
96 id="filename"
97 name="filename"
98 placeholder="File name"
99 />
100 <label for="filetype">File type:</label>
101 <small>
102 Used for syntax highlighting, if the file is only containing valid
103 UTF-8 characters. Otherwise it will always be stored as a binary blob.
104 Can select <code>blob</code> to force this detection.
105 </small>
106 <select size="10" required={true} id="filetype" name="filetype">
107 {filetypes.map((filetype, i) => (
108 <option selected={i === 0} value={filetype}>
109 {filetype}
110 </option>
111 ))}
112 </select>
113 <label for="delete-in">Delete in:</label>
114 <small>
115 Optional. If set, file will be deleted after the given number of
116 minutes.
117 </small>
118 <input
119 type="number"
120 id="delete-in"
121 name="delete_in_minutes"
122 min="0"
123 placeholder="0"
124 />
125 <label for="password">Password:</label>
126 <small>
127 Optional. If set, file will be encrypted and requires the given
128 password to open.
129 </small>
130 <input
131 type="password"
132 id="password"
133 name="password"
134 placeholder="Password"
135 />
136 <label for="encrypt-mode-server">Encryption mode:</label>
137 <small>
138 Only relevant when a password is set. Client-side encrypts in your
139 browser so the password never reaches the server (requires JavaScript).
140 Server-side encrypts on upload.
141 </small>
142 <div>
143 <label>
144 <input
145 type="radio"
146 id="encrypt-mode-server"
147 name="encrypt_mode"
148 value="server"
149 checked={true}
150 />{" "}
151 Server-side
152 </label>
153 <label>
154 <input
155 type="radio"
156 name="encrypt_mode"
157 value="client"
158 disabled={true}
159 />{" "}
160 Client-side (in your browser)
161 </label>
162 </div>
163 <div>
164 <label for="encrypted">Already encrypted:</label>
165 <input type="checkbox" id="encrypted" name="encrypted" />
166 </div>
167 <small>
168 If set, file is already encrypted with an algorithm like in the python
169 file available below. Password is ignored in this case.
170 </small>
171 <input type="submit" value="Upload File" />
172 <hr />
173 <h3>curl guide</h3>
174 You can use curl to upload and download files
175 <br />
176 Upload:
177 <pre>
178 curl {hostname}upload \{"\n"}
179 -F file=@/path/to/file \{"\n"}
180 -F filetype="plaintext" \{"\n"}# optional{"\n"}
181 -F filename="file name" \{"\n"}# optional{"\n"}
182 -F delete_in_minutes="60" \{"\n"}# optional{"\n"}
183 -F password="mypassword" \{"\n"}# optional{"\n"}
184 -F encrypted="on"
185 </pre>
186 <p>
187 If you upload an already encrypted file, you should set the{" "}
188 <code>encrypted="on"</code> parameter and encrypt it in a compatible
189 way, so the frontend/backend can also decrypt it. You can use this
190 python script to encrypt a file locally:{" "}
191 <a href="/encrypt.py">encrypt.py</a>
192 </p>
193 <br />
194 Download:
195 <pre>
196 curl {hostname}raw/$uuid?ignore_password=true/false \{"\n"}# optional,
197 if encrypted with password{"\n"}
198 --cookie "password=mypassword"
199 </pre>
200 <p>
201 If ignore_password is set to true, then encrypted files can be
202 downloaded directly (in encrypted form) without supplying the
203 password.
204 </p>
205 </form>
206 <script src="/dist/client-index.js" />
207 </Template>
208 );
209}
210
211export function WrongPassword() {
212 return (
213 <Template css="/default.css">
214 <h1>Incorrect password</h1>
215 <p>Reload page to try again</p>
216 </Template>
217 );
218}
219
220export function humanReadableTime(minutes: number) {
221 // Guard on the total before it is broken into units below; an already-expired
222 // (or sub-minute) duration has no meaningful breakdown.
223 if (minutes <= 0) return "expired";
224 const years = Math.floor(minutes / 525600);
225 minutes %= 525600;
226 const days = Math.floor(minutes / 1440);
227 minutes %= 1440;
228 const hours = Math.floor(minutes / 60);
229 minutes %= 60;
230
231 let result = "";
232 if (years > 0) result += `${years} year${years > 1 ? "s" : ""} `;
233 if (days > 0) result += `${days} day${days > 1 ? "s" : ""} `;
234 if (hours > 0) result += `${hours} hour${hours > 1 ? "s" : ""} `;
235 if (minutes > 0) result += `${minutes} minute${minutes > 1 ? "s" : ""} `;
236 if (result === "") result = "0 minutes";
237 return result.trim();
238}
239
240export async function ShowFile(
241 filename: string,
242 uuid: string,
243 content: Uint8Array | null,
244 filetype: string,
245 delete_at: number | null,
246) {
247 //if content is null, this is for the js frontend
248 let preview: JSX.Element;
249 if (!content) {
250 preview = <>Please wait for the file to load</>;
251 } else {
252 preview = <>This file can't be previewed</>;
253 if (isValidUTF8(content) && filetype !== "blob") {
254 if (filetype === "none") {
255 preview = (
256 <pre>{escapeHTML(new TextDecoder("utf-8").decode(content))}</pre>
257 );
258 } else {
259 preview = (
260 <pre>
261 {
262 hljs.highlight(new TextDecoder("utf-8").decode(content), {
263 language: filetype,
264 }).value
265 }
266 </pre>
267 );
268 }
269 } else {
270 // Point media previews at /raw/:uuid rather than inlining the whole
271 // file as a base64 data URI (which would balloon the HTML and server
272 // memory for large files). /raw serves a safe content-type and, for
273 // encrypted files, decrypts using the path-scoped password cookie.
274 const detected = await fileTypeFromBuffer(content);
275 const rawUrl = `/raw/${uuid}`;
276 if (detected) {
277 if (detected.mime.startsWith("audio/")) {
278 preview = <audio controls="" src={rawUrl} />;
279 } else if (detected.mime.startsWith("video/")) {
280 preview = <video controls src={rawUrl} />;
281 } else if (detected.mime.startsWith("image/")) {
282 preview = <img src={rawUrl} alt={filename} />;
283 }
284 }
285 }
286 }
287
288 return (
289 <Template css="/show.css">
290 {content ? (
291 ""
292 ) : (
293 <div id="decrypt-overlay">
294 <h1 safe>Encrypted file: {filename}</h1>
295 <form
296 id="decrypt-form"
297 action={`/set-cookie/${uuid}`}
298 method="post"
299 onsubmit="return onPasswordSubmit()"
300 >
301 <label id="password-label" for="password">
302 Enter a password to decrypt the file:
303 </label>
304 <input
305 required
306 type="password"
307 id="password"
308 name="password"
309 />
310 <div>
311 <label>
312 <input
313 type="radio"
314 name="decrypt_mode"
315 value="server"
316 checked={true}
317 />{" "}
318 Server-side
319 </label>
320 <label>
321 <input
322 type="radio"
323 name="decrypt_mode"
324 value="client"
325 disabled={true}
326 />{" "}
327 Client-side (in your browser)
328 </label>
329 </div>
330 <input type="submit" value="Submit" />
331 </form>
332 </div>
333 )}
334 <div id="content">
335 <div id="filename" safe>
336 {filename}
337 </div>
338 <div id="mediabox">{preview}</div>
339 </div>
340 <div id="sidebar">
341 <h3>File info</h3>
342 <hr />
343 <p id="filesize">
344 size: {content ? humanFileSize(content.byteLength) : "unknown"}
345 </p>
346 <p>declared type: {filetype}</p>
347 {delete_at ? (
348 <p>
349 delete at: {new Date(delete_at * 1000).toISOString()} (in{" "}
350 {humanReadableTime(
351 Math.floor((delete_at - Date.now() / 1000) / 60),
352 )}
353 )
354 </p>
355 ) : (
356 ""
357 )}
358 <form id="download-form" action={`/raw/${uuid}`} method="get">
359 <button type="submit">Download</button>
360 </form>
361 </div>
362 {content ? "" : <script src="/dist/client-show.js" />}
363 </Template>
364 );
365}
366
367export function SetCookie(uuid: string) {
368 return (
369 <Template css="/default.css">
370 Redirecting you back to{" "}
371 <a safe href={`/show/${uuid}`}>
372 /show/{uuid}
373 </a>
374 </Template>
375 );
376}
377