This commit is contained in:
maurice committed 2026-11-14 18:25:54 +01:00
1 parent b444b5759d
commit a3067b011d
8 files changed
+123 -110

No files matched your search

+5 -3
View File
@@ -24,11 +24,11 @@ Route handlers can access the shared database pool with the `State(pool): State<
## Browser app
Open `/gui` for the browser workspace. Create text pastes, one-time secrets, and short links; choose custom IDs, languages, expiry, and view limits; and open shares with syntax highlighting or as plain text.
Open `/gui` for the browser workspace. Create text pastes, one-time secrets, and short links; choose custom IDs, languages, expiry, and view limits. After saving, a dialog prominently displays the returned link for copying or opening. Opening a share navigates directly to its page or redirect.
The language dropdown is populated from irosashi's supported grammars. The home page renders this README as HTML and shares the browser app's logo, navigation, and styling.
Sign in with HTTP Basic credentials to browse, filter, edit, and delete your own shares. A new username creates an account. Credentials stay in page memory and are cleared on sign-out or reload. The app uses the existing API without page reloads.
Sign in with HTTP Basic credentials to browse, filter, edit, and delete your own shares. A new username creates an account. Credentials stay in page memory and are cleared on sign-out or reload. Creating and managing shares uses the existing API without page reloads.
Opening a paste or following a short link counts as a view. Listing records and loading them into the editor do not. Updates preserve omitted options and view counters.
@@ -104,7 +104,9 @@ curl --request POST 'http://localhost:3000/once/my-secret' \
**Returns:** the paste as HTML or plain text, based on the `Accept` header. The default is plain text. HTML uses the stored language for syntax highlighting, falling back to plain text for missing or unknown languages.
HTML and GUI previews show the view counter, including the current read, and the maximum when set. Both representations include `X-Paste-Views` and, when limited, `X-Paste-Max-Views` response headers, so displaying the counter does not require another read.
HTML paste pages show the view counter, including the current read, and the maximum when set. HTML and plain-text responses include `X-Paste-Views` and, when limited, `X-Paste-Max-Views` response headers, so displaying the counter does not require another read.
The HTML page includes a **Copy raw code** button that preserves the original content and whitespace without another request or view. If clipboard access is unavailable, the raw text is selected for manual copying.
Each successful read increments `views`. The final permitted read returns the content and deletes the paste. Missing, expired, and exhausted pastes return `404`. Expired and exhausted records are also cleaned up in the background every minute.
+1 -1
View File
@@ -43,7 +43,7 @@ pub async fn create(
.fetch_optional(db)
.await
.map_err(db_error)?;
if let Some(id) = created {
return Ok(id);
}
+14 -3
View File
@@ -5,6 +5,7 @@ use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use irosashi::{CodeToHtmlOptions, Highlighter};
use sqlx::SqlitePool;
@@ -17,6 +18,7 @@ use super::{db_error, return_html};
pub struct NotePage {
paste: Paste,
code: String,
raw_code: String,
}
pub async fn get_note(
@@ -24,10 +26,12 @@ pub async fn get_note(
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
let paste = Paste::read(&db, &id, PasteType::TextV1)
let Some(paste) = Paste::read(&db, &id, PasteType::TextV1)
.await
.map_err(db_error)?
.ok_or(StatusCode::NOT_FOUND)?;
else {
return Ok((StatusCode::NOT_FOUND, "Note not found").into_response());
};
let mut metadata = HeaderMap::new();
metadata.insert("x-paste-views", HeaderValue::from(paste.views));
@@ -46,9 +50,16 @@ pub async fn get_note(
),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
NotePage { paste, code }.into_response()
let raw_code = STANDARD.encode(&paste.content);
NotePage {
paste,
code,
raw_code,
}
.into_response()
} else {
paste.content.into_response()
};
Ok((metadata, response).into_response())
}
+27 -11
View File
@@ -310,15 +310,6 @@ button:hover {
color: #923e31;
}
.link-button {
padding: 0;
background: transparent;
color: var(--accent);
text-decoration: underline;
text-align: left;
overflow-wrap: anywhere;
}
button:disabled {
opacity: 0.55;
cursor: wait;
@@ -376,8 +367,33 @@ hr {
color: #7f3022;
}
.result, .preview {
margin-top: 1.25rem;
dialog.result {
box-sizing: border-box;
width: min(36rem, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
box-shadow: 0 1.5rem 5rem rgb(20 35 20 / 20%);
}
dialog::backdrop {
background: rgb(20 35 20 / 45%);
backdrop-filter: blur(3px);
}
.result h2 {
font-size: 1.75rem;
}
.result #share-url {
font-family: ui-monospace, monospace;
}
.result-actions {
justify-content: space-between;
margin-block: 1.5rem;
}
.result [data-error="true"] {
color: #7f3022;
}
.result p:last-child {
+29 -75
View File
@@ -6,12 +6,13 @@ let notes = [];
let editing = null;
function notify(message, error = false) {
$("status").textContent = message;
$("status").dataset.error = String(error);
$("status").hidden = false;
const target = $("share-result").open ? $("result-feedback") : $("status");
target.textContent = message;
target.dataset.error = String(error);
target.hidden = false;
}
async function api(path, { method = "GET", body, accept = "text/plain", includeHeaders = false } = {}, auth = authorization) {
async function api(path, { method = "GET", body, accept = "text/plain" } = {}, auth = authorization) {
const headers = { Accept: accept };
if (auth) headers.Authorization = auth;
const response = await fetch(path, { method, body, headers, credentials: "omit", cache: "no-store" });
@@ -26,8 +27,7 @@ async function api(path, { method = "GET", body, accept = "text/plain", includeH
};
throw new Error(messages[response.status] || `The request failed (${response.status}). Please try again.`);
}
const data = accept === "application/json" ? await response.json() : await response.text();
return includeHeaders ? { data, headers: response.headers } : data;
return accept === "application/json" ? response.json() : response.text();
}
async function busy(button, action) {
@@ -96,7 +96,10 @@ function renderNotes() {
for (const note of filtered) {
const row = document.createElement("tr");
const name = cell(row, "");
name.append(action(note.id, () => openEntry(note), "link-button"));
const link = document.createElement("a");
link.href = sharePath(note.id, isUrl(note));
link.textContent = note.id;
name.append(link);
const type = document.createElement("small");
type.textContent = isUrl(note) ? "Short link" : note.language || "Plain text";
name.append(type);
@@ -154,7 +157,7 @@ function resetEditor() {
editing = null;
$("language").querySelector("[data-current]")?.remove();
$("editor-form").reset();
$("share-result").hidden = true;
$("share-result").close();
updateEditor();
}
@@ -199,53 +202,6 @@ function reference(value, url) {
return { id, url };
}
async function openPaste(id) {
$("preview").replaceChildren();
$("preview").hidden = true;
const highlighted = $("open-format").value === "html";
const { data: content, headers } = await api(sharePath(id, false), {
accept: highlighted ? "text/html" : "text/plain",
includeHeaders: true,
});
const counter = document.createElement("p");
counter.className = "badge view-counter";
counter.textContent = `Views: ${headers.get("x-paste-views")} / ${headers.get("x-paste-max-views") ?? "∞"}`;
$("preview").append(counter);
if (highlighted) {
const page = new DOMParser().parseFromString(content, "text/html");
const code = page.querySelector("main pre");
if (!code) throw new Error("The server returned an unexpected preview.");
$("preview").append(document.importNode(code, true));
} else {
const pre = document.createElement("pre");
pre.textContent = content;
$("preview").append(pre);
}
$("preview").hidden = false;
await loadNotes();
notify(`Opened ${id}. This read counts as a view.`);
}
async function openEntry(note) {
$("open-id").value = note.id;
$("open-kind").value = isUrl(note) ? "url" : "note";
updateOpen();
showPanel("open");
if (isUrl(note)) {
window.open(sharePath(note.id, true), "_blank", "noopener,noreferrer");
notify("The short link opens in a new tab. Refresh your collection to update its view count.");
} else {
await openPaste(note.id);
}
}
function updateOpen() {
const url = $("open-kind").value === "url";
$("format-field").hidden = url;
$("open-submit").textContent = url ? "Follow short link ↗" : "Open paste";
$("preview").hidden = true;
}
$("login-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
@@ -272,8 +228,6 @@ $("logout").addEventListener("click", () => {
$("login-form").reset();
$("logout").hidden = true;
$("session-label").textContent = "Anonymous";
$("preview").replaceChildren();
$("preview").hidden = true;
resetEditor();
renderNotes();
notify("Signed out.");
@@ -282,6 +236,7 @@ $("logout").addEventListener("click", () => {
$("editor-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const current = editing;
const url = $("kind").value === "url";
const body = new URLSearchParams();
body.set(url ? "href" : "content", $("content").value);
@@ -290,22 +245,27 @@ $("editor-form").addEventListener("submit", event => {
if ($("max-views").value !== "") body.set("max_views", $("max-views").value);
const requested = $("custom-id").value.trim();
let path = url ? "/to" : $("once").checked ? "/once" : "";
if (editing) path = sharePath(editing.id, url);
if (current) path = sharePath(current.id, url);
else path = requested ? `${path}/${encodeURIComponent(requested)}` : path || "/";
const result = await api(path, { method: editing ? "PUT" : "POST", body });
const id = editing ? editing.id : result;
const result = await api(path, { method: current ? "PUT" : "POST", body });
const id = current ? current.id : result;
const link = new URL(sharePath(id, url), location.origin).href;
$("share-url").value = link;
$("share-link").href = link;
$("result-title").textContent = editing ? "Changes saved" : "Your share is ready";
$("share-result").hidden = false;
$("result-title").textContent = current ? "Changes saved" : "Your share is ready";
const message = `${current ? "Updated" : "Created"} ${id}.${requested && requested !== id ? " The requested ID was unavailable, so a new one was generated." : ""}`;
$("result-feedback").textContent = message;
$("result-feedback").dataset.error = "false";
$("result-feedback").hidden = false;
$("share-result").showModal();
$("share-url").focus();
$("share-url").select();
await loadNotes();
if (editing) {
if (current && editing === current) {
editing = notes.find(note => note.id === id) || editing;
$("expires").value = "";
updateEditor();
}
notify(`${editing ? "Updated" : "Created"} ${id}.${requested && requested !== id ? " The requested ID was unavailable, so a new one was generated." : ""}`);
});
});
@@ -322,17 +282,12 @@ $("copy-url").addEventListener("click", event => busy(event.currentTarget, async
$("open-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
try {
const ref = reference($("open-id").value, $("open-kind").value === "url");
$("open-kind").value = ref.url ? "url" : "note";
updateOpen();
if (ref.url) {
window.open(sharePath(ref.id, true), "_blank", "noopener,noreferrer");
notify("The short link opens in a new tab. Following it counts as a view.");
} else {
await openPaste(ref.id);
}
});
location.assign(sharePath(ref.id, ref.url));
} catch (error) {
notify(error.message, true);
}
});
for (const link of document.querySelectorAll("[data-panel]")) {
@@ -344,7 +299,6 @@ for (const link of document.querySelectorAll("[data-panel]")) {
$("kind").addEventListener("change", updateEditor);
$("once").addEventListener("change", updateEditor);
$("new-note").addEventListener("click", resetEditor);
$("open-kind").addEventListener("change", updateOpen);
$("search").addEventListener("input", renderNotes);
$("refresh").addEventListener("click", event => busy(event.currentTarget, loadNotes));
window.addEventListener("hashchange", () => showPanel(location.hash.slice(1)));
+22
View File
@@ -0,0 +1,22 @@
"use strict";
const copy = document.getElementById("copy-code");
const status = document.getElementById("copy-status");
const fallback = document.getElementById("raw-code");
copy.addEventListener("click", async () => {
const bytes = Uint8Array.from(atob(copy.dataset.raw), character => character.charCodeAt(0));
const content = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
copy.disabled = true;
try {
await navigator.clipboard.writeText(content);
fallback.hidden = true;
} catch {
fallback.value = content;
fallback.hidden = false;
fallback.focus();
fallback.select();
} finally {
copy.disabled = false;
}
});
+18 -16
View File
@@ -89,15 +89,6 @@
<p class="hint">If a custom ID is taken, you'll get a fresh one. Always use the link returned after saving.</p>
</aside>
</div>
<section id="share-result" class="card result" aria-labelledby="result-title" hidden>
<p class="eyebrow">READY TO SHARE</p>
<h2 id="result-title">Your link</h2>
<div class="copy-row">
<input id="share-url" aria-label="Share URL" readonly>
<button id="copy-url" type="button" class="secondary">Copy link</button>
</div>
<p><a id="share-link" target="_blank" rel="noopener noreferrer">Open share ↗</a> <span class="hint">Opening counts as a view.</span></p>
</section>
</section>
<section id="collection" class="panel card" aria-labelledby="collection-title" hidden>
@@ -120,15 +111,26 @@
<p class="muted">Have an ID or a Pastable link? Open it here.</p>
<form id="open-form">
<label>ID or share URL<input id="open-id" placeholder="my-paste or a full share URL" required autocomplete="off"></label>
<div class="form-grid">
<label>Type<select id="open-kind"><option value="note">Text or code</option><option value="url">Short link</option></select></label>
<label id="format-field">Display<select id="open-format"><option value="html">Syntax highlighted</option><option value="plain">Plain text</option></select></label>
</div>
<button type="submit" id="open-submit">Open paste</button>
<p class="hint">Opening a paste or following a link counts as a view. Short links open in a new tab.</p>
<label>Type<select id="open-kind"><option value="note">Text or code</option><option value="url">Short link</option></select></label>
<button type="submit">Open share</button>
<p class="hint">Opening a share navigates to it and counts as a view.</p>
</form>
<div id="preview" class="preview" aria-live="polite" hidden></div>
</section>
<dialog id="share-result" class="card result" aria-labelledby="result-title" aria-describedby="result-feedback">
<p class="eyebrow">READY TO SHARE</p>
<h2 id="result-title">Your share is ready</h2>
<p id="result-feedback" class="hint" role="status" aria-live="polite"></p>
<label for="share-url">Share link</label>
<div class="copy-row">
<input id="share-url" readonly autofocus>
<button id="copy-url" type="button">Copy link</button>
</div>
<div class="actions result-actions">
<a id="share-link">Open share ↗</a>
<form method="dialog"><button class="secondary">Done</button></form>
</div>
<p class="hint">Opening counts as a view. Copying the link does not.</p>
</dialog>
<noscript><p class="status">Enable JavaScript to use the browser app. The HTTP API remains available.</p></noscript>
<script src="/static/gui.js" defer></script>
{% endblock %}
+7 -1
View File
@@ -4,8 +4,14 @@
<article class="card paste-view">
<div class="section-heading">
<div><p class="eyebrow">SHARED PASTE</p><h1>{{ paste.id }}</h1></div>
<p class="badge view-counter">Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}</p>
<div class="actions">
<p class="badge view-counter">Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}</p>
<button id="copy-code" class="secondary" type="button" data-raw="{{ raw_code }}">Copy raw code</button>
</div>
</div>
{{ code|safe }}
<p id="copy-status" class="hint" role="status" aria-live="polite" hidden></p>
<textarea id="raw-code" aria-label="Raw paste content" rows="8" readonly spellcheck="false" hidden></textarea>
</article>
<script src="/static/paste.js" defer></script>
{% endblock %}