- Move all inline <script> blocks (layout head/theme, topbar dark toggle, foot, paste, new, history, mine, settings, admin, unlock) to external files under internal/web/static/. Page data reaches scripts via data-* attributes (data-paste-id, data-default-dark) instead of template vars. - Replace inline onclick handlers (copy, delete, stats toggle) with addEventListener wiring. - Convert inline style="" attributes to CSS utility classes; swatch colors are now set via CSSOM/DOM APIs instead of innerHTML strings. - script-src/style-src are now plain 'self'; img-src data: stays for the SVG data-URI backgrounds. Verified with headless chromium: zero CSP violations on all pages in dark and light presets, theme swatches, admin lock, tables and paste view render correctly.
399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
// #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">×</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(); }
|
|
});
|