Release v0.4.0: dev -> main #251

Merged
fen merged 138 commits from dev into main 2026-09-17 15:35:21 +00:00
25 changed files with 949 additions and 850 deletions
Showing only changes of commit 7a74ff3e74 - Show all commits
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"embed"
"io/fs"
"regexp"
"strings"
"testing"
)
// #139 regression guards: with CSP script-src/style-src 'self' (no
// 'unsafe-inline'), the templates must not carry inline <script> blocks or
// style="" attributes, and the external scripts referenced must exist.
func TestNoInlineScripts(t *testing.T) {
entries, err := fs.ReadDir(tmplFS, "templates")
if err != nil {
t.Fatal(err)
}
re := regexp.MustCompile(`(?s)<script[^>]*>.*?</script>`)
srcOnly := regexp.MustCompile(`<script[^>]+src=`)
for _, e := range entries {
b, err := fs.ReadFile(tmplFS, "templates/"+e.Name())
if err != nil {
t.Fatal(err)
}
for _, m := range re.FindAll(b, -1) {
if srcOnly.Match(m) {
continue // external script tag with src: fine
}
t.Errorf("%s: inline <script> block found (CSP #139): %q", e.Name(), string(m[:60]))
}
if strings.Contains(string(b), " onclick=") || strings.Contains(string(b), "onload=") {
t.Errorf("%s: inline event handler attribute found (CSP #139)", e.Name())
}
if strings.Contains(string(b), "style=\"") {
t.Errorf("%s: inline style attribute found (CSP #139)", e.Name())
}
}
}
func TestReferencedStaticScriptsExist(t *testing.T) {
entries, _ := fs.ReadDir(tmplFS, "templates")
var staticFiles embed.FS = staticFS
for _, e := range entries {
b, err := fs.ReadFile(tmplFS, "templates/"+e.Name())
if err != nil {
t.Fatal(err)
}
for _, m := range regexp.MustCompile(`<script src="/static/([^"]+)"`).FindAllSubmatch(b, -1) {
name := string(m[1])
if _, err := fs.ReadFile(staticFiles, "static/"+name); err != nil {
t.Errorf("%s references /static/%s: %v", e.Name(), name, err)
}
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(pages) h := SecurityHeaders(pages)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'" wantCSP := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP { if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP = %q, want %q", got, wantCSP) t.Errorf("CSP = %q, want %q", got, wantCSP)
} }
+74
View File
@@ -0,0 +1,74 @@
// #99/#139: admin lock + settings panel. Key lives in sessionStorage for this visit only.
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
// allow callers to override the key header (e.g. validating a typed key, #99)
opts.headers = Object.assign({ 'X-Admin-Key': key() }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.classList.remove('hidden');
});
}
function showLock() {
panel.classList.add('hidden');
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
// #99: don't persist the key until the server accepts it
api('/admin/api/settings', { headers: { 'X-Admin-Key': keyInput.value } }).then(function (r) {
if (r.status === 200) {
sessionStorage.setItem(KEY, keyInput.value);
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
// #112: always show the lock on fresh load — do not auto-restore the
// panel from a stale sessionStorage key. The key is only written after a
// successful unlock (above) so in-page actions still work within this visit.
})();
+17
View File
@@ -725,3 +725,20 @@ button[type="submit"]:focus-visible,
.attachment-chip:hover { border-color: var(--accent); } .attachment-chip:hover { border-color: var(--accent); }
.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; } .attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; }
.attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); } .attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); }
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
.hidden { display: none; }
.can-page { max-width: 900px; width: 100%; }
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
.col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; }
.col-f { width: 100px; } .col-g { width: 190px; }
.spacer-flex { flex: 1; }
.input-num { width: 80px; }
.input-num-sm { width: 64px; }
.danger-hint { color: var(--danger, #c0392b); margin-top: 6px; }
.hint-lg { font-size: 19px; }
.mt6 { margin-top: 6px; }
.mt18 { margin-top: 18px; }
.toggle-inline { display: inline-flex; }
.wrap-normal { word-break: normal; overflow-wrap: break-word; }
.created-banner { display: block; }
+19
View File
@@ -0,0 +1,19 @@
// #46: live relative-time counters: tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
+20
View File
@@ -0,0 +1,20 @@
// #139: public history table + 30s auto-refresh.
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
+46
View File
@@ -0,0 +1,46 @@
// #139: saved pastes table.
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
+398
View File
@@ -0,0 +1,398 @@
// #139: editor page logic (gutter, expiry, burn, attachments, create).
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const setHidden = (id, hid) => { const el = $(id); if (el) el.classList.toggle('hidden', hid); };
$('haspw').addEventListener('change', e => setHidden('pwrow', !e.target.checked));
$('burn').addEventListener('change', e => setHidden('burnrow', !e.target.checked));
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
setHidden('customexp-row', document.querySelector('input[name="exp"]:checked').value !== 'custom');
setHidden('customexp-err', true);
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.classList.remove('hidden');
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.classList.remove('hidden');
return null;
}
err.classList.add('hidden');
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
// #105: map backend machine-readable error codes to plain-language guidance.
// Unknown codes fall back to a generic message; the technical detail stays
// in the API response for API consumers.
const ERROR_MESSAGES = {
slug_taken: 'That Custom URL is already taken. Try another.',
slug_reserved: 'That Custom URL is reserved. Try another.',
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
content_empty: 'Write or paste something first.',
content_too_large: 'This paste is too large. The limit is 5 MB.',
file_too_large: 'File is too large. The limit is 25 MB.',
one_file_only: 'Choose either text or a file for now.',
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
rate_limited: 'Too many tries. Wait a minute and try again.',
};
const GENERIC_ERROR = 'Something went wrong. Please try again.';
function friendlyError(data) {
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
}
// #105: color the result box by outcome — success (ok), error (err),
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
function setResultKind(kind) {
const card = $('result-card');
card.classList.remove('result-ok', 'result-err', 'result-warn');
if (kind) card.classList.add('result-' + kind);
}
function showResult(html, kind) {
$('result').innerHTML = html;
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
setHidden('result-card', false);
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
// #4: can mode — bundle the editor + extra items into a can via multipart
if ($('iscan').checked) return createCan();
// #38: file attached -> file paste (1 file = 1 paste; text is ignored)
if (attachedFile) return createFilePaste();
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Create failed', 'error');
return;
}
finishCreate(data);
}
// shared success handling for both create paths (#38): result box, copy
// button, password auto-unlock, then redirect to the paste.
function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// token carried via sessionStorage, never in the URL (#143)
const dest = '/' + data.id + '?created=1';
try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {}
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
return;
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// #38: file attachment support — 1 file = 1 paste. Three input paths:
// Ctrl+V paste of a file, upload button (file picker), drag-and-drop.
const MAX_FILE_BYTES = 25 * 1024 * 1024;
let attachedFile = null; // the single attached File, or null
function humanSize(n) {
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
return n + ' B';
}
function setAttachedFile(file) {
if (!file) return clearAttachedFile();
if (file.size > MAX_FILE_BYTES) {
toast('File is too large. The limit is 25 MB.', 'error');
return;
}
attachedFile = file;
renderFileChip();
setHidden('file-text-note', false);
}
function clearAttachedFile() {
attachedFile = null;
$('file-input').value = '';
renderFileChip();
setHidden('file-text-note', true);
}
function renderFileChip() {
const slot = $('file-chip-slot');
slot.innerHTML = '';
if (!attachedFile) return;
const chip = document.createElement('div');
chip.className = 'file-chip';
chip.innerHTML = '<span class="file-chip-name"></span>' +
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
'<button type="button" class="file-chip-remove" title="Remove file">&times;</button>';
chip.querySelector('.file-chip-name').textContent = attachedFile.name;
chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile);
slot.appendChild(chip);
}
// (1) file picker via the dropzone click
$('dropzone').addEventListener('click', () => $('file-input').click());
$('file-input').addEventListener('change', () => {
if ($('file-input').files.length) setAttachedFile($('file-input').files[0]);
});
// (2) drag-and-drop onto the dropzone (and the page broadly)
const dz = $('dropzone');
['dragenter', 'dragover'].forEach(ev => {
document.addEventListener(ev, e => {
if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return;
e.preventDefault();
dz.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
document.addEventListener(ev, e => {
if (ev === 'drop') e.preventDefault();
if (e.target === dz || ev === 'drop') dz.classList.remove('dragover');
});
});
document.addEventListener('drop', e => {
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
e.preventDefault();
setAttachedFile(e.dataTransfer.files[0]);
});
// (3) Ctrl+V of a file anywhere on the page
document.addEventListener('paste', e => {
const files = e.clipboardData && e.clipboardData.files;
if (!files || !files.length) return; // normal text paste into the editor
e.preventDefault();
setAttachedFile(files[0]);
});
// file create path: POST multipart. 1 file = 1 paste: when a file is
// attached the editor text is ignored (server enforces this too).
async function createFilePaste() {
const fd = new FormData();
fd.append('file', attachedFile);
if ($('title').value) fd.append('title', $('title').value);
if ($('custom').value) fd.append('custom_slug', $('custom').value);
if ($('haspw').checked) fd.append('password', $('password').value);
if ($('burn').checked) {
fd.append('burn_after_read', 'true');
fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1));
}
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
fd.append('expires_in', dur);
} else if (exp) {
fd.append('expires_in', exp);
}
const res = await fetch('/api/pastes', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Create failed', 'error');
return;
}
finishCreate(data);
}
// #4: can creation — POST multipart to /api/pastes/can. The main editor is
// the first item; each extra can-item row is another text item.
async function createCan() {
const items = [];
if (content.value.trim()) {
items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''});
}
document.querySelectorAll('#can-items .can-item-row').forEach(row => {
const t = row.querySelector('.can-item-title').value.trim();
const c = row.querySelector('.can-item-content').value;
if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c});
});
if (!items.length) { toast('Nothing to put in the can', 'error'); return; }
const fd = new FormData();
fd.append('title', $('title').value || 'Untitled can');
fd.append('json_items', JSON.stringify(items));
if ($('haspw').checked) fd.append('password', $('password').value);
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
if (dur) fd.append('expires_in', dur);
} else if (exp) {
fd.append('expires_in', exp);
}
if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim());
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Can create failed', 'error');
return;
}
const url = location.origin + data.url;
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok');
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// password-protected can: unlock now with the password we already have (#26 parity)
if ($('haspw').checked && data.id) {
const pd = new FormData();
pd.append('password', $('password').value);
try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {}
}
location.href = data.url;
}
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.classList.add('hidden');
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
+47
View File
@@ -0,0 +1,47 @@
// #53/#139: paste viewer page logic. Paste id arrives via <body data-paste-id>.
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem() {
if (!confirm('Hard delete this paste immediately?')) return;
let tok = '';
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
if (!tok) { alert('deletion token not available in this browser'); return; }
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
// wiring (moved from inline handlers for CSP #139)
var PASTE_ID = document.currentScript.getAttribute('data-paste-id');
var copyBtn = document.getElementById('copy-btn');
if (copyBtn) copyBtn.addEventListener('click', function (e) { e.preventDefault(); copyContent(copyBtn); });
var delBtn = document.getElementById('delete-btn');
if (delBtn) delBtn.addEventListener('click', function (e) { e.preventDefault(); redeem(); });
var statsToggle = document.getElementById('stats-toggle');
if (statsToggle) statsToggle.addEventListener('click', toggleStats);
+101
View File
@@ -0,0 +1,101 @@
// #112/#127/#139: theme switcher. Swatch colors are derived from computed CSS vars per preset.
(function () {
// #112: derive each preset's swatches from the real CSS variables in
// app.css by temporarily applying data-preset, so they can never drift.
// #127: 5 theme pairs, light swatches top row, dark bottom row.
var pairs = [
{ id: 'midnight', name: 'Midnight' },
{ id: 'smooth', name: 'Smooth' },
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
{ id: 'pastel-peach', name: 'Pastel Peach' },
{ id: 'pastel-cloud', name: 'Pastel Cloud' }
];
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
function presetColors(id) {
var root = document.documentElement;
var prev = root.getAttribute('data-preset');
root.setAttribute('data-preset', id);
var cs = getComputedStyle(root);
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
return colors;
}
// current base pair + dark flag from the resolved data-preset.
// "midnight" is itself the dark variant, so check dark ids first.
function state() {
var p = document.documentElement.dataset.preset || 'midnight';
if (p === 'midnight' || /-dark$/.test(p)) {
return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true };
}
return { base: p === 'midnight-light' ? 'midnight' : p, dark: false };
}
var grid = document.getElementById('theme-grid');
var cards = {};
// #132: midnight is dark-first (root preset = midnight = dark; its light
// variant is midnight-light), the others are light-first. Resolve the
// LIGHT and DARK preset ids generically so the light swatches always
// render in the top row of every card.
function lightPreset(id) {
if (id === 'midnight') return 'midnight-light';
return id; // light-first bases use themselves as the light variant
}
function darkPreset(id) {
if (id === 'midnight') return 'midnight'; // root preset is midnight's dark
return id + '-dark';
}
pairs.forEach(function (t) {
var light = presetColors(lightPreset(t.id));
var dark = presetColors(darkPreset(t.id));
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-card';
btn.setAttribute('aria-pressed', 'false');
btn.setAttribute('data-pair', t.id);
// #139: build the swatches via DOM APIs (CSSOM styles) instead of
// innerHTML strings: re-parsing serialized inline styles would trip
// style-src 'self'.
var label = document.createElement('strong');
label.textContent = t.name;
btn.appendChild(label);
[light, dark].forEach(function (colors) {
var row = document.createElement('span');
row.className = 'swatches';
colors.forEach(function (c) {
var sw = document.createElement('span');
sw.className = 'swatch';
sw.style.backgroundColor = c;
row.appendChild(sw);
});
btn.appendChild(row);
});
btn.addEventListener('click', function () {
var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true');
});
cards[t.id] = btn;
grid.appendChild(btn);
});
function sync() {
var s = state();
Object.keys(cards).forEach(function (k) {
cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false');
});
var dt = document.getElementById('settings-dark-toggle');
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
}
sync();
// the topbar script runs before this button exists, so wire it here
var dt = document.getElementById('settings-dark-toggle');
dt.addEventListener('click', function () {
var btns = document.querySelectorAll('.topbar .dark-toggle');
if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
document.addEventListener('palette-darkchange', sync);
})();
+3 -3
View File
@@ -79,10 +79,10 @@ const PaletteTable = (() => {
const rows = $('rows'), empty = $('empty'); const rows = $('rows'), empty = $('empty');
if (!items.length) { if (!items.length) {
rows.innerHTML = ''; rows.innerHTML = '';
empty.style.display = 'block'; empty.classList.remove('hidden');
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll; empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else { } else {
empty.style.display = 'none'; empty.classList.add('hidden');
rows.innerHTML = items.map(opts.rowHtml).join(''); rows.innerHTML = items.map(opts.rowHtml).join('');
} }
@@ -97,7 +97,7 @@ const PaletteTable = (() => {
const view = items.slice(fOff, fOff + opts.perPage); const view = items.slice(fOff, fOff + opts.perPage);
rows.innerHTML = view.length ? view.map(opts.rowHtml).join('') : ''; rows.innerHTML = view.length ? view.map(opts.rowHtml).join('') : '';
if (!view.length) { if (!view.length) {
empty.style.display = 'block'; empty.classList.remove('hidden');
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll; empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} }
showing.textContent = `Showing ${view.length === 0 ? 0 : fOff+1}${fOff+view.length} of ${items.length.toLocaleString()} ${filtered ? 'matches' : 'sorted'} · page ${state.page} of ${filtPages}`; showing.textContent = `Showing ${view.length === 0 ? 0 : fOff+1}${fOff+view.length} of ${items.length.toLocaleString()} ${filtered ? 'matches' : 'sorted'} · page ${state.page} of ${filtPages}`;
+78
View File
@@ -0,0 +1,78 @@
// #16/#100/#127: theme preset resolution. Runs in <head> (blocking) so the
// correct data-preset is set before first paint. The server default dark flag
// arrives via the script tag's data-default-dark attribute so no inline script is needed.
// #130: shared line wrap preference (localStorage 'palette-wrap', default off).
// preset hook (#16): ?theme= wins; else persisted choice (#100).
// #127: theme pairs - each base theme has a light and dark variant;
// palette-theme stores the base id, palette-dark the dark flag.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
function resolve(t, dark) {
for (var base in PAIRS) {
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
}
return null;
}
var t = new URLSearchParams(location.search).get('theme');
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
var dark = (document.currentScript && document.currentScript.getAttribute('data-default-dark')) === '1';
try {
var stored = localStorage.getItem('palette-dark');
if (stored !== null) dark = stored !== 'false';
} catch (e) {}
if (t) {
// URL ?theme= wins and accepts both base and variant ids; a variant id
// also sets the dark flag and normalizes t back to its base pair id.
var found = false;
for (var base in PAIRS) {
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
if (t === base) { found = true; break; }
}
if (!found) t = null;
}
if (!t) {
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
}
if (!PAIRS[t]) t = 'midnight';
// do not persist anything here: URL theme is a one-off override
document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light;
})();
// #130: shared line wrap preference (localStorage 'palette-wrap', default off)
(function () {
function wrapOn() {
try { return localStorage.getItem('palette-wrap') === '1'; } catch (e) { return false; }
}
function wrapApply(on) {
if (on) document.documentElement.setAttribute('data-wrap', '1');
else document.documentElement.removeAttribute('data-wrap');
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
var s = document.getElementById('wrap-setting');
if (s) s.checked = on;
}
window.paletteWrapToggle = function () {
var on = !wrapOn();
try { localStorage.setItem('palette-wrap', on ? '1' : '0'); } catch (e) {}
wrapApply(on);
return on;
};
document.addEventListener('DOMContentLoaded', function () {
wrapApply(wrapOn());
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.addEventListener('click', function () { window.paletteWrapToggle(); });
});
var s = document.getElementById('wrap-setting');
if (s) s.addEventListener('change', function () {
if (s.checked !== wrapOn()) window.paletteWrapToggle();
});
});
})();
+44
View File
@@ -0,0 +1,44 @@
// #127: dark mode toggle: flips to the other variant of the active pair.
// #127: dark mode toggle - flips to the other variant of the active pair.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
var root = document.documentElement;
function state() {
var p = root.dataset.preset || 'midnight';
for (var base in PAIRS) {
if (p === PAIRS[base].dark) return { base: base, dark: true };
if (p === PAIRS[base].light) return { base: base, dark: false };
}
return { base: 'midnight', dark: true };
}
function apply() {
var s = state();
document.body.classList.toggle('dark', s.dark);
root.classList.toggle('dark', s.dark);
}
function sync(btns) {
btns.forEach(function (b) {
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
});
}
var btns = document.querySelectorAll('.dark-toggle');
apply();
sync(Array.prototype.slice.call(btns));
btns.forEach(function (b) {
b.addEventListener('click', function () {
var s = state();
var dark = !s.dark;
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
apply();
sync(Array.prototype.slice.call(btns));
document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
});
})();
+7
View File
@@ -0,0 +1,7 @@
// #139: unlock page: password reveal toggle.
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
+2 -76
View File
@@ -12,7 +12,7 @@
<span id="admin-key-status"></span> <span id="admin-key-status"></span>
</form> </form>
<div id="admin-panel" style="display:none"> <div id="admin-panel" class="hidden">
<h2>Settings</h2> <h2>Settings</h2>
<form id="admin-settings-form"> <form id="admin-settings-form">
<table> <table>
@@ -30,79 +30,5 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/static/admin.js" defer></script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
// allow callers to override the key header (e.g. validating a typed key, #99)
opts.headers = Object.assign({ 'X-Admin-Key': key() }, opts.headers || {});
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
// #99: don't persist the key until the server accepts it
api('/admin/api/settings', { headers: { 'X-Admin-Key': keyInput.value } }).then(function (r) {
if (r.status === 200) {
sessionStorage.setItem(KEY, keyInput.value);
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
// #112: always show the lock on fresh load — do not auto-restore the
// panel from a stale sessionStorage key. The key is only written after a
// successful unlock (above) so in-page actions still work within this visit.
})();
</script>
{{template "foot" .}} {{template "foot" .}}
+1 -1
View File
@@ -1,7 +1,7 @@
{{template "head" .}} {{template "head" .}}
{{template "topbar" .}} {{template "topbar" .}}
<div class="center"> <div class="center">
<div class="float can-page" style="max-width:900px; width:100%;"> <div class="float can-page">
<div class="inner"> <div class="inner">
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1> <h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}} {{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
+1 -21
View File
@@ -1,21 +1 @@
{{define "foot"}}<script> {{define "foot"}}<script src="/static/foot.js" defer></script>{{end}}
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
+3 -23
View File
@@ -8,7 +8,7 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup> <colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-f"><col class="col-g"></colgroup>
<thead><tr> <thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th> <th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th> <th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
@@ -20,7 +20,7 @@
</tr></thead> </tr></thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div> <div class="empty hidden" id="empty">No pastes yet. Create the first one.</div>
</div> </div>
<div class="pager float"> <div class="pager float">
<span id="showing"></span> <span id="showing"></span>
@@ -28,25 +28,5 @@
</div> </div>
</div> </div>
<script src="/static/table.js"></script> <script src="/static/table.js"></script>
<script> <script src="/static/history.js" defer></script>
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}} {{template "foot" .}}
+2 -121
View File
@@ -2,82 +2,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
<script> <script src="/static/theme.js" data-default-dark="{{ if defaultDark }}1{{ else }}0{{ end }}"></script>
// preset hook (#16): ?theme= wins; else persisted choice (#100).
// #127: theme pairs - each base theme has a light and dark variant;
// palette-theme stores the base id, palette-dark the dark flag.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
function resolve(t, dark) {
for (var base in PAIRS) {
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
}
return null;
}
var t = new URLSearchParams(location.search).get('theme');
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
var dark = {{ defaultDark }};
try {
var stored = localStorage.getItem('palette-dark');
if (stored !== null) dark = stored !== 'false';
} catch (e) {}
if (t) {
// URL ?theme= wins and accepts both base and variant ids; a variant id
// also sets the dark flag and normalizes t back to its base pair id.
var found = false;
for (var base in PAIRS) {
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
if (t === base) { found = true; break; }
}
if (!found) t = null;
}
if (!t) {
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
}
if (!PAIRS[t]) t = 'midnight';
// do not persist anything here: URL theme is a one-off override
document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light;
})();
// #130: shared line wrap preference (localStorage 'palette-wrap', default off)
(function () {
function wrapOn() {
try { return localStorage.getItem('palette-wrap') === '1'; } catch (e) { return false; }
}
function wrapApply(on) {
if (on) document.documentElement.setAttribute('data-wrap', '1');
else document.documentElement.removeAttribute('data-wrap');
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
var s = document.getElementById('wrap-setting');
if (s) s.checked = on;
}
window.paletteWrapToggle = function () {
var on = !wrapOn();
try { localStorage.setItem('palette-wrap', on ? '1' : '0'); } catch (e) {}
wrapApply(on);
return on;
};
document.addEventListener('DOMContentLoaded', function () {
wrapApply(wrapOn());
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
b.addEventListener('click', function () { window.paletteWrapToggle(); });
});
var s = document.getElementById('wrap-setting');
if (s) s.addEventListener('change', function () {
if (s.checked !== wrapOn()) window.paletteWrapToggle();
});
});
})();
</script>
{{end}} {{end}}
{{define "topbar"}} {{define "topbar"}}
@@ -98,49 +23,5 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</a> </a>
</div> </div>
<script> <script src="/static/topbar.js" defer></script>
// #127: dark mode toggle - flips to the other variant of the active pair.
(function () {
var PAIRS = {
'midnight': { light: 'midnight-light', dark: 'midnight' },
'smooth': { light: 'smooth', dark: 'smooth-dark' },
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
};
var root = document.documentElement;
function state() {
var p = root.dataset.preset || 'midnight';
for (var base in PAIRS) {
if (p === PAIRS[base].dark) return { base: base, dark: true };
if (p === PAIRS[base].light) return { base: base, dark: false };
}
return { base: 'midnight', dark: true };
}
function apply() {
var s = state();
document.body.classList.toggle('dark', s.dark);
root.classList.toggle('dark', s.dark);
}
function sync(btns) {
btns.forEach(function (b) {
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
});
}
var btns = document.querySelectorAll('.dark-toggle');
apply();
sync(Array.prototype.slice.call(btns));
btns.forEach(function (b) {
b.addEventListener('click', function () {
var s = state();
var dark = !s.dark;
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
apply();
sync(Array.prototype.slice.call(btns));
document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
});
})();
</script>
{{end}} {{end}}
+3 -49
View File
@@ -8,7 +8,7 @@
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div> <div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float"> <div class="float">
<table> <table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:150px"><col style="width:190px"><col style="width:100px"></colgroup> <colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"></colgroup>
<thead><tr> <thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th> <th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th> <th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
@@ -19,7 +19,7 @@
</tr></thead> </tr></thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div> <div class="empty hidden" id="empty">No pastes from this browser yet.</div>
</div> </div>
<div class="pager float"> <div class="pager float">
<span id="showing"></span> <span id="showing"></span>
@@ -27,51 +27,5 @@
</div> </div>
</div> </div>
<script src="/static/table.js"></script> <script src="/static/table.js"></script>
<script> <script src="/static/mine.js" defer></script>
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
{{template "foot" .}} {{template "foot" .}}
+10 -407
View File
@@ -30,7 +30,7 @@
<div class="created-banner" id="created"></div> <div class="created-banner" id="created"></div>
<div class="actionbar"> <div class="actionbar">
<span class="hint">Ctrl+Enter to create</span> <span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div> <div class="spacer spacer-flex"></div>
<button class="btn" id="create">Create</button> <button class="btn" id="create">Create</button>
</div> </div>
</div> </div>
@@ -46,8 +46,8 @@
<label><input type="radio" name="exp" value="720h"> 30 days</label> <label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label> <label><input type="radio" name="exp" value="custom"> Custom</label>
</div> </div>
<div class="pw-row" id="customexp-row" style="display:none"> <div class="pw-row hidden" id="customexp-row">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90"> <input type="number" id="expnum" min="1" class="input-num" placeholder="90">
<select id="expunit"> <select id="expunit">
<option value="m">minutes</option> <option value="m">minutes</option>
<option value="h" selected>hours</option> <option value="h" selected>hours</option>
@@ -55,16 +55,16 @@
<option value="w">weeks</option> <option value="w">weeks</option>
<option value="mo">months</option> <option value="mo">months</option>
</select> </select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div> <div class="hint danger-hint hidden" id="customexp-err"></div>
</div> </div>
</div> </div>
<div class="float side-section"> <div class="float side-section">
<h3>Protection</h3> <h3>Protection</h3>
<div class="protect"> <div class="protect">
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label> <label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div> <div class="pw-row hidden" id="pwrow"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label> <label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div> <div class="pw-row hidden" id="burnrow"><label class="hint hint-lg">Readable <input type="number" id="burnreads" min="1" value="1" class="input-num-sm"> times</label></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label> <label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div> </div>
</div> </div>
@@ -75,414 +75,17 @@
</div> </div>
<input type="file" id="file-input" class="file-input" aria-label="Choose a file"> <input type="file" id="file-input" class="file-input" aria-label="Choose a file">
<div id="file-chip-slot"></div> <div id="file-chip-slot"></div>
<div class="hint" style="margin-top:6px; font-size:19px; display:none" id="file-text-note">File pastes ignore the text editor (one file per paste)</div> <div class="hint hint-lg hidden mt6" id="file-text-note">File pastes ignore the text editor (one file per paste)</div>
</div> </div>
<div class="float side-section"> <div class="float side-section">
<h3>Custom URL</h3> <h3>Custom URL</h3>
<input type="text" id="custom" class="custom-input" placeholder="my-snippet"> <input type="text" id="custom" class="custom-input" placeholder="my-snippet">
</div> </div>
<div class="float side-section" id="result-card" style="display:none"> <div class="float side-section hidden" id="result-card">
<h3>Result</h3> <h3>Result</h3>
<div class="hint" id="result" style="word-break:normal;overflow-wrap:break-word">empty</div> <div class="hint wrap-normal" id="result">empty</div>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/static/new.js" defer></script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
// #105: map backend machine-readable error codes to plain-language guidance.
// Unknown codes fall back to a generic message; the technical detail stays
// in the API response for API consumers.
const ERROR_MESSAGES = {
slug_taken: 'That Custom URL is already taken. Try another.',
slug_reserved: 'That Custom URL is reserved. Try another.',
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
content_empty: 'Write or paste something first.',
content_too_large: 'This paste is too large. The limit is 5 MB.',
file_too_large: 'File is too large. The limit is 25 MB.',
one_file_only: 'Choose either text or a file for now.',
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
rate_limited: 'Too many tries. Wait a minute and try again.',
};
const GENERIC_ERROR = 'Something went wrong. Please try again.';
function friendlyError(data) {
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
}
// #105: color the result box by outcome — success (ok), error (err),
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
function setResultKind(kind) {
const card = $('result-card');
card.classList.remove('result-ok', 'result-err', 'result-warn');
if (kind) card.classList.add('result-' + kind);
}
function showResult(html, kind) {
$('result').innerHTML = html;
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
// #4: can mode — bundle the editor + extra items into a can via multipart
if ($('iscan').checked) return createCan();
// #38: file attached -> file paste (1 file = 1 paste; text is ignored)
if (attachedFile) return createFilePaste();
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Create failed', 'error');
return;
}
finishCreate(data);
}
// shared success handling for both create paths (#38): result box, copy
// button, password auto-unlock, then redirect to the paste.
function finishCreate(data) {
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// token carried via sessionStorage, never in the URL (#143)
const dest = '/' + data.id + '?created=1';
try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {}
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
return;
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// #38: file attachment support — 1 file = 1 paste. Three input paths:
// Ctrl+V paste of a file, upload button (file picker), drag-and-drop.
const MAX_FILE_BYTES = 25 * 1024 * 1024;
let attachedFile = null; // the single attached File, or null
function humanSize(n) {
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
return n + ' B';
}
function setAttachedFile(file) {
if (!file) return clearAttachedFile();
if (file.size > MAX_FILE_BYTES) {
toast('File is too large. The limit is 25 MB.', 'error');
return;
}
attachedFile = file;
renderFileChip();
$('file-text-note').style.display = 'block';
}
function clearAttachedFile() {
attachedFile = null;
$('file-input').value = '';
renderFileChip();
$('file-text-note').style.display = 'none';
}
function renderFileChip() {
const slot = $('file-chip-slot');
slot.innerHTML = '';
if (!attachedFile) return;
const chip = document.createElement('div');
chip.className = 'file-chip';
chip.innerHTML = '<span class="file-chip-name"></span>' +
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
'<button type="button" class="file-chip-remove" title="Remove file">&times;</button>';
chip.querySelector('.file-chip-name').textContent = attachedFile.name;
chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile);
slot.appendChild(chip);
}
// (1) file picker via the dropzone click
$('dropzone').addEventListener('click', () => $('file-input').click());
$('file-input').addEventListener('change', () => {
if ($('file-input').files.length) setAttachedFile($('file-input').files[0]);
});
// (2) drag-and-drop onto the dropzone (and the page broadly)
const dz = $('dropzone');
['dragenter', 'dragover'].forEach(ev => {
document.addEventListener(ev, e => {
if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return;
e.preventDefault();
dz.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
document.addEventListener(ev, e => {
if (ev === 'drop') e.preventDefault();
if (e.target === dz || ev === 'drop') dz.classList.remove('dragover');
});
});
document.addEventListener('drop', e => {
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
e.preventDefault();
setAttachedFile(e.dataTransfer.files[0]);
});
// (3) Ctrl+V of a file anywhere on the page
document.addEventListener('paste', e => {
const files = e.clipboardData && e.clipboardData.files;
if (!files || !files.length) return; // normal text paste into the editor
e.preventDefault();
setAttachedFile(files[0]);
});
// file create path: POST multipart. 1 file = 1 paste: when a file is
// attached the editor text is ignored (server enforces this too).
async function createFilePaste() {
const fd = new FormData();
fd.append('file', attachedFile);
if ($('title').value) fd.append('title', $('title').value);
if ($('custom').value) fd.append('custom_slug', $('custom').value);
if ($('haspw').checked) fd.append('password', $('password').value);
if ($('burn').checked) {
fd.append('burn_after_read', 'true');
fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1));
}
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
fd.append('expires_in', dur);
} else if (exp) {
fd.append('expires_in', exp);
}
const res = await fetch('/api/pastes', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Create failed', 'error');
return;
}
finishCreate(data);
}
// #4: can creation — POST multipart to /api/pastes/can. The main editor is
// the first item; each extra can-item row is another text item.
async function createCan() {
const items = [];
if (content.value.trim()) {
items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''});
}
document.querySelectorAll('#can-items .can-item-row').forEach(row => {
const t = row.querySelector('.can-item-title').value.trim();
const c = row.querySelector('.can-item-content').value;
if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c});
});
if (!items.length) { toast('Nothing to put in the can', 'error'); return; }
const fd = new FormData();
fd.append('title', $('title').value || 'Untitled can');
fd.append('json_items', JSON.stringify(items));
if ($('haspw').checked) fd.append('password', $('password').value);
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
const exp = document.querySelector('input[name="exp"]:checked').value;
if (exp === 'custom') {
const dur = composeCustomExpiry();
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
if (dur) fd.append('expires_in', dur);
} else if (exp) {
fd.append('expires_in', exp);
}
if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim());
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
const data = await res.json();
if (!res.ok) {
showResult(friendlyError(data), 'err');
toast('Can create failed', 'error');
return;
}
const url = location.origin + data.url;
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok');
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
// password-protected can: unlock now with the password we already have (#26 parity)
if ($('haspw').checked && data.id) {
const pd = new FormData();
pd.append('password', $('password').value);
try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {}
}
location.href = data.url;
}
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}} {{template "foot" .}}
+5 -43
View File
@@ -8,13 +8,13 @@
<div class="spacer"></div> <div class="spacer"></div>
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button> <button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a> <a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a> <a class="iconbtn" href="#" id="copy-btn">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem(); return false;">delete</a>{{end}} {{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">delete</a>{{end}}
</div> </div>
</div> </div>
<div class="float"> <div class="float">
<div class="stats-pill" id="stats-pill"> <div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()"> <button type="button" class="stats-head" id="stats-toggle" aria-expanded="false">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg> <svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span> <span class="stats-summary">{{.StatsSummary}}</span>
</button> </button>
@@ -35,7 +35,7 @@
</div> </div>
{{if .JustCreated}} {{if .JustCreated}}
<div class="float"> <div class="float">
<div class="created-banner" style="display:block"> <div class="created-banner">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a> Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}} {{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div> </div>
@@ -59,43 +59,5 @@
</div> </div>
</div> </div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}"> <input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script> <script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem() {
if (!confirm('Hard delete this paste immediately?')) return;
let tok = '';
try { tok = sessionStorage.getItem('deletion_token_{{.ID}}') || ''; } catch(e) {}
if (!tok) { alert('deletion token not available in this browser'); return; }
fetch('/api/pastes/{{.ID}}/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
{{template "foot" .}} {{template "foot" .}}
+3 -94
View File
@@ -13,102 +13,11 @@
</button> </button>
<h3>Theme</h3> <h3>Theme</h3>
<div class="theme-grid" id="theme-grid"></div> <div class="theme-grid" id="theme-grid"></div>
<h3 style="margin-top:18px">Editor</h3> <h3 class="mt18">Editor</h3>
<label class="toggle" for="wrap-setting" style="display:inline-flex"><input type="checkbox" id="wrap-setting"> Line wrap</label> <label class="toggle toggle-inline" for="wrap-setting"><input type="checkbox" id="wrap-setting"> Line wrap</label>
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p> <p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/static/settings.js" defer></script>
(function () {
// #112: derive each preset's swatches from the real CSS variables in
// app.css by temporarily applying data-preset, so they can never drift.
// #127: 5 theme pairs, light swatches top row, dark bottom row.
var pairs = [
{ id: 'midnight', name: 'Midnight' },
{ id: 'smooth', name: 'Smooth' },
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
{ id: 'pastel-peach', name: 'Pastel Peach' },
{ id: 'pastel-cloud', name: 'Pastel Cloud' }
];
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
function presetColors(id) {
var root = document.documentElement;
var prev = root.getAttribute('data-preset');
root.setAttribute('data-preset', id);
var cs = getComputedStyle(root);
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
return colors;
}
// current base pair + dark flag from the resolved data-preset.
// "midnight" is itself the dark variant, so check dark ids first.
function state() {
var p = document.documentElement.dataset.preset || 'midnight';
if (p === 'midnight' || /-dark$/.test(p)) {
return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true };
}
return { base: p === 'midnight-light' ? 'midnight' : p, dark: false };
}
var grid = document.getElementById('theme-grid');
var cards = {};
// #132: midnight is dark-first (root preset = midnight = dark; its light
// variant is midnight-light), the others are light-first. Resolve the
// LIGHT and DARK preset ids generically so the light swatches always
// render in the top row of every card.
function lightPreset(id) {
if (id === 'midnight') return 'midnight-light';
return id; // light-first bases use themselves as the light variant
}
function darkPreset(id) {
if (id === 'midnight') return 'midnight'; // root preset is midnight's dark
return id + '-dark';
}
pairs.forEach(function (t) {
var light = presetColors(lightPreset(t.id));
var dark = presetColors(darkPreset(t.id));
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'theme-card';
btn.setAttribute('aria-pressed', 'false');
btn.setAttribute('data-pair', t.id);
btn.innerHTML = '<strong>' + t.name + '</strong>' +
'<span class="swatches">' + light.map(function (c) {
return '<span class="swatch" style="background:' + c + '"></span>';
}).join('') + '</span>' +
'<span class="swatches">' + dark.map(function (c) {
return '<span class="swatch" style="background:' + c + '"></span>';
}).join('') + '</span>';
btn.addEventListener('click', function () {
var dark = state().dark;
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true');
});
cards[t.id] = btn;
grid.appendChild(btn);
});
function sync() {
var s = state();
Object.keys(cards).forEach(function (k) {
cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false');
});
var dt = document.getElementById('settings-dark-toggle');
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
}
sync();
// the topbar script runs before this button exists, so wire it here
var dt = document.getElementById('settings-dark-toggle');
dt.addEventListener('click', function () {
var btns = document.querySelectorAll('.topbar .dark-toggle');
if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange'));
});
document.addEventListener('palette-darkchange', sync);
})();
</script>
{{template "foot" .}} {{template "foot" .}}
+1 -8
View File
@@ -18,12 +18,5 @@
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div> <div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div> </div>
</div> </div>
<script> <script src="/static/unlock.js" defer></script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
{{template "foot" .}} {{template "foot" .}}
+7 -3
View File
@@ -383,8 +383,12 @@ func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// #59: security headers for rendered HTML pages. Applied wherever the // #59: security headers for rendered HTML pages. Applied wherever the
// response is text/html (page templates and the inline can page); JSON API // response is text/html (page templates and the inline can page); JSON API
// responses and /raw content pass through untouched. script-src allows // responses and /raw content pass through untouched.
// 'unsafe-inline' because the page templates carry inline scripts; CSP // #139: script-src and style-src no longer allow 'unsafe-inline'. All
// previously-inline scripts moved to external files under static/ (page data
// reaches them via data-* attributes on the script tags), inline style
// attributes became CSS classes, and JS sets swatch colors via CSSOM. The
// img-src data: allowance stays: SVG data-URI backgrounds in app.css need it.
// default-src 'self' still blocks external content and object/frame embeds, // default-src 'self' still blocks external content and object/frame embeds,
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34 // and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
// pentest. Runs after the handler so the Content-Type is already set. // pentest. Runs after the handler so the Content-Type is already set.
@@ -396,7 +400,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
// is harmless and arguably desirable. // is harmless and arguably desirable.
h := w.Header() h := w.Header()
h.Set("Content-Security-Policy", h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'") "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer") h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)