This commit is contained in:
maurice committed 2026-11-14 18:20:29 +01:00
1 parent 35e7259775
commit b444b5759d
17 files changed
+1040 -28

No files matched your search

Generated
+13
View File
@@ -1035,6 +1035,8 @@ dependencies = [
"axum", "axum",
"base64 0.23.1", "base64 0.23.1",
"irosashi", "irosashi",
"pulldown-cmark",
"rand",
"serde", "serde",
"sqlx", "sqlx",
"tokio", "tokio",
@@ -1088,6 +1090,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
+2
View File
@@ -10,6 +10,8 @@ askama_web = { version = "0.16.0", features = ["axum-0.8"] }
axum = "0.8.9" axum = "0.8.9"
base64 = "0.23.1" base64 = "0.23.1"
irosashi = "0.3.1" irosashi = "0.3.1"
pulldown-cmark = { version = "0.9.6", default-features = false }
rand = "0.10.3"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.54.1", features = ["rt-multi-thread"] } tokio = { version = "1.54.1", features = ["rt-multi-thread"] }
+17 -2
View File
@@ -11,6 +11,7 @@ cargo run
``` ```
The server listens on `0.0.0.0:3000` and is available locally at `http://localhost:3000`. The server listens on `0.0.0.0:3000` and is available locally at `http://localhost:3000`.
Set `PORT` to listen on another port.
On startup, the server creates the SQLite database and initializes its tables before accepting requests. On startup, the server creates the SQLite database and initializes its tables before accepting requests.
The default database is `pastable.db` in the working directory. Set `DATABASE_URL` to use a different location: The default database is `pastable.db` in the working directory. Set `DATABASE_URL` to use a different location:
@@ -21,6 +22,16 @@ DATABASE_URL=sqlite://data.db cargo run
Route handlers can access the shared database pool with the `State(pool): State<SqlitePool>` extractor. Route handlers can access the shared database pool with the `State(pool): State<SqlitePool>` extractor.
## 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.
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.
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.
## Features ## Features
- Share text and code, with optional syntax highlighting. - Share text and code, with optional syntax highlighting.
@@ -47,7 +58,7 @@ All paths are relative to the server URL. `{id}` is a path parameter containing
| Form parameter | Type | Required | Default | Description | | Form parameter | Type | Required | Default | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `content` | String | Yes | — | Text or code to store. For raw text requests, the entire body supplies this value. | | `content` | String | Yes | — | Text or code to store. For raw text requests, the entire body supplies this value. |
| `language` | String | No | Not set | Language for syntax highlighting. | | `language` | String | No | Auto detect | Language for syntax highlighting. When omitted, irosashi detects from the first line (for example a shebang), then from a requested custom ID such as `main.rs`. Without a match, the paste stays plain text. Use `text` to force plain text. |
| `expires` | Integer | No | No expiration | Lifetime in seconds from the request; `0` expires immediately. Must be nonnegative. | | `expires` | Integer | No | No expiration | Lifetime in seconds from the request; `0` expires immediately. Must be nonnegative. |
| `max_views` | Integer | No | Unlimited views | Positive maximum number of views before self-destruction. The `/once` routes force this to `1`. | | `max_views` | Integer | No | Unlimited views | Positive maximum number of views before self-destruction. The `/once` routes force this to `1`. |
@@ -55,7 +66,7 @@ All paths are relative to the server URL. `{id}` is a path parameter containing
**Returns:** the assigned ID as plain text. This is an ID, not a full URL. Always use the returned ID, since a requested custom ID may already be taken. **Returns:** the assigned ID as plain text. This is an ID, not a full URL. Always use the returned ID, since a requested custom ID may already be taken.
IDs are shared by pastes and short links. Reserved route names (`list`, `once`, `to`, and `static`) and IDs containing `/` are unavailable as custom IDs; these also receive a generated ID. Generated IDs contain exactly 8 alphanumeric characters, generated in Rust with `rand`. Confusing characters (`0`, `1`, `I`, `L`, `O`, `i`, `l`, `o`) are excluded. IDs are shared by pastes and short links. Reserved route names (`gui`, `list`, `once`, `to`, and `static`) and IDs containing `/` are unavailable as custom IDs; these also receive a generated ID.
Create a paste from raw text: Create a paste from raw text:
@@ -93,6 +104,8 @@ 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. **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.
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. 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.
```sh ```sh
@@ -175,6 +188,8 @@ curl --request PUT --user 'alice:example-password' 'http://localhost:3000/to/my-
With `Accept: text/html`, the response is an HTML table containing IDs, types, content, languages, view counts and limits, creation and update times, and expiration times. Dates are displayed in the browser's local timezone. With `Accept: text/html`, the response is an HTML table containing IDs, types, content, languages, view counts and limits, creation and update times, and expiration times. Dates are displayed in the browser's local timezone.
With `Accept: application/json`, the response is an array of full `Paste` records, including `id`, `paste_type` (`TextV1` or `UrlV1`), `content`, `language`, `expiration`, `max_views`, `views`, `created_at`, `updated_at`, and `owner`. Timestamps are Unix seconds; unset optional values are `null`.
```sh ```sh
curl --user 'alice:example-password' 'http://localhost:3000/list' curl --user 'alice:example-password' 'http://localhost:3000/list'
``` ```
+5 -3
View File
@@ -11,7 +11,7 @@ mod db;
mod paste; mod paste;
mod routes; mod routes;
use crate::routes::{ use crate::routes::{
delete_note, get_note, get_redirect, home_page, list_notes, post_note, post_once, delete_note, get_note, get_redirect, gui_page, home_page, list_notes, post_note, post_once,
post_redirect, put_note, put_redirect, post_redirect, put_note, put_redirect,
}; };
@@ -24,6 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new() let app = Router::new()
.route("/", get(home_page).post(post_note)) .route("/", get(home_page).post(post_note))
.route("/gui", get(gui_page))
.route("/once", post(post_once)) .route("/once", post(post_once))
.route("/once/{id}", post(post_once)) .route("/once/{id}", post(post_once))
.route("/to", post(post_redirect)) .route("/to", post(post_redirect))
@@ -46,8 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)) ))
.with_state(pool); .with_state(pool);
let listener = TcpListener::bind("0.0.0.0:3000").await?; let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_owned());
println!("Listening on http://localhost:3000"); let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
println!("Listening on http://localhost:{port}");
serve(listener, app).await?; serve(listener, app).await?;
Ok(()) Ok(())
} }
+2 -2
View File
@@ -1,6 +1,6 @@
use sqlx::SqlitePool; use sqlx::SqlitePool;
#[derive(sqlx::FromRow)] #[derive(serde::Serialize, sqlx::FromRow)]
pub struct Paste { pub struct Paste {
pub id: String, pub id: String,
#[sqlx(rename = "type")] #[sqlx(rename = "type")]
@@ -15,7 +15,7 @@ pub struct Paste {
pub owner: Option<String>, pub owner: Option<String>,
} }
#[derive(sqlx::Type)] #[derive(serde::Serialize, sqlx::Type)]
#[repr(i64)] #[repr(i64)]
pub enum PasteType { pub enum PasteType {
TextV1 = 0, TextV1 = 0,
+2
View File
@@ -4,6 +4,7 @@ mod create;
mod delete_note; mod delete_note;
mod get_note; mod get_note;
mod get_redirect; mod get_redirect;
mod gui;
mod home; mod home;
mod input; mod input;
mod list_notes; mod list_notes;
@@ -18,6 +19,7 @@ mod update;
pub use delete_note::delete_note; pub use delete_note::delete_note;
pub use get_note::get_note; pub use get_note::get_note;
pub use get_redirect::get_redirect; pub use get_redirect::get_redirect;
pub use gui::gui_page;
pub use home::home_page; pub use home::home_page;
pub use list_notes::list_notes; pub use list_notes::list_notes;
pub use post_note::post_note; pub use post_note::post_note;
+22 -5
View File
@@ -1,4 +1,5 @@
use axum::{Extension, extract::Path, http::StatusCode}; use axum::{Extension, extract::Path, http::StatusCode};
use irosashi::Highlighter;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::{auth::User, paste::PasteType}; use crate::{auth::User, paste::PasteType};
@@ -9,21 +10,30 @@ pub async fn create(
db: &SqlitePool, db: &SqlitePool,
id: Option<Path<String>>, id: Option<Path<String>>,
user: Option<Extension<User>>, user: Option<Extension<User>>,
input: PasteInput, mut input: PasteInput,
paste_type: PasteType, paste_type: PasteType,
) -> Result<String, StatusCode> { ) -> Result<String, StatusCode> {
let mut id = id.map(|Path(id)| id).filter(|id| { let mut id = id.map(|Path(id)| id).filter(|id| {
!matches!(id.as_str(), "list" | "once" | "to" | "static") && !id.contains('/') !matches!(id.as_str(), "gui" | "list" | "once" | "to" | "static") && !id.contains('/')
}); });
if matches!(paste_type, PasteType::TextV1) && input.language.is_none() {
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
input.language = highlighter
.detect_language_by_first_line(input.content.lines().next().unwrap_or_default());
}
let owner = user.map(|Extension(user)| user.username); let owner = user.map(|Extension(user)| user.username);
let expiration = input.expiration()?; let expiration = input.expiration()?;
loop { loop {
let candidate = id.take().unwrap_or_else(generate_id);
let created = sqlx::query_scalar( let created = sqlx::query_scalar(
"INSERT INTO notes (id, type, content, language, expiration, max_views, created_at, owner) "INSERT INTO notes (id, type, content, language, expiration, max_views, created_at, owner)
VALUES (COALESCE(?, lower(hex(randomblob(12)))), ?, ?, ?, ?, ?, unixepoch(), ?) VALUES (?, ?, ?, ?, ?, ?, unixepoch(), ?)
ON CONFLICT(id) DO NOTHING RETURNING id", ON CONFLICT(id) DO NOTHING RETURNING id",
) )
.bind(id.as_deref()) .bind(candidate)
.bind(&paste_type) .bind(&paste_type)
.bind(&input.content) .bind(&input.content)
.bind(&input.language) .bind(&input.language)
@@ -33,9 +43,16 @@ pub async fn create(
.fetch_optional(db) .fetch_optional(db)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
if let Some(id) = created { if let Some(id) = created {
return Ok(id); return Ok(id);
} }
id = None;
} }
} }
fn generate_id() -> String {
const ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";
(0..8)
.map(|_| ALPHABET[rand::random_range(0..ALPHABET.len())] as char)
.collect()
}
+13 -5
View File
@@ -2,7 +2,7 @@ use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use irosashi::{CodeToHtmlOptions, Highlighter}; use irosashi::{CodeToHtmlOptions, Highlighter};
@@ -15,6 +15,7 @@ use super::{db_error, return_html};
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "note.html")] #[template(path = "note.html")]
pub struct NotePage { pub struct NotePage {
paste: Paste,
code: String, code: String,
} }
@@ -28,7 +29,13 @@ pub async fn get_note(
.map_err(db_error)? .map_err(db_error)?
.ok_or(StatusCode::NOT_FOUND)?; .ok_or(StatusCode::NOT_FOUND)?;
if return_html(&headers) { let mut metadata = HeaderMap::new();
metadata.insert("x-paste-views", HeaderValue::from(paste.views));
if let Some(max) = paste.max_views {
metadata.insert("x-paste-max-views", HeaderValue::from(max));
}
let response = if return_html(&headers) {
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let code = highlighter let code = highlighter
.code_to_html( .code_to_html(
@@ -39,8 +46,9 @@ pub async fn get_note(
), ),
) )
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(NotePage { code }.into_response()) NotePage { paste, code }.into_response()
} else { } else {
Ok(paste.content.into_response()) paste.content.into_response()
} };
Ok((metadata, response).into_response())
} }
+17
View File
@@ -0,0 +1,17 @@
use askama::Template;
use askama_web::WebTemplate;
use axum::http::StatusCode;
use irosashi::Highlighter;
#[derive(Template, WebTemplate)]
#[template(path = "gui.html")]
pub struct GuiPage {
languages: Vec<String>,
}
pub async fn gui_page() -> Result<GuiPage, StatusCode> {
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(GuiPage {
languages: highlighter.languages(),
})
}
+42 -2
View File
@@ -1,10 +1,50 @@
use std::sync::LazyLock;
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use pulldown_cmark::{Event, Options, Parser, Tag, html};
static README_HTML: LazyLock<String> = LazyLock::new(|| {
let events: Vec<_> = Parser::new_ext(
include_str!("../../README.md"),
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS,
)
.collect();
let anchored = events.iter().enumerate().map(|(index, event)| {
if let Event::Start(Tag::Heading(level, None, _)) = event {
let title: String = events[index + 1..]
.iter()
.take_while(|event| !matches!(event, Event::End(Tag::Heading(..))))
.filter_map(|event| match event {
Event::Text(text) | Event::Code(text) => Some(text.as_ref()),
_ => None,
})
.collect();
let slug: String = title
.to_lowercase()
.chars()
.filter(|character| {
character.is_alphanumeric() || *character == '-' || character.is_whitespace()
})
.collect();
let slug = slug.split_whitespace().collect::<Vec<_>>().join("-");
return Event::Html(format!("<{level} id=\"{slug}\">").into());
}
event.clone()
});
let mut rendered = String::new();
html::push_html(&mut rendered, anchored);
rendered
});
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "home.html")] #[template(path = "home.html")]
pub struct HomePage; pub struct HomePage {
readme: &'static str,
}
pub async fn home_page() -> HomePage { pub async fn home_page() -> HomePage {
HomePage HomePage {
readme: &README_HTML,
}
} }
+9 -3
View File
@@ -1,9 +1,9 @@
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{ use axum::{
Extension, Extension, Json,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode, header::ACCEPT},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -38,7 +38,13 @@ pub async fn list_notes(
.await .await
.map_err(db_error)?; .map_err(db_error)?;
if return_html(&headers) { if headers
.get(ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|accept| accept.contains("application/json"))
{
Ok(Json(notes).into_response())
} else if return_html(&headers) {
Ok(ListPage { notes }.into_response()) Ok(ListPage { notes }.into_response())
} else { } else {
Ok(notes Ok(notes
+385 -3
View File
@@ -4,10 +4,11 @@
--muted: #60675c; --muted: #60675c;
--accent: #376345; --accent: #376345;
--surface: #eef1e9; --surface: #eef1e9;
--border: #d8ded2;
} }
body { body {
max-width: 42rem; max-width: 76rem;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1rem; padding: 0 1rem;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
@@ -16,6 +17,85 @@ body {
background: var(--background); background: var(--background);
} }
.site-header, .brand, .site-header nav {
display: flex;
align-items: center;
}
.site-header {
justify-content: space-between;
gap: 1rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.brand {
gap: 0.65rem;
font-size: 1.3rem;
font-weight: 750;
letter-spacing: -0.04em;
text-decoration: none;
}
.site-header nav {
gap: 1.25rem;
font-size: 0.85rem;
}
.site-header nav a {
color: var(--muted);
text-decoration: none;
}
.site-header nav a[aria-current] {
color: var(--accent);
font-weight: 650;
}
.readme {
max-width: 60rem;
margin-inline: auto;
overflow-wrap: anywhere;
}
.readme h2 {
margin-top: 2.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 1.5rem;
}
.readme h3 {
margin-top: 1.75rem;
}
.readme h1, .readme h2, .readme h3 {
scroll-margin-top: 1rem;
}
.readme table {
display: block;
margin-block: 1.5rem;
overflow-x: auto;
}
.readme :not(pre) > code {
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
background: var(--surface);
font-size: 0.9em;
}
.paste-view h1 {
font-size: 1.75rem;
overflow-wrap: anywhere;
}
.view-counter {
display: inline-block;
flex-shrink: 0;
}
a { a {
color: var(--accent); color: var(--accent);
} }
@@ -44,8 +124,9 @@ table {
} }
th, td { th, td {
padding: 0.5rem; padding: 0.8rem;
border-bottom: 1px solid var(--muted); border-bottom: 1px solid var(--border);
font-size: 0.8rem;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
@@ -60,3 +141,304 @@ td pre {
time { time {
white-space: nowrap; white-space: nowrap;
} }
h1, h2, p {
margin-top: 0;
}
h1 {
margin-bottom: 0.5rem;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
letter-spacing: -0.04em;
}
h2 {
font-size: 1.15rem;
letter-spacing: -0.02em;
}
.muted, .hint {
color: var(--muted);
}
.hint {
font-size: 0.85rem;
}
.eyebrow {
margin-bottom: 0.65rem;
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.12em;
color: var(--accent);
}
.workspace-heading, .section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.badge, .count {
padding: 0.2rem 0.65rem;
border-radius: 2rem;
background: var(--surface);
color: var(--accent);
font-size: 0.8rem;
overflow-wrap: anywhere;
}
.card {
padding: clamp(1rem, 3vw, 1.75rem);
border: 1px solid var(--border);
border-radius: 0.75rem;
background: #fff;
}
.account {
margin: 1rem 0 1.5rem;
}
.account summary {
cursor: pointer;
font-weight: 650;
}
.account form {
margin-top: 1rem;
}
.workspace-tabs {
display: flex;
gap: 1.5rem;
margin: 1.75rem 0;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.workspace-tabs a {
padding: 0.75rem 0;
border-bottom: 2px solid transparent;
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
font-weight: 650;
white-space: nowrap;
}
.workspace-tabs a[aria-current] {
border-bottom-color: var(--accent);
color: var(--accent);
}
.editor-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
align-items: start;
gap: 1.25rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
label {
display: block;
margin-bottom: 1rem;
font-size: 0.85rem;
font-weight: 600;
}
input, select, textarea, button {
box-sizing: border-box;
font: inherit;
}
input:not([type="checkbox"]), select, textarea {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border);
border-radius: 0.4rem;
color: var(--text);
background: var(--background);
font-weight: 400;
}
textarea {
resize: vertical;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
line-height: 1.6;
tab-size: 4;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
button {
padding: 0.65rem 1rem;
border: 1px solid transparent;
border-radius: 0.4rem;
background: var(--accent);
color: white;
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
}
button:hover {
filter: brightness(0.94);
}
.secondary {
border-color: var(--border);
background: white;
color: var(--text);
}
.danger {
border-color: #ead5d1;
background: #fff8f6;
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;
}
input:disabled, select:disabled {
opacity: 0.65;
}
.actions, .copy-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.editor-actions {
margin-top: 1.5rem;
}
.copy-row input {
flex: 1;
min-width: 12rem;
margin-top: 0 !important;
}
.check-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="checkbox"] {
accent-color: var(--accent);
}
hr {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid var(--border);
}
.status {
padding: 0.9rem 1rem;
border: 1px solid #cadacb;
border-radius: 0.5rem;
background: #edf5ee;
color: #254e30;
overflow-wrap: anywhere;
}
.status[data-error="true"] {
border-color: #e8cfc8;
background: #fff1ec;
color: #7f3022;
}
.result, .preview {
margin-top: 1.25rem;
}
.result p:last-child {
margin-bottom: 0;
}
.search-label {
max-width: 26rem;
}
.empty-state {
padding: 3rem 1rem;
color: var(--muted);
text-align: center;
}
td small {
display: block;
margin-top: 0.3rem;
color: var(--muted);
}
.content-preview {
min-width: 10rem;
max-width: 18rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, monospace;
}
.row-actions {
min-width: 9rem;
}
.row-actions button {
margin: 0 0.4rem 0.4rem 0;
padding: 0.35rem 0.6rem;
}
[hidden] {
display: none !important;
}
@media (max-width: 760px) {
.editor-layout, .form-grid {
grid-template-columns: 1fr;
}
.workspace-heading {
flex-wrap: wrap;
}
.site-header {
flex-wrap: wrap;
}
.workspace-tabs {
gap: 1.1rem;
}
body {
margin-top: 1rem;
}
}
+353
View File
@@ -0,0 +1,353 @@
"use strict";
const $ = id => document.getElementById(id);
let authorization = null;
let notes = [];
let editing = null;
function notify(message, error = false) {
$("status").textContent = message;
$("status").dataset.error = String(error);
$("status").hidden = false;
}
async function api(path, { method = "GET", body, accept = "text/plain", includeHeaders = false } = {}, auth = authorization) {
const headers = { Accept: accept };
if (auth) headers.Authorization = auth;
const response = await fetch(path, { method, body, headers, credentials: "omit", cache: "no-store" });
if (!response.ok) {
const messages = {
400: "Check the URL and settings. Expiry must be nonnegative and the view limit must be positive.",
401: "Sign in with the correct username and password.",
403: "Only the creator can change or delete this share.",
404: "This share does not exist, has expired, or has reached its view limit.",
413: "This share is too large for the server.",
422: "Check the form values and use whole numbers for expiry and views.",
};
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;
}
async function busy(button, action) {
button.disabled = true;
try {
await action();
} catch (error) {
notify(error.message, true);
} finally {
button.disabled = false;
}
}
function showPanel(id) {
if (!["compose", "collection", "open"].includes(id)) id = "compose";
for (const panel of document.querySelectorAll(".panel")) panel.hidden = panel.id !== id;
for (const link of document.querySelectorAll("[data-panel]")) {
if (link.dataset.panel === id) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
history.replaceState(null, "", `#${id}`);
}
function isUrl(note) {
return note.paste_type === "UrlV1";
}
function sharePath(id, url) {
return `${url ? "/to/" : "/"}${encodeURIComponent(id)}`;
}
function date(value) {
if (value === null) return "—";
const parsed = new Date(value * 1000);
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
function cell(row, text) {
const td = document.createElement("td");
td.textContent = text;
row.append(td);
return td;
}
function action(label, callback, className = "secondary") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = className;
button.addEventListener("click", () => busy(button, callback));
return button;
}
function renderNotes() {
$("note-count").textContent = notes.length;
$("note-rows").replaceChildren();
$("refresh").disabled = !authorization;
const query = $("search").value.toLowerCase();
const filtered = notes.filter(note => [note.id, note.content, note.language || ""]
.some(value => value.toLowerCase().includes(query)));
$("collection-table").hidden = filtered.length === 0;
$("collection-empty").hidden = filtered.length !== 0;
$("collection-empty").textContent = !authorization ? "Sign in to see and manage your shares."
: notes.length === 0 ? "Nothing here yet. Create a share while signed in to start your collection."
: "No shares match your search.";
for (const note of filtered) {
const row = document.createElement("tr");
const name = cell(row, "");
name.append(action(note.id, () => openEntry(note), "link-button"));
const type = document.createElement("small");
type.textContent = isUrl(note) ? "Short link" : note.language || "Plain text";
name.append(type);
const content = cell(row, note.content.slice(0, 140));
content.className = "content-preview";
cell(row, `${note.views} / ${note.max_views ?? "∞"}`);
cell(row, date(note.created_at));
cell(row, date(note.updated_at));
cell(row, note.expiration === null ? "Never" : date(note.expiration));
const controls = cell(row, "");
controls.className = "row-actions";
controls.append(
action("Edit", () => editNote(note)),
action("Delete", () => deleteNote(note), "danger"),
);
$("note-rows").append(row);
}
}
async function loadNotes() {
const auth = authorization;
if (!auth) return;
const result = await api("/list", { accept: "application/json" }, auth);
if (authorization !== auth) return;
notes = result;
renderNotes();
}
function updateEditor() {
const url = $("kind").value === "url";
$("kind").disabled = editing !== null;
$("custom-id").disabled = editing !== null;
$("new-note").hidden = editing === null;
$("once-field").hidden = url || editing !== null;
$("once").disabled = url || editing !== null;
if (url || editing) $("once").checked = false;
$("max-views").disabled = $("once").checked;
if ($("once").checked) $("max-views").value = "1";
$("language-field").hidden = url;
$("language").options[0].textContent = editing ? "Keep current language" : "Auto detect";
$("content-label").textContent = url ? "Destination URL" : "Your text or code";
$("content").rows = url ? 3 : 15;
$("content").placeholder = url ? "https://example.com" : "Paste something worth sharing…";
$("editor-title").textContent = editing ? `Edit ${url ? "link" : "paste"} · ${editing.id}` : "Create a share";
$("save").textContent = editing ? "Save changes" : url ? "Create short link"
: $("once").checked ? "Create one-time secret" : "Create share";
$("ownership-hint").textContent = authorization ? "Saved to your collection."
: "Sign in to edit or delete this share later.";
$("expires").placeholder = editing ? "Keep current expiry" : "Never";
$("edit-hint").hidden = editing === null;
if (editing) $("edit-hint").textContent = `Current expiry: ${editing.expiration === null ? "never" : date(editing.expiration)}. Leave expiry blank to keep it. Views stay at ${editing.views}.`;
}
function resetEditor() {
editing = null;
$("language").querySelector("[data-current]")?.remove();
$("editor-form").reset();
$("share-result").hidden = true;
updateEditor();
}
function editNote(note) {
resetEditor();
editing = note;
$("kind").value = isUrl(note) ? "url" : "note";
$("custom-id").value = note.id;
$("content").value = note.content;
const language = note.language || "";
if (language && !Array.from($("language").options).some(option => option.value === language)) {
const current = new Option(`${language} (current)`, language);
current.dataset.current = "";
$("language").add(current);
}
$("language").value = language;
$("max-views").value = note.max_views ?? "";
updateEditor();
showPanel("compose");
$("content").focus();
}
async function deleteNote(note) {
if (!window.confirm(`Permanently delete “${note.id}”?`)) return;
await api(sharePath(note.id, false), { method: "DELETE" });
if (editing?.id === note.id) resetEditor();
await loadNotes();
notify(`Deleted ${note.id}.`);
}
function reference(value, url) {
let id = value.trim();
if (/^(https?:\/\/|\/)/i.test(id)) {
const parsed = new URL(id, location.origin);
if (parsed.origin !== location.origin) throw new Error("Use an ID or a link from this Pastable server.");
url = parsed.pathname.startsWith("/to/");
id = decodeURIComponent(parsed.pathname.slice(url ? 4 : 1));
}
if (!id || id.includes("/") || ["gui", "list", "once", "to", "static"].includes(id)) {
throw new Error("Enter a valid share ID or a full Pastable share 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 () => {
const username = $("username").value;
const credentials = new TextEncoder().encode(`${username}:${$("password").value}`);
const auth = `Basic ${btoa(Array.from(credentials, byte => String.fromCharCode(byte)).join(""))}`;
const result = await api("/list", { accept: "application/json" }, auth);
if (authorization && authorization !== auth) resetEditor();
authorization = auth;
notes = result;
$("password").value = "";
$("session-label").textContent = username;
$("logout").hidden = false;
$("account").open = false;
renderNotes();
updateEditor();
notify(`Signed in as ${username}. New shares will belong to your account.`);
});
});
$("logout").addEventListener("click", () => {
authorization = null;
notes = [];
$("login-form").reset();
$("logout").hidden = true;
$("session-label").textContent = "Anonymous";
$("preview").replaceChildren();
$("preview").hidden = true;
resetEditor();
renderNotes();
notify("Signed out.");
});
$("editor-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const url = $("kind").value === "url";
const body = new URLSearchParams();
body.set(url ? "href" : "content", $("content").value);
if (!url && $("language").value) body.set("language", $("language").value);
if ($("expires").value !== "") body.set("expires", $("expires").value);
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);
else path = requested ? `${path}/${encodeURIComponent(requested)}` : path || "/";
const result = await api(path, { method: editing ? "PUT" : "POST", body });
const id = editing ? editing.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;
await loadNotes();
if (editing) {
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." : ""}`);
});
});
$("copy-url").addEventListener("click", event => busy(event.currentTarget, async () => {
if (!navigator.clipboard) {
$("share-url").focus();
$("share-url").select();
notify("The link is selected. Copy it with your keyboard or context menu.");
return;
}
await navigator.clipboard.writeText($("share-url").value);
notify("Share link copied.");
}));
$("open-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
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);
}
});
});
for (const link of document.querySelectorAll("[data-panel]")) {
link.addEventListener("click", event => {
event.preventDefault();
showPanel(link.dataset.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)));
showPanel(location.hash.slice(1));
renderNotes();
updateEditor();
+13 -2
View File
@@ -6,10 +6,21 @@
<meta name="description" content="Pastable"> <meta name="description" content="Pastable">
<title>{% block title %}Pastable{% endblock %}</title> <title>{% block title %}Pastable{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<header> <header class="site-header">
<a href="/">Pastable</a> <a class="brand" href="/" aria-label="Pastable home">
<svg width="30" height="34" viewBox="0 0 30 34" fill="none" aria-hidden="true">
<rect x="2" y="2" width="26" height="30" rx="6" fill="currentColor"/>
<path d="M9 11h12M9 17h12M9 23h7" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Pastable</span>
</a>
<nav aria-label="Main navigation">
<a href="/" {% block home_nav %}{% endblock %}>Documentation</a>
<a href="/gui" {% block gui_nav %}{% endblock %}>Browser app <span aria-hidden="true">↗</span></a>
</nav>
</header> </header>
<main> <main>
{% block content %} {% block content %}
+134
View File
@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Browser app · Pastable{% endblock %}
{% block gui_nav %}aria-current="page"{% endblock %}
{% block content %}
<div class="workspace-heading">
<div>
<p class="eyebrow">YOUR SHARING WORKSPACE</p>
<h1>Pastes, links &amp; secrets.</h1>
<p class="muted">A little space for things you want to pass along.</p>
</div>
<span id="session-label" class="badge">Anonymous</span>
</div>
<details id="account" class="card account">
<summary>Account <span class="muted">· optional</span></summary>
<form id="login-form">
<p class="muted">Sign in to manage your shares. A new username creates an account.</p>
<div class="form-grid">
<label>Username<input id="username" autocomplete="username" required pattern="[^:]+" maxlength="128"></label>
<label>Password<input id="password" type="password" autocomplete="current-password" required></label>
</div>
<div class="actions">
<button type="submit">Sign in / create account</button>
<button id="logout" type="button" class="secondary" hidden>Sign out</button>
</div>
<p class="hint">Your credentials stay in memory for this page only.</p>
</form>
</details>
<p id="status" class="status" role="status" aria-live="polite" tabindex="-1" hidden></p>
<nav class="workspace-tabs" aria-label="Workspace">
<a href="#compose" data-panel="compose" aria-current="page">Create a share</a>
<a href="#collection" data-panel="collection">My collection <span id="note-count" class="count">0</span></a>
<a href="#open" data-panel="open">Open a share</a>
</nav>
<section id="compose" class="panel" aria-labelledby="editor-title">
<div class="editor-layout">
<div class="card">
<div class="section-heading">
<h2 id="editor-title">Create a share</h2>
<button id="new-note" type="button" class="secondary" hidden>Cancel editing</button>
</div>
<form id="editor-form">
<div class="form-grid">
<label>What are you sharing?
<select id="kind"><option value="note">Text or code</option><option value="url">Short link</option></select>
</label>
<label>Custom ID <span class="muted">· optional</span>
<input id="custom-id" placeholder="Generate one for me" pattern="[^/]+" autocomplete="off">
</label>
</div>
<label><span id="content-label">Your text or code</span>
<textarea id="content" rows="15" spellcheck="false" placeholder="Paste something worth sharing…" required></textarea>
</label>
<div id="language-field">
<label>Language <span class="muted">· optional</span>
<select id="language">
<option value="">Auto detect</option>
<option value="text">Plain text</option>
{% for language in languages %}
<option value="{{ language }}">{{ language }}</option>
{% endfor %}
</select>
</label>
</div>
<p id="edit-hint" class="hint" hidden></p>
<div class="actions editor-actions">
<button id="save" type="submit">Create share</button>
<span id="ownership-hint" class="hint">Shared anonymously.</span>
</div>
</form>
</div>
<aside class="card options" aria-labelledby="options-title">
<p class="eyebrow">MAKE IT YOURS</p>
<h2 id="options-title">Share settings</h2>
<label>Expires after <span class="muted">· seconds</span>
<input id="expires" form="editor-form" type="number" min="0" step="1" placeholder="Never">
</label>
<label>Maximum views
<input id="max-views" form="editor-form" type="number" min="1" step="1" placeholder="Unlimited">
</label>
<label id="once-field" class="check-label">
<input id="once" form="editor-form" type="checkbox"> One-time secret
</label>
<p class="hint">One-time shares disappear after their first read. A link's views count its redirects.</p>
<hr>
<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>
<div class="section-heading">
<div><h2 id="collection-title">My collection</h2><p class="hint">Editing here does not count as a view.</p></div>
<button id="refresh" type="button" class="secondary">Refresh</button>
</div>
<label class="search-label">Filter shares<input id="search" type="search" placeholder="Find by ID, content or language…"></label>
<div id="collection-empty" class="empty-state">Sign in to see and manage your shares.</div>
<div id="collection-table" class="table-scroll" hidden>
<table>
<thead><tr><th scope="col">Share</th><th scope="col">Content</th><th scope="col">Views</th><th scope="col">Created</th><th scope="col">Updated</th><th scope="col">Expires</th><th scope="col">Actions</th></tr></thead>
<tbody id="note-rows"></tbody>
</table>
</div>
</section>
<section id="open" class="panel card" aria-labelledby="open-title" hidden>
<h2 id="open-title">Open a share</h2>
<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>
</form>
<div id="preview" class="preview" aria-live="polite" hidden></div>
</section>
<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 %}
+4 -1
View File
@@ -1,4 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block home_nav %}aria-current="page"{% endblock %}
{% block content %} {% block content %}
<h1>Pastable</h1> <article class="card readme">
{{ readme|safe }}
</article>
{% endblock %} {% endblock %}
+7
View File
@@ -1,4 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ paste.id }} · Pastable{% endblock %}
{% block content %} {% block content %}
<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>
{{ code|safe }} {{ code|safe }}
</article>
{% endblock %} {% endblock %}