112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
import { makeAuthToken, parseAuthToken } from "./security";
|
|
|
|
export const AUTH_COOKIE = "fh_auth";
|
|
export const AUTH_TTL_MS = 30 * 60 * 1000;
|
|
|
|
export function parseCookie(header: string | null, name: string): string | null {
|
|
if (!header) return null;
|
|
for (const part of header.split(";")) {
|
|
const idx = part.indexOf("=");
|
|
if (idx === -1) continue;
|
|
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function hasValidAuth(req: Request, secret: string, key: string): boolean {
|
|
const token = parseCookie(req.headers.get("cookie"), AUTH_COOKIE);
|
|
if (!token) return false;
|
|
const parsed = parseAuthToken(token, secret);
|
|
return parsed !== null && parsed.key === key;
|
|
}
|
|
|
|
export function authCookieValue(key: string, secret: string): string {
|
|
const token = makeAuthToken(key, secret, AUTH_TTL_MS);
|
|
return `${AUTH_COOKIE}=${token}`;
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export function renderPasswordForm(
|
|
key: string,
|
|
filename: string,
|
|
error: boolean,
|
|
): Response {
|
|
const message = error
|
|
? '<p class="err">Incorrect password. Please try again.</p>'
|
|
: "";
|
|
const html = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="referrer" content="no-referrer" />
|
|
<title>Protected file</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
background: #0f1115; color: #e6e8ee; min-height: 100vh;
|
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
}
|
|
.card {
|
|
width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a;
|
|
border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
|
}
|
|
.brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; }
|
|
h1 { margin: 0 0 4px; font-size: 19px; }
|
|
p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; }
|
|
.err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08);
|
|
border: 1px solid rgba(224,82,82,.3); border-radius: 8px; padding: 9px 12px; }
|
|
input[type="password"] {
|
|
width: 100%; background: #0f1115; border: 1px solid #2a2f3a; color: #e6e8ee;
|
|
border-radius: 8px; padding: 11px 12px; font-size: 14px; margin-bottom: 12px;
|
|
}
|
|
button { width: 100%; font: inherit; cursor: pointer; border: none; border-radius: 8px;
|
|
padding: 11px 14px; font-size: 14px; background: #4f8cff; color: #fff; }
|
|
button:hover { background: #3a74e0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<svg class="brand-logo" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="uG" x1="200" y1="336" x2="312" y2="196" gradientUnits="userSpaceOnUse">
|
|
<stop stop-color="#4F46E5"/>
|
|
<stop offset="1" stop-color="#3B82F6"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect x="48" y="48" width="416" height="416" rx="94" fill="#FFFFFF" stroke="#ECEFF5" stroke-width="2"/>
|
|
<path d="M184 144H306l38 38V352a16 16 0 0 1-16 16H184a16 16 0 0 1-16-16V160a16 16 0 0 1 16-16Z" fill="#fff" stroke="#E7ECF5" stroke-width="4"/>
|
|
<path d="M306 144v31c0 6 5 11 11 11h27" fill="#E8EEF9"/>
|
|
<path d="M306 144l38 38h-27c-6 0-11-5-11-11V144Z" fill="#DDE6F5"/>
|
|
<path d="M256 300V196 M222 230l34-34 34 34 M200 318v14c0 9 7 16 16 16h80c9 0 16-7 16-16v-14" stroke="url(#uG)" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<h1>Protected file</h1>
|
|
<p>Enter the password to download <strong>${escapeHtml(filename)}</strong>.</p>
|
|
${message}
|
|
<form method="post" action="/api/auth/${key}">
|
|
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
|
|
<button type="submit">Download</button>
|
|
</form>
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/html; charset=utf-8",
|
|
"cache-control": "private, no-store",
|
|
"x-content-type-options": "nosniff",
|
|
"referrer-policy": "no-referrer",
|
|
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
},
|
|
});
|
|
} |