Add explicit login functionality

This commit is contained in:
2026-09-13 00:50:15 +02:00
parent 74bfdbfd8a
commit 1b77cdd165
14 changed files with 629 additions and 258 deletions
+6 -54
View File
@@ -40,7 +40,6 @@
if (!form) return;
var fileInput = document.getElementById('file');
var tokenInput = document.getElementById('token');
var drop = document.getElementById('drop');
var dropHint = document.getElementById('drop-hint');
var progress = document.getElementById('progress');
@@ -49,8 +48,9 @@
var submit = document.getElementById('submit');
var errorBox = document.getElementById('error');
// Limits as rendered for an anonymous caller; refreshed when a token is typed.
var limits = { max_size: null, allow_vanity: false };
// The session's limits are rendered by the server, so this script never has
// to ask who the visitor is. Zero means unlimited.
var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
function formatSize(n) {
if (n === null || n === undefined) return 'unlimited';
@@ -64,54 +64,6 @@
errorBox.hidden = !msg;
}
// --- credentials --------------------------------------------------------
// A token is remembered in an HttpOnly cookie the server sets, not here:
// this script cannot read it back, so an injected script cannot steal it
// either. The page is told who it is by the server when it renders, and the
// limits panel is refreshed from /api/limits, which reads the same cookie.
function refreshLimits() {
var token = tokenInput ? tokenInput.value.trim() : '';
var xhr = new XMLHttpRequest();
xhr.open('GET', base + 'api/limits');
xhr.setRequestHeader('Accept', 'application/json');
// Sent only when the field holds something; otherwise the cookie answers.
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.onload = function () {
if (xhr.status !== 200) {
if (xhr.status === 401 && token) showError('That token is not recognised.');
return;
}
showError('');
var l;
try { l = JSON.parse(xhr.responseText); } catch (e) { return; }
limits = l;
set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size));
set('limit-expiry', l.max_expiry || 'never');
set('limit-default', l.default_expiry || 'never');
set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token');
var vanity = document.getElementById('vanity');
if (vanity) vanity.disabled = !l.allow_vanity;
var expiry = document.getElementById('expiry');
if (expiry) expiry.placeholder = l.default_expiry || 'never';
};
xhr.send();
}
function set(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
if (tokenInput) {
var debounce;
tokenInput.addEventListener('input', function () {
clearTimeout(debounce);
debounce = setTimeout(refreshLimits, 400);
});
}
refreshLimits();
// --- drag and drop ------------------------------------------------------
function describeSelection() {
var f = fileInput.files[0];
@@ -144,10 +96,10 @@
var file = fileInput.files[0];
if (!file) return; // let the browser's own validation speak
if (limits.max_size && file.size > limits.max_size) {
if (maxSize && file.size > maxSize) {
e.preventDefault();
showError('That file is ' + formatSize(file.size) + '; the limit is ' +
formatSize(limits.max_size) + '.');
showError('That file is ' + formatSize(file.size) + '; your limit is ' +
formatSize(maxSize) + '.');
return;
}
+23 -1
View File
@@ -190,7 +190,17 @@ button.link {
/* Administration --------------------------------------------------------- */
header nav { float: right; font-size: .875rem; }
header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
header nav {
margin-left: auto;
display: flex;
align-items: baseline;
gap: .875rem;
font-size: .875rem;
}
header nav form { display: inline; }
header nav .who { color: var(--muted); }
header nav .who::before { content: "\1F511\00a0"; }
dl.stats { grid-template-columns: auto 1fr; }
dl.stats em { font-style: normal; color: var(--muted); }
@@ -235,3 +245,15 @@ p.error {
.card .actions { margin: 1.25rem 0; }
.card > .field:last-child { margin-bottom: 0; }
/* One-off token, tucked away so the common path stays a single button. */
.onceoff { margin-bottom: 1rem; }
.onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); }
.onceoff .field { max-width: 24rem; }
.limits .hint { margin-top: .75rem; }
.limits dl, .card dl { row-gap: .375rem; }
/* Actions that mix a link-button with a form-button. */
.actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.actions form { margin: 0; }
+28 -32
View File
@@ -1,45 +1,39 @@
{{define "content"}}
{{if .Stale}}
<p class="notice">The token remembered on this device no longer exists. It has been forgotten.</p>
<p class="notice">Your login is no longer valid — that token has been removed. You have been logged out.</p>
{{end}}
{{if .TokenName}}
<div class="notice" id="identity">
Uploading as <strong>{{.TokenName}}</strong>.
<form method="post" action="{{.Base}}api/forget"><button type="submit" class="link">Forget</button></form>
</div>
{{end}}
<form id="upload" class="card" method="post" action="{{.Base}}api/upload"
enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" enctype="multipart/form-data">
<!-- Field order is load-bearing: the server streams this body and must know
the credentials and options before the file part arrives. -->
<label class="field">
<span>Token <em>optional</em></span>
<input type="password" name="token" id="token" autocomplete="off"
placeholder="{{if .TokenName}}remembered — type to replace{{else}}anonymous{{end}}">
</label>
<label class="check">
<input type="checkbox" name="remember" id="remember" value="1" checked>
<span>Remember this token on this device</span>
</label>
<div class="drop" id="drop">
<input type="file" name="file" id="file" required>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
</div>
<div class="row">
<label class="field">
<span>Expires in</span>
<input type="text" name="expiry" id="expiry" placeholder="{{.DefaultExpiry}}" autocomplete="off">
</label>
<label class="field" id="vanity-field">
<span>Vanity name <em>token only</em></span>
<label class="field">
<span>Custom name <em>{{if .AllowVanity}}optional{{else}}needs a token{{end}}</em></span>
<input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off"
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}>
</label>
</div>
<div class="drop" id="drop">
<input type="file" name="file" id="file" required>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
</div>
<details class="onceoff">
<summary>Use a different token for this upload</summary>
<p class="hint">
Applies to this upload only and does not log you in.
{{if not .User}}To keep a token for this browser, <a href="{{.Base}}login">log in</a> instead.{{end}}
</p>
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="off">
</label>
</details>
<div class="progress" id="progress" hidden>
<div class="bar"><div class="fill" id="bar-fill"></div></div>
@@ -51,13 +45,15 @@
</form>
<section class="limits">
<h2>Current limits</h2>
<dl id="limits">
<dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd>
<dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd>
<dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd>
<h2>Your limits</h2>
<dl>
<dt>Uploading as</dt><dd>{{if .User}}{{.User}}{{else}}<em>anonymous</em>{{end}}</dd>
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Default lifetime</dt><dd>{{.DefaultExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .AllowVanity}}allowed{{else}}need a token{{end}}</dd>
</dl>
{{if not .User}}<p class="hint"><a href="{{.Base}}login">Log in</a> with a token to raise these.</p>{{end}}
</section>
<section class="cli">
+9 -1
View File
@@ -9,7 +9,15 @@
<body data-base="{{.Base}}">
<header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
{{if .Admin}}<nav><a href="{{.Base}}admin">Administration</a></nav>{{end}}
<nav>
{{if .User}}
{{if .Admin}}<a href="{{.Base}}admin">Administration</a>{{end}}
<span class="who">{{.User}}</span>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
{{else}}
<a href="{{.Base}}login">Log in</a>
{{end}}
</nav>
</header>
<main>
{{template "content" .}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
{{if .User}}
<section class="card">
<h1>Logged in</h1>
<p>You are logged in as <strong>{{.User}}</strong>.</p>
<dl>
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .Vanity}}allowed{{else}}not allowed{{end}}</dd>
</dl>
<p class="actions">
<a class="button" href="{{.Base}}">Upload a file</a>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
</p>
</section>
{{else}}
<section class="card">
<h1>Log in</h1>
<p class="hint">
A token raises your size and lifetime limits and lets you choose custom
names. Logging in keeps it for this browser, so you do not have to paste it
for every upload. Without one you can still upload anonymously.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{.Base}}login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="current-password" required autofocus>
</label>
<label class="check">
<input type="checkbox" name="persist" value="1" checked>
<span>Stay logged in on this device</span>
</label>
<button type="submit">Log in</button>
</form>
</section>
{{end}}
{{end}}