fileshare/src/views.js

303 lines
11 KiB
JavaScript
Raw Normal View History

2026-06-16 14:16:02 +03:00
import config from './config.js';
export function esc(s) {
return String(s ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
const u = (p) => esc(config.url(p));
export function humanBytes(n) {
n = Number(n) || 0;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function flashHtml(flash) {
if (!flash) return '';
const cls = flash.type === 'error' ? 'flash error' : 'flash ok';
return `<div class="${cls}">${esc(flash.msg)}</div>`;
}
export function layout({ title, body, user, flash, active }) {
const nav = user
? `<a class="${active === 'dash' ? 'on' : ''}" href="${u('/dashboard')}">My files</a>
${user.is_admin ? `<a class="${active === 'admin' ? 'on' : ''}" href="${u('/admin')}">Admin</a>` : ''}
<span class="who">${esc(user.username)}</span>
<form method="post" action="${u('/logout')}" class="inline">
<button class="link" type="submit">Log out</button>
</form>`
: `<a href="${u('/login')}">Log in</a> <a href="${u('/register')}">Register</a>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${esc(title)} · File Share</title>
<link rel="stylesheet" href="${u('/static/style.css')}">
<script src="${u('/static/app.js')}" defer></script>
</head>
<body>
<header>
<a class="brand" href="${u('/')}">📦 File Share</a>
<nav>${nav}</nav>
</header>
<main>
${flashHtml(flash)}
${body}
</main>
<footer>One-time-use file sharing · unlisted</footer>
</body>
</html>`;
}
export function messagePage(opts) {
const { heading, text, link } = opts;
const body = `<section class="card">
<h1>${esc(heading)}</h1>
<p>${esc(text)}</p>
${link ? `<p><a class="btn" href="${esc(link.href)}">${esc(link.label)}</a></p>` : ''}
</section>`;
return layout({ ...opts, body });
}
export function loginPage(opts = {}) {
const body = `<section class="card narrow">
<h1>Log in</h1>
<form method="post" action="${u('/login')}">
<label>Username or email<input name="identifier" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn" type="submit">Log in</button>
</form>
<p class="muted">No account? <a href="${u('/register')}">Register</a></p>
</section>`;
return layout({ title: 'Log in', body, ...opts });
}
export function registerPage(opts = {}) {
const needCode = !!config.signupCode;
const body = `<section class="card narrow">
<h1>Register</h1>
<form method="post" action="${u('/register')}">
<label>Username<input name="username" autocomplete="username" required minlength="3" maxlength="64"></label>
<label>Email<input name="email" type="email" autocomplete="email" required></label>
<label>Password<input name="password" type="password" autocomplete="new-password" required minlength="8"></label>
${needCode ? `<label>Signup code<input name="signup_code" required></label>` : ''}
<button class="btn" type="submit">Create account</button>
</form>
<p class="muted">Already have an account? <a href="${u('/login')}">Log in</a></p>
</section>`;
return layout({ title: 'Register', body, ...opts });
}
function linkStatusBadge(file) {
const s = file.link_state; // 'active' | 'downloaded' | 'expired' | 'none'
const labels = {
active: 'Active link',
downloaded: 'Downloaded',
expired: 'Expired',
none: 'No link',
};
return `<span class="badge ${s}">${labels[s] || s}</span>`;
}
function avBadge(file) {
return `<span class="badge av-${file.av_status}">${esc(file.av_status)}</span>`;
}
function fileRow(file) {
const link = file.token ? config.absoluteUrl(`/d/${file.token}`) : '';
const expires = file.expires_at
? new Date(file.expires_at).toLocaleString()
: '';
return `<tr>
<td>
<div class="fname">${esc(file.original_name)}</div>
<div class="sub">${humanBytes(file.size_bytes)}${file.is_bundle ? ' · bundle' : ''}${file.compressed ? ' · compressed' : ''}</div>
</td>
<td>${avBadge(file)}</td>
<td>${linkStatusBadge(file)}${file.link_state === 'active' ? `<div class="sub">expires ${esc(expires)}</div>` : ''}</td>
<td class="linkcell">
${
file.link_state === 'active'
? `<input class="copyfield" readonly value="${esc(link)}">`
: '<span class="muted">—</span>'
}
</td>
<td class="actions">
<form method="post" action="${u(`/files/${file.id}/link`)}" class="inline">
<button class="btn small" type="submit">New link</button>
</form>
<form method="post" action="${u(`/files/${file.id}/delete`)}" class="inline"
data-confirm="Delete this file permanently?">
<button class="btn small danger" type="submit">Delete</button>
</form>
</td>
</tr>`;
}
export function dashboardPage({ user, files, disk, flash, newLink }) {
const diskPct = disk ? Math.round(disk.usedPct) : 0;
const rows = files.length
? files.map(fileRow).join('')
: `<tr><td colspan="5" class="muted">No files yet. Upload one below.</td></tr>`;
const newLinkBanner = newLink
? `<div class="flash ok">
<strong>One-time link ready:</strong>
<input class="copyfield" readonly value="${esc(newLink)}">
</div>`
: '';
const verifyBanner = !user.email_verified
? `<div class="flash error">
Your email isn't verified yet — you can't upload until it is.
<form method="post" action="${u('/verify/resend')}" class="inline">
<button class="link" type="submit">Resend verification email</button>
</form>
</div>`
: '';
const uploadCard = user.email_verified
? `<section class="card">
<h1>Upload</h1>
<form method="post" action="${u('/upload')}" enctype="multipart/form-data">
<input type="file" name="files" multiple required>
<p class="muted">Select multiple files to bundle them into a single <code>.zip</code> behind one link.</p>
<button class="btn" type="submit">Upload &amp; get link</button>
</form>
</section>`
: '';
const body = `
${verifyBanner}
${newLinkBanner}
<section class="card">
<div class="diskbar">
<div>Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}</div>
<div class="meter"><span style="width:${diskPct}%"></span></div>
<div>${diskPct}% used</div>
</div>
</section>
${uploadCard}
<section class="card">
<h1>My files</h1>
<table class="files">
<thead><tr><th>File</th><th>Scan</th><th>Link</th><th>Download URL</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</section>`;
return layout({ title: 'My files', body, user, flash, active: 'dash' });
}
export function gonePage() {
return messagePage({
title: 'Link unavailable',
heading: 'This link is no longer available',
text: 'It may have already been used, expired, or never existed.',
});
}
// ---- Admin views ----
export function adminOverviewPage({ user, stats, disk, flash }) {
const diskPct = disk ? Math.round(disk.usedPct) : 0;
const body = `
<section class="card">
<h1>Admin overview</h1>
<div class="stats">
<div class="stat"><b>${stats.users}</b><span>users</span></div>
<div class="stat"><b>${stats.files}</b><span>files</span></div>
<div class="stat"><b>${stats.activeLinks}</b><span>active links</span></div>
<div class="stat"><b>${stats.usedLinks}</b><span>used links</span></div>
<div class="stat"><b>${stats.expiredLinks}</b><span>expired links</span></div>
<div class="stat"><b>${stats.queueDepth}</b><span>compress queue</span></div>
<div class="stat"><b>${stats.infected}</b><span>infected blocked</span></div>
</div>
<div class="diskbar">
<div>Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}</div>
<div class="meter"><span style="width:${diskPct}%"></span></div>
<div>${diskPct}% used</div>
</div>
<p><a class="btn" href="${u('/admin/users')}">Manage users</a>
<a class="btn" href="${u('/admin/files')}">Manage files</a></p>
</section>`;
return layout({ title: 'Admin', body, user, flash, active: 'admin' });
}
export function adminUsersPage({ user, users, flash }) {
const rows = users
.map(
(r) => `<tr>
<td>${esc(r.username)}${r.is_admin ? ' <span class="badge admin">admin</span>' : ''}
${r.disabled ? ' <span class="badge expired">disabled</span>' : ''}
${!r.email_verified ? ' <span class="badge none">unverified</span>' : ''}
<div class="sub">${esc(r.email)}</div></td>
<td>${r.file_count}<div class="sub">${humanBytes(r.bytes_used)}</div></td>
<td class="actions">
${!r.email_verified ? actionBtn(`/admin/users/${r.id}/verify`, 'Verify') : ''}
${r.disabled ? actionBtn(`/admin/users/${r.id}/enable`, 'Enable') : actionBtn(`/admin/users/${r.id}/disable`, 'Disable', r.id === user.id)}
${actionBtn(`/admin/users/${r.id}/delete`, 'Delete', r.id === user.id, true)}
</td>
</tr>`
)
.join('');
const body = `<section class="card">
<h1>Users</h1>
<table class="files">
<thead><tr><th>User</th><th>Files</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
<p><a href="${u('/admin')}">&larr; Overview</a></p>
</section>`;
return layout({ title: 'Admin · Users', body, user, flash, active: 'admin' });
}
export function adminFilesPage({ user, files, flash }) {
const rows = files
.map(
(f) => `<tr>
<td><div class="fname">${esc(f.original_name)}</div>
<div class="sub">${humanBytes(f.size_bytes)}${f.is_bundle ? ' · bundle' : ''}${f.compressed ? ' · compressed' : ''}</div></td>
<td>${esc(f.username)}</td>
<td><span class="badge av-${f.av_status}">${esc(f.av_status)}</span></td>
<td><span class="badge ${f.link_state}">${f.link_state}</span></td>
<td class="actions">
${actionBtn(`/admin/files/${f.id}/link`, 'New link')}
${f.link_id ? actionBtn(`/admin/links/${f.link_id}/expire`, 'Expire') : ''}
${actionBtn(`/admin/files/${f.id}/delete`, 'Delete', false, true)}
</td>
</tr>`
)
.join('');
const body = `<section class="card">
<h1>All files</h1>
<table class="files">
<thead><tr><th>File</th><th>Owner</th><th>Scan</th><th>Link</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="5" class="muted">No files.</td></tr>`}</tbody>
</table>
<p><a href="${u('/admin')}">&larr; Overview</a></p>
</section>`;
return layout({ title: 'Admin · Files', body, user, flash, active: 'admin' });
}
function actionBtn(path, label, disabled = false, danger = false) {
if (disabled) return `<button class="btn small" disabled>${esc(label)}</button>`;
const confirmAttr = danger ? ` data-confirm="Are you sure?"` : '';
return `<form method="post" action="${u(path)}" class="inline"${confirmAttr}>
<button class="btn small ${danger ? 'danger' : ''}" type="submit">${esc(label)}</button>
</form>`;
}