<!DOCTYPE html><html lang="en"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" type="image/svg+xml" href="/favicon.svg"><title>A file was requested from you — dove</title><meta name="description" content="Encrypted on your device before upload. The key never touches a server."><meta name="theme-color" content="#fbfcff" media="(prefers-color-scheme: light)"><meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)"><meta property="og:title" content="A file was requested from you — dove"><meta property="og:description" content="Encrypted on your device before upload. The key never touches a server."><meta property="og:type" content="website"><meta property="og:image" content="__OGBASE__/og-request.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="__OGBASE__/og-request.png"><style>@import"https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap";:root{color-scheme:light dark;--paper: #fbfcff;--paper-deep: #eef3f9;--ink: #111c2f;--muted: #58667a;--line: #d8e0ec;--panel: rgba(255, 255, 255, .88);--green: #2878c8;--green-strong: #164f8a;--green-soft: #e6f2ff;--yellow: #a8d8ff;--shadow: 0 24px 70px rgba(31, 75, 122, .12);--sans: "Manrope", system-ui, sans-serif;--mono: "DM Mono", ui-monospace, monospace}@media(prefers-color-scheme:dark){:root{--paper: #0b1220;--paper-deep: #121d2e;--ink: #f5f8fc;--muted: #aab7c9;--line: #29384d;--panel: rgba(18, 29, 46, .86);--green: #73b9f4;--green-strong: #9acdf7;--green-soft: #132c48;--yellow: #b8ddfb;--shadow: 0 22px 65px rgba(0, 0, 0, .34)}}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 15% -5%,color-mix(in srgb,var(--green-soft) 80%,transparent),transparent 36rem),var(--paper);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}a{color:inherit;text-underline-offset:.2em}button,input{font:inherit}button{color:inherit}::selection{background:var(--yellow);color:#111c2f}.shell{width:min(1120px,calc(100% - 40px));margin:0 auto}.site-header{display:flex;justify-content:space-between;align-items:center;padding:24px 0}.brand{display:inline-flex;align-items:center;gap:10px;font-weight:700;text-decoration:none;letter-spacing:-.03em}.mark{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;background:linear-gradient(145deg,#3389d8,#164f8a);color:#fff;box-shadow:0 5px 14px #164f8a38}.mark svg{width:21px;height:21px;stroke-width:1.8}.nav{display:flex;align-items:center;gap:24px;color:var(--muted);font-size:.9rem}.nav a{text-decoration:none}.nav a:hover{color:var(--ink)}.button{display:inline-flex;justify-content:center;align-items:center;gap:9px;min-height:46px;padding:0 19px;border:1px solid var(--green-strong);border-radius:8px;background:var(--green-strong);color:var(--paper);text-decoration:none;font-weight:600;cursor:pointer}.button:hover{transform:translateY(-1px)}.button.secondary{background:transparent;color:var(--green-strong)}.eyebrow{display:inline-flex;align-items:center;gap:9px;font-family:var(--mono);color:var(--green);font-size:.75rem;letter-spacing:.08em;text-transform:uppercase}.eyebrow:before{content:"";width:22px;border-top:1px solid currentColor}.mono{font-family:var(--mono)}.site-footer{display:flex;justify-content:space-between;gap:24px;padding:34px 0 44px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}.site-footer a{text-decoration:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}:focus-visible{outline:3px solid #80c6ff;outline-offset:3px}@media(max-width:640px){.shell{width:min(100% - 28px,1120px)}.nav a:not(.button){display:none}.site-footer{flex-direction:column}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition-duration:.01ms!important;animation-duration:.01ms!important}}
body{min-height:100vh;background:radial-gradient(circle at 50% -15%,var(--green-soft),transparent 42rem),var(--paper)}[data-astro-cid-hbtokfri][hidden]{display:none!important}.request-shell[data-astro-cid-hbtokfri]{width:min(760px,calc(100% - 36px));min-height:100vh;margin:0 auto;display:flex;flex-direction:column}.secure[data-astro-cid-hbtokfri]{display:inline-flex;align-items:center;gap:7px;color:var(--green);font:.72rem var(--mono);letter-spacing:.06em;text-transform:uppercase}.request-main[data-astro-cid-hbtokfri]{flex:1;padding:64px 0 76px}.request-card[data-astro-cid-hbtokfri]{padding:44px;border:1px solid var(--line);border-radius:16px;background:color-mix(in srgb,var(--paper) 88%,transparent);box-shadow:var(--shadow);backdrop-filter:blur(14px)}.requester[data-astro-cid-hbtokfri]{display:flex;align-items:center;gap:13px}.requester-glyph[data-astro-cid-hbtokfri]{width:48px;height:48px;display:grid;place-items:center;flex:0 0 auto;border-radius:14px;color:var(--green-strong);background:var(--green-soft)}.overline[data-astro-cid-hbtokfri]{margin:0 0 4px;color:var(--green);font:.69rem var(--mono);letter-spacing:.07em;text-transform:uppercase}.requester[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri]{display:block;font-size:.95rem}h1[data-astro-cid-hbtokfri]{margin:27px 0 24px;max-width:600px;font-size:clamp(2rem,6vw,3.25rem);line-height:1.08;letter-spacing:-.055em}.message[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:auto 1fr;gap:12px;padding:17px 18px;border-left:3px solid var(--green);border-radius:0 9px 9px 0;background:var(--green-soft)}.message[data-astro-cid-hbtokfri]>span[data-astro-cid-hbtokfri]{color:var(--green-strong);margin-top:2px}.message[data-astro-cid-hbtokfri] p[data-astro-cid-hbtokfri]{margin:0;color:var(--muted);font-size:.7rem;font-weight:650;text-transform:uppercase;letter-spacing:.05em}blockquote[data-astro-cid-hbtokfri]{margin:4px 0 0;color:var(--ink);font-size:.91rem;line-height:1.6}.request-policy[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:1fr 1fr;gap:1px;margin:28px 0 34px;overflow:hidden;border:1px solid var(--line);border-radius:9px;background:var(--line)}.request-policy[data-astro-cid-hbtokfri]>div[data-astro-cid-hbtokfri]{position:relative;padding:18px 18px 18px 51px;background:var(--paper)}.request-policy[data-astro-cid-hbtokfri] svg[data-astro-cid-hbtokfri]{position:absolute;left:18px;top:25px;color:var(--green)}.request-policy[data-astro-cid-hbtokfri] p[data-astro-cid-hbtokfri]{margin:0;color:var(--muted);font-size:.71rem}.request-policy[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri]{display:block;font-size:.88rem}.pin-gate[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:auto 1fr;gap:0 15px;padding-top:32px;border-top:1px solid var(--line)}.pin-glyph[data-astro-cid-hbtokfri]{width:44px;height:44px;display:grid;place-items:center;grid-row:1;border-radius:12px;color:var(--green-strong);background:var(--green-soft)}.pin-copy[data-astro-cid-hbtokfri] h2[data-astro-cid-hbtokfri],.upload-heading[data-astro-cid-hbtokfri] h2[data-astro-cid-hbtokfri]{margin:3px 0 7px;font-size:1.55rem;letter-spacing:-.035em}.pin-copy[data-astro-cid-hbtokfri]>p[data-astro-cid-hbtokfri]:last-child,.upload-heading[data-astro-cid-hbtokfri]>p[data-astro-cid-hbtokfri]:last-child{margin:0;color:var(--muted);font-size:.86rem}form[data-astro-cid-hbtokfri]{grid-column:1 / -1;margin-top:24px}label[data-astro-cid-hbtokfri][for=request-pin]{display:block;margin-bottom:8px;font-size:.78rem;font-weight:650}.pin-row[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:1fr auto;gap:10px}input[data-astro-cid-hbtokfri][type=password]{min-width:0;height:56px;padding:0 10px 0 24px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);font:1.45rem var(--mono);letter-spacing:.45em}input[data-astro-cid-hbtokfri]::placeholder{color:var(--line)}input[data-astro-cid-hbtokfri][aria-invalid=true]{border-color:#bd5a58}.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri]{min-width:150px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:0;border-radius:8px;background:var(--green-strong);color:var(--paper);font-weight:650;cursor:pointer}.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri]:disabled{opacity:.82;cursor:wait}.pin-icon[data-astro-cid-hbtokfri]{display:grid;place-items:center}.pin-icon[data-astro-cid-hbtokfri] .loader-i[data-astro-cid-hbtokfri],.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri].verifying .pin-icon[data-astro-cid-hbtokfri] .key-i[data-astro-cid-hbtokfri]{display:none}.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri].verifying .pin-icon[data-astro-cid-hbtokfri] .loader-i[data-astro-cid-hbtokfri]{display:block;animation:spin .8s linear infinite}.message[data-astro-cid-hbtokfri] .msg[data-astro-cid-hbtokfri]{margin-top:6px;color:var(--muted);font-size:.84rem}.pin-error[data-astro-cid-hbtokfri]{min-height:1.3em;margin:8px 0 0;color:#bd5a58;font-size:.75rem;font-weight:600}form[data-astro-cid-hbtokfri]>p[data-astro-cid-hbtokfri]:last-child{margin:1px 0 0;color:var(--muted);font-size:.7rem}.pin-pass[data-astro-cid-hbtokfri]{display:flex;align-items:center;gap:9px;margin-bottom:28px;padding:13px 15px;border:1px solid color-mix(in srgb,var(--green) 30%,var(--line));border-radius:9px;color:var(--green-strong);background:var(--green-soft);font-size:.78rem}.upload-view[data-astro-cid-hbtokfri]{padding-top:32px;border-top:1px solid var(--line)}.dropzone[data-astro-cid-hbtokfri]{min-height:166px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin-top:24px;border:1.5px dashed color-mix(in srgb,var(--green) 55%,var(--line));border-radius:12px;background:color-mix(in srgb,var(--green-soft) 42%,var(--paper));cursor:pointer}.dropzone[data-astro-cid-hbtokfri]:hover{border-color:var(--green)}.drop-icon[data-astro-cid-hbtokfri]{width:44px;height:44px;display:grid;place-items:center;margin-bottom:10px;border-radius:12px;color:var(--green-strong);background:var(--green-soft)}.dropzone[data-astro-cid-hbtokfri]>strong[data-astro-cid-hbtokfri]{font-size:.93rem}.dropzone[data-astro-cid-hbtokfri]>span[data-astro-cid-hbtokfri]:not(.drop-icon){color:var(--muted);font-size:.76rem}.dropzone[data-astro-cid-hbtokfri] input[data-astro-cid-hbtokfri]{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.selected-file[data-astro-cid-hbtokfri],.receipt[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:auto 1fr auto;gap:13px;align-items:center;margin-top:24px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--paper)}.file-glyph[data-astro-cid-hbtokfri]{width:42px;height:42px;display:grid;place-items:center;border-radius:10px;color:var(--green-strong);background:var(--green-soft)}.selected-file[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri],.selected-file[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri],.receipt[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri],.receipt[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri]{display:block}.selected-file[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri],.receipt[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri]{font-size:.84rem;overflow-wrap:anywhere}.selected-file[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri],.receipt[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri]{margin-top:2px;color:var(--muted);font-size:.71rem}.selected-file[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri]{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}.send-button[data-astro-cid-hbtokfri]{width:100%;min-height:58px;display:flex;align-items:center;justify-content:space-between;margin-top:17px;padding:0 18px 0 22px;border:0;border-radius:8px;background:var(--green-strong);color:var(--paper);font-weight:650;cursor:pointer}.send-button[data-astro-cid-hbtokfri]:disabled{opacity:.46;cursor:not-allowed}.send-button[data-astro-cid-hbtokfri]>span[data-astro-cid-hbtokfri]{display:flex;align-items:center;gap:8px}.send-button[data-astro-cid-hbtokfri] b[data-astro-cid-hbtokfri]{width:28px;height:28px;display:grid;place-items:center;border:1px solid color-mix(in srgb,var(--paper) 40%,transparent);border-radius:50%}.action-note[data-astro-cid-hbtokfri]{margin:10px 0 0;color:var(--muted);font-size:.71rem;text-align:center}.uploading[data-astro-cid-hbtokfri]{padding-top:30px;border-top:1px solid var(--line);text-align:center}.uploading-glyph[data-astro-cid-hbtokfri]{width:58px;height:58px;display:grid;place-items:center;margin:0 auto 20px;border-radius:17px;color:var(--green-strong);background:var(--green-soft)}.uploading[data-astro-cid-hbtokfri] h1[data-astro-cid-hbtokfri]{margin:8px auto 27px}.upload-file[data-astro-cid-hbtokfri]{display:flex;align-items:center;gap:9px;padding:14px 16px;border:1px solid var(--line);border-radius:9px;background:var(--paper);font-size:.78rem;text-align:left}.upload-file[data-astro-cid-hbtokfri] svg[data-astro-cid-hbtokfri]{color:var(--green)}.upload-file[data-astro-cid-hbtokfri] strong[data-astro-cid-hbtokfri]{margin-left:auto;color:var(--muted);font-weight:500}.progress-head[data-astro-cid-hbtokfri]{display:flex;justify-content:space-between;margin-top:25px;font-size:.78rem}.progress-head[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri]{color:var(--muted)}.progress[data-astro-cid-hbtokfri]{height:8px;margin-top:10px;overflow:hidden;border-radius:20px;background:var(--paper-deep)}.progress[data-astro-cid-hbtokfri] i[data-astro-cid-hbtokfri]{display:block;width:68%;height:100%;border-radius:inherit;background:var(--green)}.upload-note[data-astro-cid-hbtokfri]{margin:13px 0 0;color:var(--muted);font-size:.72rem}.status-card[data-astro-cid-hbtokfri]{min-height:500px;display:flex;flex-direction:column}.status-card[data-astro-cid-hbtokfri] .requester[data-astro-cid-hbtokfri]{margin-bottom:35px}.status-body[data-astro-cid-hbtokfri]{max-width:570px;margin:auto;text-align:center}.status-body[data-astro-cid-hbtokfri] h1[data-astro-cid-hbtokfri]{margin:7px auto 18px}.status-body[data-astro-cid-hbtokfri]>p[data-astro-cid-hbtokfri]:not(.overline){color:var(--muted);line-height:1.7}.status-glyph[data-astro-cid-hbtokfri]{width:64px;height:64px;display:grid;place-items:center;margin:0 auto 22px;border-radius:18px;color:var(--green-strong);background:var(--green-soft)}.success-glyph[data-astro-cid-hbtokfri]{color:var(--green-strong)}.danger[data-astro-cid-hbtokfri],.danger-glyph[data-astro-cid-hbtokfri]{color:#bd5a58}.danger-glyph[data-astro-cid-hbtokfri]{background:color-mix(in srgb,#bd5a58 13%,var(--paper))}.receipt[data-astro-cid-hbtokfri]{grid-template-columns:auto 1fr;max-width:470px;margin:25px auto 0;text-align:left}.next-step[data-astro-cid-hbtokfri]{display:flex;flex-direction:column;gap:3px;margin-top:25px;padding:17px 18px;border-radius:10px;background:var(--green-soft);font-size:.81rem}.next-step[data-astro-cid-hbtokfri] span[data-astro-cid-hbtokfri]{color:var(--muted)}.trust-note[data-astro-cid-hbtokfri]{display:grid;grid-template-columns:54px 1fr;gap:20px;margin:26px 22px 0;padding:27px 8px 0;border-top:1px solid var(--line)}.key-mark[data-astro-cid-hbtokfri]{width:48px;height:48px;display:grid;place-items:center;border-radius:14px;color:#fff;background:linear-gradient(145deg,#3389d8,#164f8a);box-shadow:0 8px 22px #164f8a2e}.trust-note[data-astro-cid-hbtokfri] h2[data-astro-cid-hbtokfri]{margin:0 0 6px;font-size:1rem}.trust-note[data-astro-cid-hbtokfri] p[data-astro-cid-hbtokfri]{margin:0;color:var(--muted);font-size:.81rem;line-height:1.65}.expectation[data-astro-cid-hbtokfri]{display:flex;align-items:flex-start;gap:11px;margin:24px 22px 0;padding:15px 17px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--muted)}.expectation[data-astro-cid-hbtokfri] svg[data-astro-cid-hbtokfri]{flex:0 0 auto;margin-top:2px;color:var(--green)}.expectation[data-astro-cid-hbtokfri] p[data-astro-cid-hbtokfri]{margin:0;font-size:.73rem;line-height:1.6}.expectation[data-astro-cid-hbtokfri] code[data-astro-cid-hbtokfri]{color:var(--ink);font-family:var(--mono)}.request-footer[data-astro-cid-hbtokfri]{display:flex;justify-content:space-between;gap:20px;padding:24px 0 32px;border-top:1px solid var(--line);color:var(--muted);font-size:.7rem}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri].verifying svg[data-astro-cid-hbtokfri]{animation:none}}@media(max-width:600px){.request-main[data-astro-cid-hbtokfri]{padding-top:40px}.request-card[data-astro-cid-hbtokfri]{padding:28px 20px}.request-policy[data-astro-cid-hbtokfri],.pin-row[data-astro-cid-hbtokfri]{grid-template-columns:1fr}.pin-row[data-astro-cid-hbtokfri] button[data-astro-cid-hbtokfri]{min-height:52px}.selected-file[data-astro-cid-hbtokfri]{grid-template-columns:auto minmax(0,1fr) auto}.trust-note[data-astro-cid-hbtokfri],.expectation[data-astro-cid-hbtokfri]{margin-inline:0}.trust-note[data-astro-cid-hbtokfri]{grid-template-columns:42px 1fr}.key-mark[data-astro-cid-hbtokfri]{width:40px;height:40px}.request-footer[data-astro-cid-hbtokfri]{flex-direction:column}.secure[data-astro-cid-hbtokfri]{font-size:.61rem}.status-card[data-astro-cid-hbtokfri]{min-height:440px}}
</style></head> <body> <div class="request-shell" data-state="loading" data-astro-cid-hbtokfri> <header class="site-header" data-astro-cid-hbtokfri> <a class="brand" href="https://dove.sh" aria-label="dove home"> <span class="mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="lucide lucide-bird"> <path d="M16 7h.01"></path><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"></path><path d="m20 7 2 .5-2 .5"></path><path d="M10 18v3"></path><path d="M14 17.75V21"></path><path d="M7 18a6 6 0 0 0 3.84-10.61"></path> </svg></span> <span>dove</span> </a> <div class="secure" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-shield-check"> <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="m9 12 2 2 4-4"></path> </svg> Encrypted file request</div> </header> <main class="request-main" data-astro-cid-hbtokfri> <section class="request-card" aria-labelledby="request-title" data-astro-cid-hbtokfri> <div class="requester" data-astro-cid-hbtokfri> <span class="requester-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-user-round"> <circle cx="12" cy="8" r="5"></circle><path d="M20 21a8 8 0 0 0-16 0"></path> </svg></span> <div data-astro-cid-hbtokfri><p class="overline" data-astro-cid-hbtokfri>File request from</p><strong data-requester-name data-astro-cid-hbtokfri>A dove user</strong></div> </div> <h1 id="request-title" data-active-title data-astro-cid-hbtokfri>Someone needs a file from you.</h1> <div class="message" data-message hidden data-astro-cid-hbtokfri> <span aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-message-square-text"> <path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path><path d="M7 11h10"></path><path d="M7 15h6"></path><path d="M7 7h8"></path> </svg></span> <div data-astro-cid-hbtokfri><p data-message-label data-astro-cid-hbtokfri>What’s being requested</p><blockquote data-desc hidden data-astro-cid-hbtokfri></blockquote><blockquote class="msg" data-msg hidden data-astro-cid-hbtokfri></blockquote></div> </div> <div class="request-policy" aria-label="Request details" data-astro-cid-hbtokfri> <div data-astro-cid-hbtokfri><span aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-clock-3"> <circle cx="12" cy="12" r="10"></circle><path d="M12 6v6h4"></path> </svg></span><p data-astro-cid-hbtokfri>Request expires in</p><strong data-expiry data-astro-cid-hbtokfri>—</strong></div> <div data-astro-cid-hbtokfri><span aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-key"> <path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M4 12v6"></path><path d="M4 14h2"></path><path d="M9.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v4"></path><circle cx="4" cy="20" r="2"></circle> </svg></span><p data-astro-cid-hbtokfri>Files requested</p><strong data-uploads data-astro-cid-hbtokfri>One file</strong></div> </div> <div class="pin-gate" hidden data-astro-cid-hbtokfri> <span class="pin-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-lock-keyhole"> <circle cx="12" cy="16" r="1"></circle><rect x="3" y="10" width="18" height="12" rx="2"></rect><path d="M7 10V7a5 5 0 0 1 10 0v3"></path> </svg></span> <div class="pin-copy" data-astro-cid-hbtokfri><p class="overline" data-astro-cid-hbtokfri>PIN required</p><h2 data-pin-title data-astro-cid-hbtokfri>Enter the PIN the requester sent you.</h2><p data-pin-intro data-astro-cid-hbtokfri>It was sent separately—by text or a call—so this link alone cannot accept a file.</p></div> <form novalidate aria-busy="false" data-astro-cid-hbtokfri> <label for="request-pin" data-astro-cid-hbtokfri>Request PIN</label> <div class="pin-row" data-astro-cid-hbtokfri> <input id="request-pin" name="request-pin" type="password" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{4,6}" minlength="4" maxlength="6" placeholder="••••••" required aria-describedby="pin-help pin-error" data-astro-cid-hbtokfri> <button type="submit" data-astro-cid-hbtokfri> <span class="pin-icon" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-key-round key-i"> <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"></path><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"></circle> </svg><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-loader-circle loader-i"> <path d="M21 12a9 9 0 1 1-6.219-8.56"></path> </svg></span> <span class="pin-label" data-astro-cid-hbtokfri>Continue</span> </button> </div> <p id="pin-error" class="pin-error" role="alert" aria-live="assertive" data-astro-cid-hbtokfri></p> <p id="pin-help" data-astro-cid-hbtokfri>4–6 digits · Checking the PIN does not upload anything.</p> </form> </div> <div class="upload-view" hidden data-astro-cid-hbtokfri> <div class="pin-pass" data-pin-pass hidden data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-shield-check"> <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="m9 12 2 2 4-4"></path> </svg><span data-astro-cid-hbtokfri><strong data-astro-cid-hbtokfri>PIN accepted</strong> · This request is ready for one file.</span></div> <div class="upload-heading" data-astro-cid-hbtokfri><p class="overline" data-astro-cid-hbtokfri>Choose a file</p><h2 data-upload-title data-astro-cid-hbtokfri>Send the requested file.</h2><p data-astro-cid-hbtokfri>dove will encrypt it on this device before any file bytes are uploaded.</p></div> <label class="dropzone" for="requested-file" data-dropzone hidden data-astro-cid-hbtokfri> <span class="drop-icon" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-up"> <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M12 12v6"></path><path d="m15 15-3-3-3 3"></path> </svg></span> <strong data-astro-cid-hbtokfri>Choose a file</strong> <span data-astro-cid-hbtokfri>or drag one here</span> <input id="requested-file" type="file" data-astro-cid-hbtokfri> </label> <div class="selected-file" data-selected hidden data-astro-cid-hbtokfri> <span class="file-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-text"> <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path> </svg></span> <div data-astro-cid-hbtokfri><strong data-selected-name data-astro-cid-hbtokfri>Selected file</strong><span data-selected-meta data-astro-cid-hbtokfri>—</span></div> <button type="button" data-remove aria-label="Remove the selected file" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-trash-2"> <path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> </svg></button> </div> <button class="send-button" type="button" disabled data-astro-cid-hbtokfri><span data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-key"> <path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M4 12v6"></path><path d="M4 14h2"></path><path d="M9.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v4"></path><circle cx="4" cy="20" r="2"></circle> </svg> Encrypt & send file</span><b aria-hidden="true" data-astro-cid-hbtokfri>→</b></button> <p class="action-note" data-send-note data-astro-cid-hbtokfri>Choosing a file does not upload it.</p> </div> <div class="uploading" aria-live="polite" hidden data-astro-cid-hbtokfri> <span class="uploading-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-up"> <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M12 12v6"></path><path d="m15 15-3-3-3 3"></path> </svg></span> <p class="overline" data-astro-cid-hbtokfri>Encrypting & sending</p> <h1 data-uploading-title data-astro-cid-hbtokfri>Sending your file.</h1> <div class="upload-file" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-text"> <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path> </svg><span data-uploading-name data-astro-cid-hbtokfri>Your file</span><strong data-uploading-size data-astro-cid-hbtokfri>—</strong></div> <div class="progress-head" data-astro-cid-hbtokfri><span data-progress-label data-astro-cid-hbtokfri>Encrypting locally</span><strong data-upload-pct data-astro-cid-hbtokfri>0%</strong></div> <div class="progress" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-progressbar data-astro-cid-hbtokfri><i data-upload-bar data-astro-cid-hbtokfri></i></div> <p class="upload-note" data-astro-cid-hbtokfri>Keep this page open until the encrypted upload finishes.</p> </div> <div class="status-body success-body" data-body="complete" hidden data-astro-cid-hbtokfri> <span class="status-glyph success-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-check"> <path d="M20 6 9 17l-5-5"></path> </svg></span> <p class="overline" data-astro-cid-hbtokfri>Upload complete</p> <h1 data-complete-title data-astro-cid-hbtokfri>File sent.</h1> <p data-complete-text data-astro-cid-hbtokfri>dove encrypted the file on this device before uploading it. The requester can retrieve and decrypt it with the key that created this request.</p> <div class="receipt" data-astro-cid-hbtokfri><span class="file-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-file-check-corner"> <path d="M10.5 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v6"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="m14 20 2 2 4-4"></path> </svg></span><div data-astro-cid-hbtokfri><strong data-receipt-name data-astro-cid-hbtokfri>Your file</strong><span data-receipt-meta data-astro-cid-hbtokfri>encrypted upload complete</span></div></div> <div class="next-step" data-astro-cid-hbtokfri><strong data-astro-cid-hbtokfri>You’re done.</strong><span data-astro-cid-hbtokfri>You can close this page. There is no account to create.</span></div> </div> <div class="status-body" data-body="locked" hidden data-astro-cid-hbtokfri> <span class="status-glyph danger-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-lock-keyhole"> <circle cx="12" cy="16" r="1"></circle><rect x="3" y="10" width="18" height="12" rx="2"></rect><path d="M7 10V7a5 5 0 0 1 10 0v3"></path> </svg></span> <p class="overline danger" data-astro-cid-hbtokfri>Access locked</p> <h1 data-astro-cid-hbtokfri>This request is locked for security.</h1> <p data-astro-cid-hbtokfri>Too many PIN attempts were unsuccessful. The request will not accept more guesses or any file.</p> <div class="next-step" data-astro-cid-hbtokfri><strong data-astro-cid-hbtokfri>Still need to send it?</strong><span data-astro-cid-hbtokfri>Ask the requester to create a new request and send you the new PIN separately.</span></div> </div> <div class="status-body" data-body="expired" hidden data-astro-cid-hbtokfri> <span class="status-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-clock-3"> <circle cx="12" cy="12" r="10"></circle><path d="M12 6v6h4"></path> </svg></span> <p class="overline danger" data-astro-cid-hbtokfri>Request unavailable</p> <h1 data-astro-cid-hbtokfri>This file request has expired.</h1> <p data-astro-cid-hbtokfri>No file was selected or uploaded. The request stopped accepting files when its access window ended.</p> <div class="next-step" data-astro-cid-hbtokfri><strong data-astro-cid-hbtokfri>Still need to send it?</strong><span data-astro-cid-hbtokfri>Ask the requester to create a new request.</span></div> </div> <div class="status-body" data-body="error" hidden data-astro-cid-hbtokfri> <span class="status-glyph danger-glyph" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-circle-alert"> <circle cx="12" cy="12" r="10"></circle><line x1="12" x2="12" y1="8" y2="12"></line><line x1="12" x2="12.01" y1="16" y2="16"></line> </svg></span> <p class="overline danger" data-astro-cid-hbtokfri>Something went wrong</p> <h1 data-error-title data-astro-cid-hbtokfri>The request couldn’t be opened.</h1> <p data-error-text data-astro-cid-hbtokfri>No file was selected or uploaded. Try the original link again; if this continues, ask the requester to verify it.</p> <div class="next-step" data-astro-cid-hbtokfri><strong data-astro-cid-hbtokfri>Nothing left this device.</strong><span data-astro-cid-hbtokfri>dove does not begin an upload while opening or checking a request.</span></div> </div> </section> <aside class="trust-note" data-astro-cid-hbtokfri> <span class="key-mark" aria-hidden="true" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-hash"> <line x1="4" x2="20" y1="9" y2="9"></line><line x1="4" x2="20" y1="15" y2="15"></line><line x1="10" x2="8" y1="3" y2="21"></line><line x1="16" x2="14" y1="3" y2="21"></line> </svg></span> <div data-astro-cid-hbtokfri><h2 data-astro-cid-hbtokfri>Encrypted before it leaves this device.</h2><p data-astro-cid-hbtokfri>The gate checks the PIN and limits guesses. After access is approved, dove encrypts the file locally for the requester before upload.</p></div> </aside> <aside class="expectation" data-astro-cid-hbtokfri><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-hbtokfri="true" class="lucide lucide-shield-check"> <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="m9 12 2 2 4-4"></path> </svg><p data-astro-cid-hbtokfri>Only continue if you expected this request and received the PIN through a separate conversation. This page is hosted by <code data-request-host data-astro-cid-hbtokfri>share.example</code>.</p></aside> </main> <footer class="request-footer" data-astro-cid-hbtokfri><span data-astro-cid-hbtokfri>Requested securely with dove</span><span data-astro-cid-hbtokfri>Nothing uploads without your explicit confirmation.</span></footer> </div> <script>
const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
document.querySelectorAll('[data-request-host]').forEach((label) => { if (!localHosts.has(location.hostname)) label.textContent = location.host; });
// ── Browser encrypt: byte-identical to Rust crypto::{encrypt,encrypt_meta}
// and the exact inverse of share.html's decrypt/decryptMeta. The requester's
// `dove requests get` (Rust crypto::decrypt/decrypt_meta) opens what we upload,
// so these bytes MUST match. The four functions below (b64urlToBytes,
// bytesToB64url, encrypt, encryptMeta) are a TEXT-IDENTICAL copy — minus the
// `export` keyword — of dove-core/assets/request-crypto.mjs. dove-core's Rust
// drift test (tests/browser_crypto_compat.rs) FAILS if the served copy here and
// that module ever diverge, and the Node harness runs the module against a
// fixture the Rust byte-compat test decrypts. Edit both copies together.
const MAGIC = [0x44, 0x4f, 0x56, 0x45]; // "DOVE"
const VERSION = 1;
const DEFAULT_CHUNK = 1 << 20;
function b64urlToBytes(s) {
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const bin = atob(b64);
const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
function bytesToB64url(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function encrypt(data, keyBytes, chunkSize = DEFAULT_CHUNK, onProgress) {
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
const pt = data instanceof Uint8Array ? data : new Uint8Array(data);
const total = pt.length;
const header = new Uint8Array(17);
header.set(MAGIC, 0);
header[4] = VERSION;
const prefix = new Uint8Array(8);
crypto.getRandomValues(prefix);
header.set(prefix, 5);
new DataView(header.buffer).setUint32(13, chunkSize, false);
const parts = [header];
let counter = 0;
let offset = 0;
for (;;) {
const end = Math.min(offset + chunkSize, total);
const block = pt.subarray(offset, end);
const n = block.length;
const isLast = n < chunkSize; // a short (incl. empty) read means EOF
const nonce = new Uint8Array(12);
nonce.set(prefix, 0);
new DataView(nonce.buffer).setUint32(8, counter, false);
const aad = new Uint8Array(5);
new DataView(aad.buffer).setUint32(0, counter, false);
aad[4] = isLast ? 1 : 0;
const ctBuf = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
key,
block,
);
const ct = new Uint8Array(ctBuf);
const frame = new Uint8Array(5);
frame[0] = isLast ? 1 : 0;
new DataView(frame.buffer).setUint32(1, ct.length, false);
parts.push(frame, ct);
offset += n;
if (onProgress) onProgress(total ? offset / total : 1);
if (isLast) break;
counter += 1;
}
return new Blob(parts);
}
async function encryptMeta(plaintextBytes, secretBytes) {
const label = new TextEncoder().encode('dove-meta-v1');
const material = new Uint8Array(secretBytes.length + label.length);
material.set(secretBytes, 0);
material.set(label, secretBytes.length);
const mkRaw = await crypto.subtle.digest('SHA-256', material);
const mk = await crypto.subtle.importKey('raw', mkRaw, 'AES-GCM', false, ['encrypt']);
const nonce = new Uint8Array(12);
crypto.getRandomValues(nonce);
const ctBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, mk, plaintextBytes);
const ct = new Uint8Array(ctBuf);
const blob = new Uint8Array(12 + ct.length);
blob.set(nonce, 0);
blob.set(ct, 12);
return bytesToB64url(blob);
}
// Decrypt the trust blob from /rmeta — verbatim from share.html's decryptMeta.
async function decryptMeta(metaB64, secret) {
if (!secret || !metaB64) return null;
try {
const material = new Uint8Array([...secret, ...new TextEncoder().encode('dove-meta-v1')]);
const mkRaw = await crypto.subtle.digest('SHA-256', material);
const mk = await crypto.subtle.importKey('raw', mkRaw, 'AES-GCM', false, ['decrypt']);
const blob = b64urlToBytes(metaB64);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: blob.slice(0, 12) }, mk, blob.slice(12));
return JSON.parse(new TextDecoder().decode(pt));
} catch { return null; }
}
// ── The key rides the URL fragment (#…) — read locally, never sent anywhere.
// No PIN folding: the content key is the raw fragment secret (the PIN is only
// the gate's second factor). Split on '.' for backward compatibility.
const fragRaw = location.hash.replace(/^#/, '').trim();
const [secretB64] = fragRaw.split('.');
function keyBytes() {
if (!secretB64) return null;
try { const u = b64urlToBytes(secretB64); return u.length === 32 ? u : null; } catch { return null; }
}
const pathParts = location.pathname.split('/').filter(Boolean);
const requestId = pathParts[1] || '';
const origin = location.origin;
// ── State machine. Every panel is in the DOM; states show a set and hide the
// rest by toggling `hidden` (data-state is set for observability).
const shell = document.querySelector('.request-shell');
const card = document.querySelector('.request-card');
const panels = {
heading: '#request-title', message: '[data-message]', policy: '.request-policy',
pin: '.pin-gate', upload: '.upload-view', uploading: '.uploading',
complete: '[data-body="complete"]', locked: '[data-body="locked"]',
expired: '[data-body="expired"]', error: '[data-body="error"]', trust: '.trust-note',
};
const SHOW = {
loading: ['heading', 'policy', 'trust'],
pin: ['heading', 'message', 'policy', 'pin', 'trust'],
verifying: ['heading', 'message', 'policy', 'pin', 'trust'],
'wrong-pin': ['heading', 'message', 'policy', 'pin', 'trust'],
ready: ['heading', 'message', 'policy', 'upload', 'trust'],
selected: ['heading', 'message', 'policy', 'upload', 'trust'],
uploading: ['policy', 'uploading', 'trust'],
complete: ['complete'], locked: ['locked'], expired: ['expired'], error: ['error'],
};
let hasMessage = false;
function setState(name) {
if (shell) shell.dataset.state = name;
const show = new Set(SHOW[name] || []);
for (const key in panels) {
const el = document.querySelector(panels[key]);
if (!el) continue;
let visible = show.has(key);
if (key === 'message') visible = visible && hasMessage;
el.hidden = !visible;
}
// The dropzone (ready) and the selected-file card (selected) share the one
// `.upload-view` panel, so swap them here. Their markup `hidden` only
// reflects the initial server-rendered state (`pin`), so without this the
// dropzone stays hidden after the client advances to `ready` — no file
// picker to click.
const drop = document.querySelector('[data-dropzone]');
if (drop) drop.hidden = name !== 'ready';
const sel = document.querySelector('[data-selected]');
if (sel) sel.hidden = name !== 'selected';
if (card) card.classList.toggle('status-card', ['locked', 'complete', 'expired', 'error'].includes(name));
}
const $ = (sel) => document.querySelector(sel);
const text = (sel, value) => { const el = $(sel); if (el) el.textContent = value; };
function humanSize(n) {
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let b = n, i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return (i >= 3 ? b.toFixed(1) : Math.round(b)) + ' ' + u[i];
}
function humanLeft(secs) {
if (secs <= 0) return 'expired';
const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d} day${d > 1 ? 's' : ''}${h ? `, ${h} hour${h > 1 ? 's' : ''}` : ''}`;
if (h > 0) return `${h} hour${h > 1 ? 's' : ''}${m ? `, ${m} min` : ''}`;
return `${m} min`;
}
function fileKind(name) {
const ext = (name.split('.').pop() || '').toUpperCase().slice(0, 5);
return ext ? `${ext} file` : 'file';
}
let requesterName = ''; // the trust blob's `from`, once decrypted
let verifiedPin = null; // set after a successful /verify (PIN requests)
let pinRequired = false;
let chosenFile = null;
function showError(title, msg) {
if (title) text('[data-error-title]', title);
if (msg) text('[data-error-text]', msg);
setState('error');
}
// ── Populate the card from the trust blob + policy (free /rmeta, no upload).
async function applyMeta(meta) {
const secret = keyBytes();
const blob = await decryptMeta(meta.meta, secret);
if (blob) {
if (blob.from) {
requesterName = blob.from;
text('[data-requester-name]', blob.from);
text('[data-active-title]', `${blob.from} needs a file from you.`);
text('[data-pin-title]', `Enter the PIN ${blob.from} sent you.`);
text('[data-upload-title]', `Send the file ${blob.from} requested.`);
text('[data-uploading-title]', `Sending your file to ${blob.from}.`);
text('[data-complete-title]', `File sent to ${blob.from}.`);
}
const descEl = $('[data-desc]'), msgEl = $('[data-msg]');
if (blob.desc && descEl) { descEl.textContent = blob.desc; descEl.hidden = false; hasMessage = true; }
if (blob.msg && msgEl) { msgEl.textContent = `“${blob.msg}”`; msgEl.hidden = false; hasMessage = true; }
if (blob.msg && !blob.desc) text('[data-message-label]', 'Message');
}
const now = Math.floor(Date.now() / 1000);
text('[data-expiry]', humanLeft((meta.expires_at || now) - now));
const rem = meta.uploads_remaining;
if (typeof rem === 'number') text('[data-uploads]', rem === 1 ? 'One file' : `${rem} files`);
}
// ── Progress: encrypt fills 0–50%, the upload 50–100%.
function setProgress(frac, label) {
const pct = Math.round(Math.max(0, Math.min(1, frac)) * 100);
text('[data-upload-pct]', `${pct}%`);
const bar = $('[data-upload-bar]'); if (bar) bar.style.width = `${pct}%`;
const pb = $('[data-progressbar]'); if (pb) pb.setAttribute('aria-valuenow', String(pct));
if (label) text('[data-progress-label]', label);
}
function selectFile(file) {
if (!file) return;
chosenFile = file;
text('[data-selected-name]', file.name);
text('[data-selected-meta]', `${humanSize(file.size)} · ${fileKind(file.name)}`);
const btn = $('[data-remove]'); if (btn) btn.setAttribute('aria-label', `Remove ${file.name}`);
const send = $('.send-button'); if (send) send.disabled = false;
text('[data-send-note]', 'The upload starts only when you click.');
setState('selected');
}
function clearFile() {
chosenFile = null;
const input = $('#requested-file'); if (input) input.value = '';
const send = $('.send-button'); if (send) send.disabled = true;
text('[data-send-note]', 'Choosing a file does not upload it.');
setState('ready');
}
// POST the multipart form to S3 with upload progress (fetch can't report it).
function s3Post(url, form, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); };
xhr.onload = () => resolve(xhr.status);
xhr.onerror = () => reject(new Error('network'));
xhr.send(form);
});
}
async function encryptAndSend() {
const secret = keyBytes();
if (!secret) return showError('This link is missing its key.', 'Nothing after # in the address — ask the requester for the full link.');
if (!chosenFile) return;
const file = chosenFile;
text('[data-uploading-name]', file.name);
text('[data-uploading-size]', humanSize(file.size));
setProgress(0, 'Encrypting locally');
setState('uploading');
const pinQ = verifiedPin ? `?pin=${encodeURIComponent(verifiedPin)}` : '';
try {
const buf = await file.arrayBuffer();
const blob = await encrypt(buf, secret, DEFAULT_CHUNK, (f) => setProgress(f * 0.5, 'Encrypting locally'));
const up = await fetch(`${origin}/up/${requestId}${pinQ}`);
if (up.status === 410) return setState('expired');
if (up.status === 423) return setState('locked');
if (up.status === 429) return showError('This request is temporarily blocked.', 'Too many upload attempts. Wait a bit, then reopen the link.');
if (up.status === 401) return showError('The PIN is no longer accepted.', 'Reopen the link and enter the PIN again.');
if (!up.ok) return showError('The request couldn’t be opened.', `The gate returned ${up.status}.`);
const grant = await up.json();
const form = new FormData();
for (const k in grant.fields) form.append(k, grant.fields[k]);
form.append('file', blob, 'upload.dove');
setProgress(0.5, 'Uploading encrypted file');
const status = await s3Post(grant.url, form, (f) => setProgress(0.5 + f * 0.5, 'Uploading encrypted file'));
if (!(status >= 200 && status < 300)) return showError('The upload didn’t complete.', `The storage endpoint returned ${status}. Try the link again.`);
setProgress(1, 'Finalizing');
const nameMeta = await encryptMeta(new TextEncoder().encode(JSON.stringify({ name: file.name })), secret);
const done = await fetch(`${origin}/done/${requestId}${pinQ}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ upload_meta: nameMeta }),
});
if (!done.ok) return showError('The upload didn’t finalize.', `The gate returned ${done.status}. The file may not have been recorded.`);
text('[data-receipt-name]', file.name);
text('[data-receipt-meta]', `${humanSize(file.size)} · encrypted upload complete`);
setState('complete');
} catch (e) {
showError('The upload didn’t complete.', 'Something interrupted the encrypt-and-upload. Try the original link again.');
}
}
// ── PIN verify (does NOT upload). On success, reveal the upload view.
function setupPin() {
const form = $('.pin-gate form');
const input = $('#request-pin');
const error = $('#pin-error');
const submit = $('.pin-gate button[type="submit"]');
const label = submit && submit.querySelector('.pin-label');
input && input.addEventListener('input', () => { input.value = input.value.replace(/\D/g, '').slice(0, 6); input.removeAttribute('aria-invalid'); if (error) error.textContent = ''; });
form && form.addEventListener('submit', async (e) => {
e.preventDefault();
const secret = keyBytes();
const pin = ((input && input.value) || '').replace(/\D/g, '');
if (!secret) { if (error) error.textContent = 'This link is missing its key (nothing after #).'; return; }
if (pin.length < 4) { if (input) { input.setAttribute('aria-invalid', 'true'); input.focus(); } if (error) error.textContent = 'Enter the 4–6 digit PIN the requester sent you.'; return; }
if (submit) { submit.disabled = true; submit.classList.add('verifying'); } if (label) label.textContent = 'Checking PIN…';
if (input) input.disabled = true;
try {
const resp = await fetch(`${origin}/verify/${requestId}?pin=${encodeURIComponent(pin)}`);
if (resp.status === 423) return setState('locked');
if (resp.status === 410) return setState('expired');
if (resp.status === 401) {
let body = {}; try { body = await resp.json(); } catch {}
if (body.attempts_remaining === 0) return setState('locked');
const n = body.attempts_remaining;
if (error) error.textContent = (n != null) ? `That PIN didn’t match — ${n} attempt${n === 1 ? '' : 's'} left.` : 'That PIN didn’t match.';
if (input) { input.disabled = false; input.setAttribute('aria-invalid', 'true'); input.select(); }
if (submit) { submit.disabled = false; submit.classList.remove('verifying'); } if (label) label.textContent = 'Try again';
shell && (shell.dataset.state = 'wrong-pin');
return;
}
if (!resp.ok) {
if (error) error.textContent = `The gate returned ${resp.status}.`;
if (input) input.disabled = false;
if (submit) { submit.disabled = false; submit.classList.remove('verifying'); } if (label) label.textContent = 'Continue';
return;
}
verifiedPin = pin;
const pass = $('[data-pin-pass]'); if (pass) pass.hidden = false;
clearFile();
} catch (err) {
if (error) error.textContent = 'Couldn’t reach the gate — try again.';
if (input) input.disabled = false;
if (submit) { submit.disabled = false; submit.classList.remove('verifying'); } if (label) label.textContent = 'Continue';
}
});
}
function wireUpload() {
const input = $('#requested-file');
input && input.addEventListener('change', () => { if (input.files && input.files[0]) selectFile(input.files[0]); });
const drop = $('[data-dropzone]');
if (drop) {
drop.addEventListener('dragover', (e) => { e.preventDefault(); });
drop.addEventListener('drop', (e) => { e.preventDefault(); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) selectFile(f); });
}
const remove = $('[data-remove]'); remove && remove.addEventListener('click', clearFile);
const send = $('.send-button'); send && send.addEventListener('click', () => { if (chosenFile) encryptAndSend(); });
}
// ── Boot: read /rmeta, decrypt trust, pick the entry state.
setupPin();
wireUpload();
(async () => {
setState('loading');
if (!requestId) return showError('This link is incomplete.', 'The request id is missing from the address.');
if (!keyBytes()) return showError('This link is missing its key.', 'Nothing after # in the address — ask the requester for the full link.');
let meta;
try {
const r = await fetch(`${origin}/rmeta/${requestId}`);
if (r.status === 404) return showError('This request is unavailable.', 'It may have been revoked, or it never existed.');
if (!r.ok) return showError('The request couldn’t be opened.', `The gate returned ${r.status}.`);
meta = await r.json();
} catch { return showError('Couldn’t reach the gate.', 'Check your connection and reopen the link.'); }
await applyMeta(meta);
pinRequired = !!meta.pin_required;
if (meta.status === 'received') {
text('[data-complete-title]', 'This request was already fulfilled.');
text('[data-complete-text]', 'A file was already uploaded for this request. Its budget is spent, so no further upload is accepted.');
const rn = $('[data-receipt-name]');
const nameBlob = meta.name_meta ? await decryptMeta(meta.name_meta, keyBytes()) : null;
if (rn && nameBlob && nameBlob.name) rn.textContent = nameBlob.name;
if (typeof meta.size === 'number') text('[data-receipt-meta]', `${humanSize(meta.size)} · encrypted upload received`);
return setState('complete');
}
if (meta.status === 'failed') {
if (meta.reason === 'locked') return setState('locked');
if (meta.reason === 'expired') return setState('expired');
if (meta.reason === 'rate-limited') return showError('This request is temporarily blocked.', 'Too many upload attempts were made. Try again later.');
return setState('expired');
}
if (meta.locked) return setState('locked');
if (pinRequired) return setState('pin');
clearFile(); // waiting, no PIN → straight to the upload view (ready)
})();
</script> </body></html>