676 lines
17 KiB
Svelte
676 lines
17 KiB
Svelte
<script>
|
|
import { onDestroy, onMount } from "svelte";
|
|
|
|
let selectedFile = $state(null);
|
|
let loading = $state(false);
|
|
let errorMessage = $state("");
|
|
|
|
let maxUploadSizeBytes = $state(0);
|
|
let defaultTTL = $state("15m");
|
|
let uploadAllowed = $state(true);
|
|
let configError = $state("");
|
|
|
|
let routeMode = $state("home");
|
|
let scratchID = $state("");
|
|
|
|
let viewLoading = $state(false);
|
|
let viewError = $state("");
|
|
let viewMeta = $state(null);
|
|
let viewContent = $state("");
|
|
let viewIsBinary = $state(false);
|
|
let viewIsImage = $state(false);
|
|
let viewIsVideo = $state(false);
|
|
let copyStatusMessage = $state("");
|
|
let copyStatusKind = $state("");
|
|
let refreshTriggered = $state(false);
|
|
let nowMs = $state(Date.now());
|
|
|
|
let nowTicker = null;
|
|
|
|
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
|
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
|
|
|
onMount(async () => {
|
|
nowTicker = window.setInterval(() => {
|
|
nowMs = Date.now();
|
|
}, 1000);
|
|
|
|
detectRoute();
|
|
await loadUIConfig();
|
|
if (routeMode === "view") {
|
|
await loadScratchView();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (nowTicker !== null) {
|
|
window.clearInterval(nowTicker);
|
|
nowTicker = null;
|
|
}
|
|
});
|
|
|
|
function detectRoute() {
|
|
const path = window.location.pathname;
|
|
if (path === "/" || path === "") {
|
|
routeMode = "home";
|
|
scratchID = "";
|
|
return;
|
|
}
|
|
|
|
if (path === "/u" || path === "/u/") {
|
|
routeMode = "upload";
|
|
scratchID = "";
|
|
return;
|
|
}
|
|
|
|
if (!path.startsWith("/s/")) {
|
|
routeMode = "notfound";
|
|
scratchID = "";
|
|
return;
|
|
}
|
|
|
|
const rawID = path.slice("/s/".length).trim();
|
|
if (rawID.length === 0) {
|
|
routeMode = "notfound";
|
|
scratchID = "";
|
|
return;
|
|
}
|
|
|
|
routeMode = "view";
|
|
scratchID = decodeURIComponent(rawID);
|
|
}
|
|
|
|
async function loadUIConfig() {
|
|
configError = "";
|
|
|
|
try {
|
|
const response = await fetch("/api/config");
|
|
if (!response.ok) {
|
|
throw new Error(`failed to load UI config (${response.status})`);
|
|
}
|
|
const data = await response.json();
|
|
const max = Number.parseInt(String(data.max_upload_size_bytes ?? "0"), 10);
|
|
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
|
uploadAllowed = Boolean(data.upload_allowed);
|
|
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
|
} catch (err) {
|
|
configError = err instanceof Error ? err.message : String(err);
|
|
maxUploadSizeBytes = 0;
|
|
uploadAllowed = true;
|
|
defaultTTL = "15m";
|
|
}
|
|
}
|
|
|
|
async function loadScratchView() {
|
|
if (!scratchID) {
|
|
viewError = "Scratch ID is missing.";
|
|
return;
|
|
}
|
|
|
|
viewLoading = true;
|
|
viewError = "";
|
|
viewMeta = null;
|
|
viewContent = "";
|
|
viewIsBinary = false;
|
|
viewIsImage = false;
|
|
viewIsVideo = false;
|
|
copyStatusMessage = "";
|
|
copyStatusKind = "";
|
|
refreshTriggered = false;
|
|
|
|
try {
|
|
const metaResponse = await fetch(`/api/scratch/${encodeURIComponent(scratchID)}`);
|
|
if (!metaResponse.ok) {
|
|
if (metaResponse.status === 404 || metaResponse.status === 410) {
|
|
routeMode = "expired";
|
|
viewError = "This scratch is no longer available.";
|
|
return;
|
|
}
|
|
const msg = await metaResponse.text();
|
|
throw new Error(msg || `Failed to load scratch metadata (${metaResponse.status}).`);
|
|
}
|
|
viewMeta = await metaResponse.json();
|
|
|
|
const rawResponse = await fetch(`/api/raw/${encodeURIComponent(scratchID)}`);
|
|
if (!rawResponse.ok) {
|
|
const msg = await rawResponse.text();
|
|
throw new Error(msg || `Failed to load scratch content (${rawResponse.status}).`);
|
|
}
|
|
|
|
const contentType = rawResponse.headers.get("content-type") ?? "";
|
|
if (isLikelyTextContentType(contentType)) {
|
|
viewContent = await rawResponse.text();
|
|
viewIsBinary = false;
|
|
viewIsImage = false;
|
|
viewIsVideo = false;
|
|
} else if (isBrowserImageContentType(contentType)) {
|
|
viewIsImage = true;
|
|
viewIsBinary = false;
|
|
viewIsVideo = false;
|
|
} else if (isBrowserVideoContentType(contentType)) {
|
|
viewIsVideo = true;
|
|
viewIsImage = false;
|
|
viewIsBinary = false;
|
|
} else {
|
|
viewIsVideo = false;
|
|
viewIsImage = false;
|
|
viewIsBinary = true;
|
|
}
|
|
} catch (err) {
|
|
viewError = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
viewLoading = false;
|
|
}
|
|
}
|
|
|
|
function trimTrailingZeros(value) {
|
|
return value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
|
}
|
|
|
|
function formatBytes(bytes, binary) {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
return "0 bytes";
|
|
}
|
|
|
|
const base = binary ? 1024 : 1000;
|
|
const units = binary ? ["KiB", "MiB", "GiB"] : ["KB", "MB", "GB"];
|
|
if (bytes < base) {
|
|
return `${bytes} bytes`;
|
|
}
|
|
|
|
let value = bytes;
|
|
let unitIndex = -1;
|
|
while (value >= base && unitIndex < units.length - 1) {
|
|
value /= base;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2);
|
|
return `${trimTrailingZeros(rounded)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function isLikelyTextContentType(contentType) {
|
|
const normalized = String(contentType).toLowerCase();
|
|
if (normalized.startsWith("text/")) {
|
|
return true;
|
|
}
|
|
return (
|
|
normalized.startsWith("application/json") ||
|
|
normalized.startsWith("application/xml") ||
|
|
normalized.startsWith("application/javascript") ||
|
|
normalized.startsWith("application/x-www-form-urlencoded")
|
|
);
|
|
}
|
|
|
|
const browserImageContentTypes = new Set([
|
|
"image/apng",
|
|
"image/avif",
|
|
"image/gif",
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/svg+xml",
|
|
"image/webp",
|
|
"image/bmp",
|
|
"image/x-icon",
|
|
"image/vnd.microsoft.icon"
|
|
]);
|
|
|
|
const browserVideoContentTypes = new Set([
|
|
"video/mp4",
|
|
"video/webm",
|
|
"video/ogg"
|
|
]);
|
|
|
|
function normalizeContentType(contentType) {
|
|
return String(contentType).toLowerCase().split(";")[0].trim();
|
|
}
|
|
|
|
function isBrowserImageContentType(contentType) {
|
|
return browserImageContentTypes.has(normalizeContentType(contentType));
|
|
}
|
|
|
|
function isBrowserVideoContentType(contentType) {
|
|
return browserVideoContentTypes.has(normalizeContentType(contentType));
|
|
}
|
|
|
|
function formatTTLDisplay(rawTTL) {
|
|
const source = String(rawTTL ?? "").trim().toLowerCase();
|
|
if (!source) {
|
|
return "15 minutes";
|
|
}
|
|
|
|
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
|
|
if (matches.length === 0) {
|
|
return rawTTL;
|
|
}
|
|
|
|
let hours = 0;
|
|
let minutes = 0;
|
|
let seconds = 0;
|
|
for (const match of matches) {
|
|
const value = Number.parseInt(match[1], 10);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
continue;
|
|
}
|
|
const unit = match[2];
|
|
if (unit === "h") {
|
|
hours += value;
|
|
} else if (unit === "m") {
|
|
minutes += value;
|
|
} else if (unit === "s") {
|
|
seconds += value;
|
|
}
|
|
}
|
|
|
|
const parts = [];
|
|
if (hours > 0) {
|
|
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
|
}
|
|
if (minutes > 0) {
|
|
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
|
}
|
|
if (seconds > 0) {
|
|
parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`);
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
return rawTTL;
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
$effect(() => {
|
|
if (refreshTriggered || routeMode !== "view" || !viewMeta?.expires_at) {
|
|
return;
|
|
}
|
|
|
|
const expiry = Date.parse(String(viewMeta.expires_at));
|
|
if (!Number.isFinite(expiry)) {
|
|
return;
|
|
}
|
|
|
|
if (nowMs >= expiry) {
|
|
refreshTriggered = true;
|
|
window.location.reload();
|
|
}
|
|
});
|
|
|
|
function expiresInText(expiresAt, now) {
|
|
const expiry = Date.parse(String(expiresAt ?? ""));
|
|
if (!Number.isFinite(expiry)) {
|
|
return "unknown";
|
|
}
|
|
const deltaMs = Math.max(0, expiry - now);
|
|
if (deltaMs === 0) {
|
|
return "expired";
|
|
}
|
|
|
|
const totalSeconds = Math.floor(deltaMs / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m ${seconds}s`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
function onFileChange(event) {
|
|
selectedFile = event.currentTarget.files?.[0] ?? null;
|
|
}
|
|
|
|
async function copyScratchURL() {
|
|
if (!viewMeta?.raw_url) {
|
|
return;
|
|
}
|
|
|
|
copyStatusMessage = "";
|
|
copyStatusKind = "";
|
|
|
|
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
|
|
try {
|
|
await navigator.clipboard.writeText(absoluteRawURL);
|
|
copyStatusMessage = "Copied download URL.";
|
|
copyStatusKind = "success";
|
|
} catch {
|
|
copyStatusMessage = "Unable to copy URL. Copy it manually from the address bar.";
|
|
copyStatusKind = "error";
|
|
}
|
|
}
|
|
|
|
function openScratchRawURL() {
|
|
if (!viewMeta?.raw_url) {
|
|
return;
|
|
}
|
|
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
|
|
window.open(absoluteRawURL, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
function navigateTo(path) {
|
|
const destination = String(path ?? "").trim();
|
|
if (!destination) {
|
|
return;
|
|
}
|
|
window.location.assign(destination);
|
|
}
|
|
|
|
async function onSubmit(event) {
|
|
event.preventDefault();
|
|
if (loading) {
|
|
return;
|
|
}
|
|
if (!uploadAllowed) {
|
|
errorMessage = "Uploads are not available from your network location.";
|
|
return;
|
|
}
|
|
|
|
errorMessage = "";
|
|
|
|
if (!selectedFile) {
|
|
errorMessage = "Choose a file before creating a scratch.";
|
|
return;
|
|
}
|
|
if (maxUploadSizeBytes > 0 && selectedFile.size > maxUploadSizeBytes) {
|
|
errorMessage = `Selected file is too large (${formatBytes(selectedFile.size, true)}). Max upload size is ${maxUploadSizeDisplay}.`;
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", selectedFile);
|
|
const response = await fetch("/api/scratch", {
|
|
method: "POST",
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await response.text();
|
|
throw new Error(message || `request failed (${response.status})`);
|
|
}
|
|
|
|
const created = await response.json();
|
|
const viewURL = typeof created?.view_url === "string" ? created.view_url : "";
|
|
if (!viewURL) {
|
|
throw new Error("scratch created but view URL is missing");
|
|
}
|
|
window.location.assign(viewURL);
|
|
return;
|
|
} catch (err) {
|
|
if (err instanceof TypeError) {
|
|
errorMessage = `Upload failed before the server returned a response. Max upload size is ${maxUploadSizeDisplay}.`;
|
|
} else {
|
|
errorMessage = err instanceof Error ? err.message : String(err);
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<main class="container">
|
|
<header class="site-header">
|
|
<div class="brand-row">
|
|
<a class="brand" href="/">Scratchbox</a>
|
|
</div>
|
|
<nav class="site-nav" aria-label="Primary">
|
|
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/")}>Home</button>
|
|
{#if uploadAllowed}
|
|
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/u")}>Upload</button>
|
|
{/if}
|
|
</nav>
|
|
</header>
|
|
|
|
{#if routeMode === "home"}
|
|
<section class="panel">
|
|
<h1>Share scratch files quickly</h1>
|
|
<p>
|
|
Scratchbox is a minimal temporary upload service with expiration.
|
|
</p>
|
|
<p>
|
|
No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.
|
|
</p>
|
|
{#if !uploadAllowed}
|
|
<p class="helper">Uploads are currently unavailable from your network location.</p>
|
|
{/if}
|
|
</section>
|
|
{:else if routeMode === "view"}
|
|
<section class="panel">
|
|
{#if viewLoading}
|
|
<p>Loading scratch content...</p>
|
|
{:else if viewError}
|
|
<section id="error" class="error">{viewError}</section>
|
|
{:else if viewMeta}
|
|
{#if viewIsImage}
|
|
<img
|
|
class="scratch-image"
|
|
src={viewMeta.raw_url}
|
|
alt={`Scratch ${scratchID}`}
|
|
loading="lazy"
|
|
/>
|
|
{:else if viewIsVideo}
|
|
<video class="scratch-video" controls preload="metadata">
|
|
<source src={viewMeta.raw_url} type={viewMeta.content_type} />
|
|
Your browser does not support inline video playback.
|
|
</video>
|
|
{:else if !viewIsBinary}
|
|
<pre>{viewContent}</pre>
|
|
{/if}
|
|
|
|
<p><strong>Expires in:</strong> {expiresInText(viewMeta.expires_at, nowMs)}</p>
|
|
<p class="scratch-actions">
|
|
<button class="link-button action-button" type="button" onclick={openScratchRawURL}>Download</button>
|
|
<button class="link-button action-button" type="button" onclick={copyScratchURL}>Copy Link</button>
|
|
</p>
|
|
{#if copyStatusMessage}
|
|
<p class={copyStatusKind === "error" ? "copy-status error" : "copy-status"}>{copyStatusMessage}</p>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
{:else if routeMode === "upload"}
|
|
<section class="panel">
|
|
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
|
<p class="helper">Expiration: {defaultTTLDisplay}</p>
|
|
{#if configError}
|
|
<p class="helper warning">Could not load UI config: {configError}</p>
|
|
{/if}
|
|
{#if !uploadAllowed}
|
|
<section id="error" class="error">
|
|
Uploads are disabled for your current IP based on server allowlist settings.
|
|
</section>
|
|
{:else}
|
|
<form id="scratch-form" onsubmit={onSubmit}>
|
|
<section id="file-panel" class="input-panel">
|
|
<label for="file">Select a file to upload</label>
|
|
<input
|
|
id="file"
|
|
name="file"
|
|
type="file"
|
|
onchange={onFileChange}
|
|
disabled={loading}
|
|
/>
|
|
</section>
|
|
|
|
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
|
{#if loading}Creating...{:else}Create scratch{/if}
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if errorMessage}
|
|
<section id="error" class="error">{errorMessage}</section>
|
|
{/if}
|
|
</section>
|
|
{:else if routeMode === "expired"}
|
|
<section class="panel">
|
|
<h1>Scratch expired</h1>
|
|
<p>{viewError || "The scratch you requested is no longer available because its retention time elapsed."}</p>
|
|
</section>
|
|
{:else}
|
|
<section class="panel">
|
|
<h1>Page not found</h1>
|
|
<p>The requested path does not exist.</p>
|
|
</section>
|
|
{/if}
|
|
|
|
</main>
|
|
|
|
<style>
|
|
:global(body) {
|
|
margin: 0;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
background: #0b0e14;
|
|
color: #e7ebf3;
|
|
}
|
|
|
|
.container {
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 2rem 1.25rem 3rem;
|
|
}
|
|
|
|
.site-header,
|
|
.panel {
|
|
border: 1px solid #2a3243;
|
|
border-radius: 10px;
|
|
background: #101726;
|
|
}
|
|
|
|
.site-header {
|
|
padding: 1rem 1.1rem;
|
|
}
|
|
|
|
.site-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.brand-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
}
|
|
|
|
.brand {
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
color: #f5f7fc;
|
|
}
|
|
|
|
.site-nav {
|
|
display: flex;
|
|
gap: 0.85rem;
|
|
}
|
|
|
|
.panel {
|
|
margin-top: 1rem;
|
|
padding: 1.2rem;
|
|
}
|
|
|
|
.helper {
|
|
color: #b1b7c7;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.link-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.45rem 0.8rem;
|
|
border: 1px solid #2f67c8;
|
|
border-radius: 8px;
|
|
background: #213f75;
|
|
color: #f2f5fb;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.nav-button {
|
|
background: #162640;
|
|
border-color: #355487;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.warning {
|
|
color: #f4c16e;
|
|
}
|
|
|
|
.scratch-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.action-button {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.copy-status {
|
|
margin-top: 0.35rem;
|
|
color: #9be8ac;
|
|
}
|
|
|
|
.input-panel,
|
|
#error {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
input[type="file"] {
|
|
margin-top: 0.5rem;
|
|
width: 100%;
|
|
background: #121826;
|
|
color: #e7ebf3;
|
|
border: 1px solid #2a3243;
|
|
border-radius: 8px;
|
|
padding: 0.7rem;
|
|
}
|
|
|
|
.submit-button {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
button[disabled] {
|
|
opacity: 0.65;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
pre {
|
|
max-width: 100%;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
background: #101726;
|
|
border: 1px solid #2a3243;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.scratch-image {
|
|
display: block;
|
|
max-width: 100%;
|
|
max-height: 70vh;
|
|
border: 1px solid #2a3243;
|
|
border-radius: 8px;
|
|
background: #0b0e14;
|
|
}
|
|
|
|
.scratch-video {
|
|
display: block;
|
|
width: 100%;
|
|
max-height: 70vh;
|
|
border: 1px solid #2a3243;
|
|
border-radius: 8px;
|
|
background: #0b0e14;
|
|
}
|
|
|
|
.error {
|
|
color: #ff9b9b;
|
|
}
|
|
</style>
|