Implement wisp: async encrypted single-retrieval file drop
All checks were successful
Docker / build-and-push (push) Successful in 3m33s
All checks were successful
Docker / build-and-push (push) Successful in 3m33s
Client-side libsodium encryption with the key in the URL fragment, an Express/SQLite server holding ciphertext until a confirm-token round trip proves successful decrypt (avoiding the delete-on-first-byte race), TTL sweep for unclaimed drops, and a password-gated upload UI styled to match flit. Dockerized to match the project family's conventions, with a named volume so the DB/blobs survive redeploys, and a Gitea Actions workflow to build and push the image.
This commit is contained in:
14
client/index.html
Normal file
14
client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>wisp</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
client/package.json
Normal file
18
client/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "wisp-client",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers": "^0.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
client/public/favicon.svg
Normal file
6
client/public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#080808"/>
|
||||
<text x="16" y="23" text-anchor="middle"
|
||||
font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace"
|
||||
font-size="19" font-weight="700" fill="#00e87a">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
44
client/src/api.ts
Normal file
44
client/src/api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export async function checkPassword(password: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/check', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Upload-Password': password },
|
||||
})
|
||||
const body = (await res.json()) as { ok: boolean }
|
||||
return body.ok
|
||||
}
|
||||
|
||||
export async function uploadDrop(
|
||||
ciphertext: Uint8Array,
|
||||
password: string,
|
||||
): Promise<{ id: string; ttl: number }> {
|
||||
const res = await fetch('/api/drops', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Upload-Password': password,
|
||||
},
|
||||
body: ciphertext.slice().buffer,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error ?? `upload failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function downloadDrop(
|
||||
id: string,
|
||||
): Promise<{ ciphertext: Uint8Array; confirmToken: string }> {
|
||||
const res = await fetch(`/api/drops/${id}`)
|
||||
if (!res.ok) throw new Error(`download failed: ${res.status}`)
|
||||
const confirmToken = res.headers.get('X-Confirm-Token') ?? ''
|
||||
const ciphertext = new Uint8Array(await res.arrayBuffer())
|
||||
return { ciphertext, confirmToken }
|
||||
}
|
||||
|
||||
export async function confirmDrop(id: string, confirmToken: string): Promise<void> {
|
||||
await fetch(`/api/drops/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${confirmToken}` },
|
||||
})
|
||||
}
|
||||
65
client/src/crypto.ts
Normal file
65
client/src/crypto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import sodium from 'libsodium-wrappers'
|
||||
|
||||
export interface EncryptedPayload {
|
||||
ciphertext: Uint8Array
|
||||
keyB64: string
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
filename: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const p of parts) {
|
||||
out.set(p, offset)
|
||||
offset += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function encryptFile(file: File): Promise<EncryptedPayload> {
|
||||
await sodium.ready
|
||||
|
||||
const key = sodium.crypto_secretbox_keygen()
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
|
||||
|
||||
const envelope: Envelope = { filename: file.name, contentType: file.type }
|
||||
const envelopeBytes = sodium.from_string(JSON.stringify(envelope))
|
||||
const envelopeLen = new Uint8Array(4)
|
||||
new DataView(envelopeLen.buffer).setUint32(0, envelopeBytes.length, false)
|
||||
|
||||
const fileBytes = new Uint8Array(await file.arrayBuffer())
|
||||
const plaintext = concat(envelopeLen, envelopeBytes, fileBytes)
|
||||
|
||||
const sealed = sodium.crypto_secretbox_easy(plaintext, nonce, key)
|
||||
const ciphertext = concat(nonce, sealed)
|
||||
|
||||
return { ciphertext, keyB64: sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING) }
|
||||
}
|
||||
|
||||
export async function decryptBlob(
|
||||
ciphertext: Uint8Array,
|
||||
keyB64: string,
|
||||
): Promise<{ file: Blob; filename: string }> {
|
||||
await sodium.ready
|
||||
|
||||
const key = sodium.from_base64(keyB64, sodium.base64_variants.URLSAFE_NO_PADDING)
|
||||
const nonce = ciphertext.slice(0, sodium.crypto_secretbox_NONCEBYTES)
|
||||
const sealed = ciphertext.slice(sodium.crypto_secretbox_NONCEBYTES)
|
||||
|
||||
const plaintext = sodium.crypto_secretbox_open_easy(sealed, nonce, key)
|
||||
|
||||
const envelopeLen = new DataView(plaintext.buffer, plaintext.byteOffset, 4).getUint32(0, false)
|
||||
const envelopeBytes = plaintext.slice(4, 4 + envelopeLen)
|
||||
const envelope: Envelope = JSON.parse(sodium.to_string(envelopeBytes))
|
||||
const fileBytes = plaintext.slice(4 + envelopeLen)
|
||||
|
||||
return {
|
||||
file: new Blob([fileBytes], { type: envelope.contentType || 'application/octet-stream' }),
|
||||
filename: envelope.filename,
|
||||
}
|
||||
}
|
||||
45
client/src/download.ts
Normal file
45
client/src/download.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { decryptBlob } from './crypto'
|
||||
import { downloadDrop, confirmDrop } from './api'
|
||||
import { renderHelpButton } from './help'
|
||||
|
||||
export function renderDownloadPage(root: HTMLElement, id: string): void {
|
||||
root.innerHTML = ''
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'header-row'
|
||||
header.innerHTML = `<h1>wisp</h1>`
|
||||
renderHelpButton(header)
|
||||
root.appendChild(header)
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'card'
|
||||
card.innerHTML = `<div id="status" class="status-row"><span class="spinner"></span>fetching…</div>`
|
||||
root.appendChild(card)
|
||||
|
||||
const status = card.querySelector<HTMLElement>('#status')!
|
||||
|
||||
run(id, status).catch((err) => {
|
||||
status.outerHTML = `<div class="error-banner">${err instanceof Error ? err.message : 'Something went wrong.'}</div>`
|
||||
})
|
||||
}
|
||||
|
||||
async function run(id: string, status: HTMLElement): Promise<void> {
|
||||
const key = new URLSearchParams(location.hash.slice(1)).get('k')
|
||||
if (!key) throw new Error('Missing decryption key — check the full link was copied.')
|
||||
|
||||
const { ciphertext, confirmToken } = await downloadDrop(id)
|
||||
|
||||
status.innerHTML = `<span class="spinner"></span>decrypting…`
|
||||
const { file, filename } = await decryptBlob(ciphertext, key)
|
||||
|
||||
if (confirmToken) await confirmDrop(id, confirmToken)
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename || 'download'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
status.outerHTML = `<p class="hint">downloaded ${filename || 'file'} — this link is now burned</p>`
|
||||
}
|
||||
49
client/src/help.ts
Normal file
49
client/src/help.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export function renderHelpButton(container: HTMLElement): void {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'help-btn'
|
||||
btn.textContent = '?'
|
||||
btn.title = 'How wisp works'
|
||||
btn.addEventListener('click', openHelpModal)
|
||||
container.appendChild(btn)
|
||||
}
|
||||
|
||||
function openHelpModal(): void {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<span>how wisp works</span>
|
||||
<button class="help-btn" id="modal-close">×</button>
|
||||
</div>
|
||||
<h3>flow</h3>
|
||||
<ol>
|
||||
<li>Your file is encrypted in the browser, before anything is sent.</li>
|
||||
<li>Only the ciphertext is uploaded. The decryption key stays in the
|
||||
link's fragment (after <code>#</code>), which browsers never send
|
||||
to the server.</li>
|
||||
<li>You share the link however you like.</li>
|
||||
<li>The recipient opens it, downloads the ciphertext, and decrypts
|
||||
it locally. Once that succeeds, the server deletes the file.</li>
|
||||
</ol>
|
||||
<h3>security</h3>
|
||||
<ul>
|
||||
<li>The server never sees the filename, contents, or key —
|
||||
only opaque encrypted bytes.</li>
|
||||
<li>Each link works once. After a successful download it's burned;
|
||||
unopened links expire automatically after the TTL.</li>
|
||||
<li>The link itself is the credential — anyone who has the
|
||||
full link (including the <code>#k=...</code> part) can retrieve
|
||||
the file once. Share it over a channel you trust.</li>
|
||||
<li>This does not protect against a recipient who saves or
|
||||
re-shares the file after decrypting it — that's out of
|
||||
scope by design, same as it is for flit.</li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove()
|
||||
})
|
||||
overlay.querySelector('#modal-close')!.addEventListener('click', () => overlay.remove())
|
||||
document.body.appendChild(overlay)
|
||||
}
|
||||
11
client/src/main.ts
Normal file
11
client/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { renderUploadPage } from './upload'
|
||||
import { renderDownloadPage } from './download'
|
||||
|
||||
const root = document.querySelector<HTMLElement>('#app')!
|
||||
const match = location.pathname.match(/^\/d\/([^/]+)/)
|
||||
|
||||
if (match) {
|
||||
renderDownloadPage(root, match[1])
|
||||
} else {
|
||||
renderUploadPage(root)
|
||||
}
|
||||
265
client/src/style.css
Normal file
265
client/src/style.css
Normal file
@@ -0,0 +1,265 @@
|
||||
:root {
|
||||
--bg: #080808;
|
||||
--bg-alt: #0e0e0e;
|
||||
--text: #c8c8c8;
|
||||
--muted: #555;
|
||||
--heading: #f0f0f0;
|
||||
--accent: #00e87a;
|
||||
--accent-dim: rgba(0, 232, 122, 0.12);
|
||||
--accent-border:rgba(0, 232, 122, 0.25);
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
|
||||
|
||||
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
|
||||
font: 16px/1.6 var(--sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
color-scheme: dark;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
#app {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
|
||||
p { margin: 0; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-row h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-row h1::before {
|
||||
content: '> ';
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.help-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Card / stack ───────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
background: var(--bg-alt);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.hint {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #00ff88; border-color: #00ff88; color: #000; }
|
||||
|
||||
/* ── Inputs ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.text-input {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.text-input::placeholder { color: var(--muted); }
|
||||
.text-input:focus { outline: none; border-color: var(--accent-border); }
|
||||
|
||||
/* ── Dropzone ───────────────────────────────────────────────────────── */
|
||||
|
||||
.dropzone {
|
||||
position: relative;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropzone::before { content: '+ '; color: var(--accent); }
|
||||
|
||||
.dropzone input[type='file'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dropzone:hover { border-color: var(--accent-border); background: var(--accent-dim); color: var(--heading); }
|
||||
|
||||
/* ── Status / error ─────────────────────────────────────────────────── */
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(0,232,122,0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-banner {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: #ff6b6b;
|
||||
border: 1px solid rgba(255,107,107,0.25);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,107,107,0.06);
|
||||
}
|
||||
|
||||
/* ── Result link ────────────────────────────────────────────────────── */
|
||||
|
||||
.link-box {
|
||||
word-break: break-all;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Help modal ─────────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.modal-card ol, .modal-card ul {
|
||||
padding-left: 1.2em;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.modal-card li { margin-bottom: 0.5em; font-size: 14px; }
|
||||
|
||||
.modal-card h3 { font-size: 13px; margin: 0 0 0.5em; color: var(--accent); }
|
||||
109
client/src/upload.ts
Normal file
109
client/src/upload.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { encryptFile } from './crypto'
|
||||
import { checkPassword, uploadDrop } from './api'
|
||||
import { renderHelpButton } from './help'
|
||||
|
||||
function renderHeader(root: HTMLElement): void {
|
||||
const header = document.createElement('div')
|
||||
header.className = 'header-row'
|
||||
header.innerHTML = `<h1>wisp</h1>`
|
||||
renderHelpButton(header)
|
||||
root.appendChild(header)
|
||||
}
|
||||
|
||||
export function renderUploadPage(root: HTMLElement): void {
|
||||
root.innerHTML = ''
|
||||
renderHeader(root)
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'card'
|
||||
card.innerHTML = `
|
||||
<form id="password-form" class="stack">
|
||||
<input id="password" class="text-input" type="password" placeholder="password" autocomplete="off" />
|
||||
<button type="submit" class="btn btn-primary">unlock</button>
|
||||
</form>
|
||||
<div id="password-error"></div>
|
||||
`
|
||||
root.appendChild(card)
|
||||
|
||||
const passwordForm = card.querySelector<HTMLFormElement>('#password-form')!
|
||||
passwordForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const password = card.querySelector<HTMLInputElement>('#password')!.value
|
||||
const errorEl = card.querySelector<HTMLElement>('#password-error')!
|
||||
errorEl.innerHTML = ''
|
||||
|
||||
const ok = await checkPassword(password)
|
||||
if (!ok) {
|
||||
errorEl.innerHTML = `<div class="error-banner">Wrong password.</div>`
|
||||
return
|
||||
}
|
||||
renderUploadForm(root, password)
|
||||
})
|
||||
}
|
||||
|
||||
function renderUploadForm(root: HTMLElement, password: string): void {
|
||||
root.innerHTML = ''
|
||||
renderHeader(root)
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'card'
|
||||
card.innerHTML = `
|
||||
<form id="upload-form" class="stack">
|
||||
<label class="dropzone">
|
||||
<span id="file-label">drop a file, or click to choose</span>
|
||||
<input id="file" type="file" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">encrypt & upload</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
<div id="result"></div>
|
||||
`
|
||||
root.appendChild(card)
|
||||
|
||||
const fileInput = card.querySelector<HTMLInputElement>('#file')!
|
||||
const fileLabel = card.querySelector<HTMLElement>('#file-label')!
|
||||
fileInput.addEventListener('change', () => {
|
||||
fileLabel.textContent = fileInput.files?.[0]?.name ?? 'drop a file, or click to choose'
|
||||
})
|
||||
|
||||
const form = card.querySelector<HTMLFormElement>('#upload-form')!
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const status = card.querySelector<HTMLElement>('#status')!
|
||||
const result = card.querySelector<HTMLElement>('#result')!
|
||||
const file = fileInput.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
status.innerHTML = `<div class="status-row"><span class="spinner"></span>encrypting…</div>`
|
||||
result.innerHTML = ''
|
||||
try {
|
||||
const { ciphertext, keyB64 } = await encryptFile(file)
|
||||
status.innerHTML = `<div class="status-row"><span class="spinner"></span>uploading…</div>`
|
||||
const { id } = await uploadDrop(ciphertext, password)
|
||||
|
||||
const link = `${location.origin}/d/${id}#k=${keyB64}`
|
||||
status.innerHTML = ''
|
||||
result.innerHTML = `
|
||||
<div class="stack">
|
||||
<p class="hint">share this link — it works once</p>
|
||||
<div class="link-box">${link}</div>
|
||||
<button type="button" id="copy-link" class="btn">copy to clipboard</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const copyBtn = result.querySelector<HTMLButtonElement>('#copy-link')!
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
copyBtn.textContent = 'copied!'
|
||||
} catch {
|
||||
copyBtn.textContent = 'copy failed — select manually'
|
||||
}
|
||||
setTimeout(() => (copyBtn.textContent = 'copy to clipboard'), 1500)
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed.'
|
||||
status.innerHTML = `<div class="error-banner">${message}</div>`
|
||||
}
|
||||
})
|
||||
}
|
||||
12
client/tsconfig.json
Normal file
12
client/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
client/vite.config.ts
Normal file
9
client/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user