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:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
client/node_modules
|
||||
client/dist
|
||||
server/node_modules
|
||||
server/dist
|
||||
data
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
node_modules
|
||||
.env
|
||||
.git
|
||||
README.md
|
||||
IMPLEMENTATION.md
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Image is built and pushed by .gitea/workflows/docker.yml
|
||||
IMAGE=your-registry.example.com/your-org/wisp:latest
|
||||
|
||||
# Public URL is fronted by a reverse proxy — this container just needs to
|
||||
# listen on HOST_PORT.
|
||||
HOST_PORT=3040
|
||||
|
||||
TTL_SECONDS=259200
|
||||
MAX_UPLOAD_BYTES=209715200
|
||||
UPLOAD_PASSWORD=change-me
|
||||
53
.gitea/workflows/docker.yml
Normal file
53
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image name and tags
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
|
||||
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
|
||||
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
|
||||
fi
|
||||
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
|
||||
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
|
||||
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
|
||||
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.host }}
|
||||
username: ${{ github.actor }}
|
||||
# Create a Gitea PAT with packages:write scope and add it as a
|
||||
# repository secret named TKNTKN (Settings → Secrets)
|
||||
password: ${{ secrets.TKNTKN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
data
|
||||
*.tsbuildinfo
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# ── Stage 1: build the Vite client ───────────────────────────────────────────
|
||||
FROM node:24-slim AS client-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY client/package*.json client/
|
||||
RUN npm install --prefix client
|
||||
|
||||
COPY client/ client/
|
||||
RUN npm run build --prefix client
|
||||
|
||||
# ── Stage 2: compile the TypeScript server ────────────────────────────────────
|
||||
FROM node:24-slim AS server-build
|
||||
|
||||
# better-sqlite3 has a native addon with no prebuilt binary for this
|
||||
# platform/Node combo yet — build it from source.
|
||||
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server/package*.json server/
|
||||
COPY server/tsconfig.json server/
|
||||
RUN npm install --prefix server
|
||||
|
||||
COPY server/src/ server/src/
|
||||
RUN npm run build --prefix server
|
||||
|
||||
# ── Stage 3: runtime ──────────────────────────────────────────────────────────
|
||||
FROM node:24-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# better-sqlite3 needs to rebuild its native addon here too, then the
|
||||
# build toolchain is dropped again to keep the runtime image lean.
|
||||
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Production-only server dependencies (no tsx, no typescript)
|
||||
COPY server/package*.json server/
|
||||
RUN npm install --prefix server --omit=dev \
|
||||
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||
|
||||
# Compiled server + built client
|
||||
COPY --from=server-build /app/server/dist server/dist
|
||||
COPY --from=client-build /app/client/dist client/dist
|
||||
|
||||
# Do NOT copy data/ — that's a mutable runtime volume holding the SQLite
|
||||
# DB and ciphertext blobs. Baking it into the image would clobber it on
|
||||
# every redeploy.
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
@@ -134,6 +134,8 @@ disk space per upload:
|
||||
(Ed25519 pubkey signs the upload request) to unlock a larger size limit
|
||||
or bypass rate limits — reuses identity infra that already exists rather
|
||||
than building new auth. Optional, not required for v1.
|
||||
- For first version - require a password before showing upload screen. Configurable in .env
|
||||
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 explewd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE}
|
||||
container_name: wisp
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${HOST_PORT:-3040}:3000"
|
||||
environment:
|
||||
- TTL_SECONDS=${TTL_SECONDS:-259200}
|
||||
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-209715200}
|
||||
- UPLOAD_PASSWORD=${UPLOAD_PASSWORD}
|
||||
volumes:
|
||||
# Named volume, not a bind mount into the build context — the SQLite
|
||||
# DB and ciphertext blobs live here and must survive image redeploys.
|
||||
- wisp-data:/app/data
|
||||
|
||||
volumes:
|
||||
wisp-data:
|
||||
3290
package-lock.json
generated
Normal file
3290
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "wisp",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n client,server -c cyan,yellow \"npm run dev --workspace=client\" \"npm run dev --workspace=server\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
22
server/package.json
Normal file
22
server/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "wisp-server",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=../.env src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node --env-file=../.env dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.7.4",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
24
server/src/cleanup.ts
Normal file
24
server/src/cleanup.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs'
|
||||
import { config } from './config.js'
|
||||
import { db, blobPath } from './db.js'
|
||||
|
||||
// Sweeps expired-but-never-confirmed drops. The TTL is the backstop for
|
||||
// links nobody ever opens; confirmed downloads are deleted immediately by
|
||||
// the confirm endpoint instead.
|
||||
export function sweepExpiredDrops(): void {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const expired = db
|
||||
.prepare(`SELECT id FROM drops WHERE expires_at < ? AND burned = 0`)
|
||||
.all(now) as { id: string }[]
|
||||
|
||||
for (const { id } of expired) {
|
||||
fs.rm(blobPath(id), { force: true }, () => {})
|
||||
db.prepare(`UPDATE drops SET burned = 1 WHERE id = ?`).run(id)
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM confirm_tokens WHERE expires_at < ?`).run(now)
|
||||
}
|
||||
|
||||
export function startCleanupJob(): NodeJS.Timeout {
|
||||
return setInterval(sweepExpiredDrops, config.cleanupIntervalSeconds * 1000)
|
||||
}
|
||||
15
server/src/config.ts
Normal file
15
server/src/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from 'node:path'
|
||||
|
||||
if (!process.env.UPLOAD_PASSWORD) {
|
||||
console.error('UPLOAD_PASSWORD is not set — refusing to start with an open upload gate.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
|
||||
ttlSeconds: Number(process.env.TTL_SECONDS ?? 60 * 60 * 24 * 3), // 3 days
|
||||
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 200 * 1024 * 1024), // 200MB
|
||||
uploadPassword: process.env.UPLOAD_PASSWORD,
|
||||
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
|
||||
}
|
||||
35
server/src/db.ts
Normal file
35
server/src/db.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { config } from './config.js'
|
||||
|
||||
fs.mkdirSync(config.dataDir, { recursive: true })
|
||||
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
|
||||
|
||||
export const db = new Database(path.join(config.dataDir, 'wisp.db'))
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS drops (
|
||||
id TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
downloaded_at INTEGER,
|
||||
burned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Single-use tokens issued at download time, redeemed by the confirm
|
||||
-- endpoint. Kept in SQLite (not memory) so a server restart between
|
||||
-- download and confirm doesn't strand the client with an unredeemable
|
||||
-- token.
|
||||
CREATE TABLE IF NOT EXISTS confirm_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
drop_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
export function blobPath(id: string): string {
|
||||
return path.join(config.dataDir, 'blobs', id)
|
||||
}
|
||||
27
server/src/ids.ts
Normal file
27
server/src/ids.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
// 128-bit random value, rendered as base62 — matches the blob-id shape
|
||||
// described in IMPLEMENTATION.md (unguessable, distinct from the key).
|
||||
function randomBase62(bits: number): string {
|
||||
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
|
||||
let value = 0n
|
||||
for (const b of bytes) value = (value << 8n) | BigInt(b)
|
||||
|
||||
let out = ''
|
||||
const base = BigInt(BASE62.length)
|
||||
while (value > 0n) {
|
||||
out = BASE62[Number(value % base)] + out
|
||||
value /= base
|
||||
}
|
||||
return out.padStart(Math.ceil((bits / Math.log2(62))), '0')
|
||||
}
|
||||
|
||||
export function newBlobId(): string {
|
||||
return randomBase62(128)
|
||||
}
|
||||
|
||||
export function newConfirmToken(): string {
|
||||
return randomBase62(128)
|
||||
}
|
||||
21
server/src/index.ts
Normal file
21
server/src/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { config } from './config.js'
|
||||
import { router } from './routes.js'
|
||||
import { startCleanupJob, sweepExpiredDrops } from './cleanup.js'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use('/api', router)
|
||||
|
||||
const clientDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../client/dist')
|
||||
app.use(express.static(clientDist))
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(clientDist, 'index.html')))
|
||||
|
||||
sweepExpiredDrops()
|
||||
startCleanupJob()
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`wisp server listening on :${config.port}`)
|
||||
})
|
||||
123
server/src/routes.ts
Normal file
123
server/src/routes.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Router } from 'express'
|
||||
import fs from 'node:fs'
|
||||
import { config } from './config.js'
|
||||
import { db, blobPath } from './db.js'
|
||||
import { newBlobId, newConfirmToken } from './ids.js'
|
||||
|
||||
export const router = Router()
|
||||
|
||||
function checkUploadPassword(req: import('express').Request): boolean {
|
||||
return req.get('X-Upload-Password') === config.uploadPassword
|
||||
}
|
||||
|
||||
// Lets the client gate the upload UI behind a password without uploading
|
||||
// anything yet.
|
||||
router.post('/auth/check', (req, res) => {
|
||||
res.json({ ok: checkUploadPassword(req) })
|
||||
})
|
||||
|
||||
router.post('/drops', (req, res) => {
|
||||
if (!checkUploadPassword(req)) {
|
||||
res.status(401).json({ error: 'bad password' })
|
||||
return
|
||||
}
|
||||
|
||||
const contentLength = Number(req.get('Content-Length') ?? 0)
|
||||
if (contentLength > config.maxUploadBytes) {
|
||||
res.status(413).json({ error: 'file too large' })
|
||||
return
|
||||
}
|
||||
|
||||
const id = newBlobId()
|
||||
const dest = blobPath(id)
|
||||
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
|
||||
|
||||
let bytesReceived = 0
|
||||
let aborted = false
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
bytesReceived += chunk.length
|
||||
if (bytesReceived > config.maxUploadBytes) {
|
||||
aborted = true
|
||||
writeStream.destroy()
|
||||
req.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
req.pipe(writeStream)
|
||||
|
||||
writeStream.on('error', () => {
|
||||
fs.rm(dest, { force: true }, () => {})
|
||||
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
|
||||
})
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
if (aborted) {
|
||||
fs.rm(dest, { force: true }, () => {})
|
||||
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const expiresAt = now + config.ttlSeconds
|
||||
db.prepare(
|
||||
`INSERT INTO drops (id, size_bytes, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
).run(id, bytesReceived, now, expiresAt)
|
||||
|
||||
res.json({ id, ttl: config.ttlSeconds })
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/drops/:id', (req, res) => {
|
||||
const drop = db
|
||||
.prepare(`SELECT * FROM drops WHERE id = ?`)
|
||||
.get(req.params.id) as
|
||||
| { id: string; burned: number; expires_at: number }
|
||||
| undefined
|
||||
|
||||
if (!drop || drop.burned || drop.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
res.status(404).json({ error: 'not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const path = blobPath(drop.id)
|
||||
if (!fs.existsSync(path)) {
|
||||
res.status(404).json({ error: 'not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = newConfirmToken()
|
||||
const tokenExpiresAt = Math.floor(Date.now() / 1000) + 60 * 15
|
||||
db.prepare(
|
||||
`INSERT INTO confirm_tokens (token, drop_id, expires_at) VALUES (?, ?, ?)`,
|
||||
).run(token, drop.id, tokenExpiresAt)
|
||||
|
||||
res.setHeader('X-Confirm-Token', token)
|
||||
res.setHeader('Content-Type', 'application/octet-stream')
|
||||
fs.createReadStream(path).pipe(res)
|
||||
})
|
||||
|
||||
router.post('/drops/:id/confirm', (req, res) => {
|
||||
const auth = req.get('Authorization') ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : ''
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM confirm_tokens WHERE token = ?`)
|
||||
.get(token) as { token: string; drop_id: string; expires_at: number } | undefined
|
||||
|
||||
if (!row || row.drop_id !== req.params.id || row.expires_at < Math.floor(Date.now() / 1000)) {
|
||||
res.status(400).json({ error: 'invalid or expired confirm token' })
|
||||
return
|
||||
}
|
||||
|
||||
// Single-use: delete the token before doing anything else.
|
||||
db.prepare(`DELETE FROM confirm_tokens WHERE token = ?`).run(token)
|
||||
|
||||
fs.rm(blobPath(row.drop_id), { force: true }, () => {})
|
||||
db.prepare(`UPDATE drops SET burned = 1, downloaded_at = ? WHERE id = ?`).run(
|
||||
Math.floor(Date.now() / 1000),
|
||||
row.drop_id,
|
||||
)
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
14
server/tsconfig.json
Normal file
14
server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user