From 35e7259775d5e34ef0b76b4f8e202f4085db7c8e Mon Sep 17 00:00:00 2001 From: maurice Date: Sat, 14 Nov 2026 17:54:29 +0100 Subject: [PATCH] Func --- README.md | 55 +++++++++++++++++++------ src/auth.rs | 2 +- src/db.rs | 2 +- src/main.rs | 34 +++++++++++++--- src/paste.rs | 32 +++++++++++++++ src/routes.rs | 30 ++++++++++++++ src/routes/create.rs | 41 +++++++++++++++++++ src/routes/delete_note.rs | 25 ++++++++++++ src/routes/get_note.rs | 47 ++++------------------ src/routes/get_redirect.rs | 18 ++++----- src/routes/input.rs | 80 +++++++++++++++++++++++++++++++++++++ src/routes/list_notes.rs | 51 +++++++++++++++++++++++ src/routes/owned_note.rs | 29 ++++++++++++++ src/routes/post_note.rs | 19 +++++++++ src/routes/post_once.rs | 20 ++++++++++ src/routes/post_redirect.rs | 20 ++++++++++ src/routes/put_note.rs | 19 +++++++++ src/routes/put_redirect.rs | 20 ++++++++++ src/routes/update.rs | 37 +++++++++++++++++ static/app.css | 27 +++++++++++++ templates/list.html | 52 ++++++++++++++++++++++++ 21 files changed, 590 insertions(+), 70 deletions(-) create mode 100644 src/routes/create.rs create mode 100644 src/routes/delete_note.rs create mode 100644 src/routes/input.rs create mode 100644 src/routes/list_notes.rs create mode 100644 src/routes/owned_note.rs create mode 100644 src/routes/post_note.rs create mode 100644 src/routes/post_once.rs create mode 100644 src/routes/post_redirect.rs create mode 100644 src/routes/put_note.rs create mode 100644 src/routes/put_redirect.rs create mode 100644 src/routes/update.rs create mode 100644 templates/list.html diff --git a/README.md b/README.md index 8d86269..43030f5 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,15 @@ All paths are relative to the server URL. `{id}` is a path parameter containing | --- | --- | --- | --- | --- | | `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. | -| `expires` | Integer | No | No expiration | Expiration time in seconds | -| `max_views` | Integer | No | Unlimited views | Maximum number of views before self-destruction. The `/once` routes force this to `1`. | +| `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`. | **Authentication:** optional HTTP Basic authentication. If supplied, the paste is associated with that user. An account is created if the user does not exist. **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. + Create a paste from raw text: ```sh @@ -89,7 +91,9 @@ curl --request POST 'http://localhost:3000/once/my-secret' \ | `id` | Path | Yes | The paste ID returned when it was created. | | `Accept` | Header | No | Use `text/html` for syntax-highlighted HTML or `text/plain` for plain text. | -**Returns:** the paste as HTML or plain text, based on the `Accept` header. The default representation is not defined yet. +**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. + +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 curl 'http://localhost:3000/my-paste' \ @@ -103,24 +107,28 @@ curl 'http://localhost:3000/my-paste' \ | `POST` | `/to` | Create a short link with a generated ID. | | `POST` | `/to/{id}` | Use the requested ID if available; otherwise generate a random ID. | -**Request body:** the destination URL as raw text, or an `application/x-www-form-urlencoded` form with the following parameter: +**Request body:** the destination URL as raw text, or an `application/x-www-form-urlencoded` form with the following parameters: | Form parameter | Type | Required | Description | | --- | --- | --- | --- | -| `href` | String | Yes | Destination URL. For raw text requests, the entire body supplies this value. | +| `href` | String | Yes | Absolute HTTP or HTTPS destination URL. For raw text requests, the entire body supplies this value. | +| `expires` | Integer | No | Lifetime in seconds from the request; `0` expires immediately. Must be nonnegative. Defaults to no expiration. | +| `max_views` | Integer | No | Positive maximum number of redirects before self-destruction. Defaults to unlimited redirects. | **Returns:** the assigned ID as plain text. Use it in `/to/{id}` to follow the link. ```sh curl 'http://localhost:3000/to' \ - --data-urlencode 'href=https://example.com' + --data-urlencode 'href=https://example.com' \ + --data-urlencode 'expires=3600' \ + --data-urlencode 'max_views=5' ``` ### Follow a short link `GET /to/{id}` looks up a short link by its ID. -**Returns:** an HTTP redirect to the destination URL when found. The redirect status code and behavior for missing links are not defined yet. +**Returns:** a `307 Temporary Redirect` to the destination URL, or `404` if the link is missing, expired, or exhausted. Each successful redirect increments `views`. The final permitted redirect deletes the short link immediately. Expiration and view limits use the same logic as text pastes. ### Update a paste @@ -128,21 +136,44 @@ curl 'http://localhost:3000/to' \ **Authentication:** HTTP Basic authentication of the creator is required. -The same request body as [the POST request](#create-a-paste) +The same request body as [the POST request](#create-a-paste). `content` is required; omitted `language`, `expires`, and `max_views` retain their current values. A supplied `expires` starts a new lifetime from the update request. The view counter is preserved, and `updated_at` is set to the current time. -### Delete a paste +**Returns:** `204 No Content` on success, `401` without authentication, `403` for another user's paste or an anonymous paste, and `404` for a missing, expired, or exhausted paste. -`DELETE` delete a paste by its ID. +### Update a short link + +`PUT /to/{id}` updates a short link by its ID. **Authentication:** HTTP Basic authentication of the creator is required. +Use the same request body as [creating a short link](#create-a-short-link): the new destination as raw text, or a form with `href` and optional `expires` and `max_views`. Omitted options retain their current values. The view counter is preserved, and `updated_at` is set to the current time. + +**Returns:** `204 No Content` on success, with the same authentication and missing-record errors as the paste update endpoint. Invalid destination URLs return `400`. + +```sh +curl --request PUT --user 'alice:example-password' 'http://localhost:3000/to/my-link' \ + --data-urlencode 'href=https://example.org' \ + --data-urlencode 'expires=3600' \ + --data-urlencode 'max_views=10' +``` + +### Delete a paste + +`DELETE /{id}` deletes a paste or short link by its ID. + +**Authentication:** HTTP Basic authentication of the creator is required. + +**Returns:** `204 No Content` on success, with the same authentication and missing-paste errors as the update endpoint. + ### List pastes -`GET /list` returns a list of notes. +`GET /list` returns the authenticated user's pastes and short links. **Authentication:** HTTP Basic authentication is required. -**Returns:** a plain-text list of the notes that are present and their content. +**Returns:** a plain-text list of that user's active records, ordered by creation time and ID. Each entry contains the ID on one line, followed by the content, with a blank line between entries. An empty list returns an empty body. Listing does not increment view counters. Missing authentication returns `401`. + +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. ```sh curl --user 'alice:example-password' 'http://localhost:3000/list' diff --git a/src/auth.rs b/src/auth.rs index 3ac21be..69a782f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,7 +17,7 @@ pub struct User { } /** - * Check Bearer auth. + * Check Basic auth. * If present, check password. * If user doesn't exist, create it. */ diff --git a/src/db.rs b/src/db.rs index e101043..f817d66 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,8 +2,8 @@ use sqlx::{ SqlitePool, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, }; -use tokio::time::interval; use std::time::Duration; +use tokio::time::interval; pub async fn cleanup_notes(pool: SqlitePool) { let mut interval = interval(Duration::from_secs(60)); diff --git a/src/main.rs b/src/main.rs index c8589d0..100bdc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,8 @@ -use axum::{Router, middleware, routing::get, serve}; +use axum::{ + Router, middleware, + routing::{get, post}, + serve, +}; use tokio::net::TcpListener; use tower_http::services::ServeDir; @@ -6,17 +10,35 @@ mod auth; mod db; mod paste; mod routes; -use crate::routes::{get_note, get_redirect, home_page}; +use crate::routes::{ + delete_note, get_note, get_redirect, home_page, list_notes, post_note, post_once, + post_redirect, put_note, put_redirect, +}; #[tokio::main] async fn main() -> Result<(), Box> { - let pool = db::connect_db("sqlite://pastable.db").await?; + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://pastable.db".to_owned()); + let pool = db::connect_db(&database_url).await?; tokio::spawn(db::cleanup_notes(pool.clone())); let app = Router::new() - .route("/", get(home_page)) - .route("/to/{id}", get(get_redirect)) - .route("/{id}", get(get_note)) + .route("/", get(home_page).post(post_note)) + .route("/once", post(post_once)) + .route("/once/{id}", post(post_once)) + .route("/to", post(post_redirect)) + .route( + "/to/{id}", + get(get_redirect).post(post_redirect).put(put_redirect), + ) + .route("/list", get(list_notes)) + .route( + "/{id}", + get(get_note) + .post(post_note) + .put(put_note) + .delete(delete_note), + ) .nest_service("/static", ServeDir::new("static")) .layer(middleware::from_fn_with_state( pool.clone(), diff --git a/src/paste.rs b/src/paste.rs index eaffc82..8e3a2fd 100644 --- a/src/paste.rs +++ b/src/paste.rs @@ -1,3 +1,5 @@ +use sqlx::SqlitePool; + #[derive(sqlx::FromRow)] pub struct Paste { pub id: String, @@ -19,3 +21,33 @@ pub enum PasteType { TextV1 = 0, UrlV1 = 100, } +impl Paste { + pub async fn read( + db: &SqlitePool, + id: &str, + paste_type: PasteType, + ) -> Result, sqlx::Error> { + let mut transaction = db.begin().await?; + let paste = sqlx::query_as::<_, Self>( + "UPDATE notes SET views = views + 1 + WHERE type = ? AND id = ? + AND (expiration IS NULL OR expiration > unixepoch()) + AND (max_views IS NULL OR views < max_views) + RETURNING *", + ) + .bind(paste_type) + .bind(id) + .fetch_optional(&mut *transaction) + .await?; + if let Some(paste) = &paste + && paste.max_views.is_some_and(|max| paste.views >= max) + { + sqlx::query("DELETE FROM notes WHERE id = ?") + .bind(&paste.id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(paste) + } +} diff --git a/src/routes.rs b/src/routes.rs index 5e99eee..e7e544f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,7 +1,37 @@ +use axum::http::{HeaderMap, header::ACCEPT}; + +mod create; +mod delete_note; mod get_note; mod get_redirect; mod home; +mod input; +mod list_notes; +mod owned_note; +mod post_note; +mod post_once; +mod post_redirect; +mod put_note; +mod put_redirect; +mod update; +pub use delete_note::delete_note; pub use get_note::get_note; pub use get_redirect::get_redirect; pub use home::home_page; +pub use list_notes::list_notes; +pub use post_note::post_note; +pub use post_once::post_once; +pub use post_redirect::post_redirect; +pub use put_note::put_note; +pub use put_redirect::put_redirect; + +fn db_error(_: sqlx::Error) -> axum::http::StatusCode { + axum::http::StatusCode::INTERNAL_SERVER_ERROR +} +fn return_html(headers: &HeaderMap) -> bool { + headers + .get(ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains("text/html")) +} diff --git a/src/routes/create.rs b/src/routes/create.rs new file mode 100644 index 0000000..440677d --- /dev/null +++ b/src/routes/create.rs @@ -0,0 +1,41 @@ +use axum::{Extension, extract::Path, http::StatusCode}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{db_error, input::PasteInput}; + +pub async fn create( + db: &SqlitePool, + id: Option>, + user: Option>, + input: PasteInput, + paste_type: PasteType, +) -> Result { + let mut id = id.map(|Path(id)| id).filter(|id| { + !matches!(id.as_str(), "list" | "once" | "to" | "static") && !id.contains('/') + }); + let owner = user.map(|Extension(user)| user.username); + let expiration = input.expiration()?; + loop { + let created = sqlx::query_scalar( + "INSERT INTO notes (id, type, content, language, expiration, max_views, created_at, owner) + VALUES (COALESCE(?, lower(hex(randomblob(12)))), ?, ?, ?, ?, ?, unixepoch(), ?) + ON CONFLICT(id) DO NOTHING RETURNING id", + ) + .bind(id.as_deref()) + .bind(&paste_type) + .bind(&input.content) + .bind(&input.language) + .bind(expiration) + .bind(input.max_views) + .bind(owner.as_deref()) + .fetch_optional(db) + .await + .map_err(db_error)?; + if let Some(id) = created { + return Ok(id); + } + id = None; + } +} diff --git a/src/routes/delete_note.rs b/src/routes/delete_note.rs new file mode 100644 index 0000000..2522171 --- /dev/null +++ b/src/routes/delete_note.rs @@ -0,0 +1,25 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::auth::User; + +use super::{db_error, owned_note::owned_note}; + +pub async fn delete_note( + State(db): State, + Path(id): Path, + user: Option>, +) -> Result { + let mut transaction = owned_note(&db, &id, user).await?; + sqlx::query("DELETE FROM notes WHERE id = ?") + .bind(id) + .execute(&mut *transaction) + .await + .map_err(db_error)?; + transaction.commit().await.map_err(db_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/routes/get_note.rs b/src/routes/get_note.rs index f2961b8..13608ca 100644 --- a/src/routes/get_note.rs +++ b/src/routes/get_note.rs @@ -2,7 +2,7 @@ use askama::Template; use askama_web::WebTemplate; use axum::{ extract::{Path, State}, - http::{HeaderMap, StatusCode, header::ACCEPT}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use irosashi::{CodeToHtmlOptions, Highlighter}; @@ -10,6 +10,8 @@ use sqlx::SqlitePool; use crate::paste::{Paste, PasteType}; +use super::{db_error, return_html}; + #[derive(Template, WebTemplate)] #[template(path = "note.html")] pub struct NotePage { @@ -21,39 +23,12 @@ pub async fn get_note( Path(id): Path, headers: HeaderMap, ) -> Result { - let mut transaction = db - .begin() + let paste = Paste::read(&db, &id, PasteType::TextV1) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let paste = sqlx::query_as::<_, Paste>( - "UPDATE notes SET views = views + 1 - WHERE type = ? AND id = ? - AND (expiration IS NULL OR expiration > unixepoch()) - AND (max_views IS NULL OR views < max_views) - RETURNING *", - ) - .bind(PasteType::TextV1) - .bind(id) - .fetch_optional(&mut *transaction) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + .map_err(db_error)? + .ok_or(StatusCode::NOT_FOUND)?; - if paste.max_views.is_some_and(|max| paste.views >= max) { - sqlx::query("DELETE FROM notes WHERE id = ?") - .bind(&paste.id) - .execute(&mut *transaction) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - transaction - .commit() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if return_html(headers) { + if return_html(&headers) { let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let code = highlighter .code_to_html( @@ -69,11 +44,3 @@ pub async fn get_note( Ok(paste.content.into_response()) } } - -fn return_html(headers: HeaderMap) -> bool { - headers - .get(ACCEPT) - .and_then(|v| v.to_str().ok()) - .map(|h| h.contains("text/html")) - .unwrap_or_default() -} diff --git a/src/routes/get_redirect.rs b/src/routes/get_redirect.rs index 69a3d44..a08ac36 100644 --- a/src/routes/get_redirect.rs +++ b/src/routes/get_redirect.rs @@ -5,20 +5,18 @@ use axum::{ }; use sqlx::SqlitePool; -use crate::paste::PasteType; +use crate::paste::{Paste, PasteType}; + +use super::db_error; pub async fn get_redirect( State(db): State, Path(id): Path, ) -> Result { - let url = - sqlx::query_scalar::<_, String>("SELECT content FROM notes WHERE type = ? AND id = ?") - .bind(PasteType::UrlV1) - .bind(id) - .fetch_optional(&db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + let paste = Paste::read(&db, &id, PasteType::UrlV1) + .await + .map_err(db_error)? + .ok_or(StatusCode::NOT_FOUND)?; - Ok(Redirect::temporary(&url)) + Ok(Redirect::temporary(&paste.content)) } diff --git a/src/routes/input.rs b/src/routes/input.rs new file mode 100644 index 0000000..190cf70 --- /dev/null +++ b/src/routes/input.rs @@ -0,0 +1,80 @@ +use std::time::UNIX_EPOCH; + +use axum::{ + Form, + extract::{FromRequest, Request}, + http::{HeaderValue, StatusCode, Uri, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +#[derive(Default, Deserialize)] +pub struct PasteInput { + #[serde(alias = "href")] + pub content: String, + pub language: Option, + pub expires: Option, + pub max_views: Option, +} + +impl PasteInput { + pub fn validate_url(&mut self) -> Result<(), StatusCode> { + self.content = self.content.trim().to_owned(); + let uri: Uri = self.content.parse().map_err(|_| StatusCode::BAD_REQUEST)?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.host().is_none() + || HeaderValue::from_str(&self.content).is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + Ok(()) + } + + pub fn expiration(&self) -> Result, StatusCode> { + self.expires + .map(|expires| { + let now = UNIX_EPOCH + .elapsed() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .as_secs() as i64; + now.checked_add(expires).ok_or(StatusCode::BAD_REQUEST) + }) + .transpose() + } +} + +impl FromRequest for PasteInput { + type Rejection = Response; + + async fn from_request(request: Request, state: &S) -> Result { + let form = request + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_some_and(|value| { + value + .trim() + .eq_ignore_ascii_case("application/x-www-form-urlencoded") + }); + let input = if form { + Form::::from_request(request, state) + .await + .map_err(IntoResponse::into_response)? + .0 + } else { + Self { + content: String::from_request(request, state) + .await + .map_err(IntoResponse::into_response)?, + ..Self::default() + } + }; + if input.expires.is_some_and(|expires| expires < 0) + || input.max_views.is_some_and(|max| max < 1) + { + return Err(StatusCode::BAD_REQUEST.into_response()); + } + Ok(input) + } +} diff --git a/src/routes/list_notes.rs b/src/routes/list_notes.rs new file mode 100644 index 0000000..f4a7d28 --- /dev/null +++ b/src/routes/list_notes.rs @@ -0,0 +1,51 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::{ + Extension, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use sqlx::SqlitePool; + +use crate::{ + auth::User, + paste::{Paste, PasteType}, +}; + +use super::{db_error, return_html}; + +#[derive(Template, WebTemplate)] +#[template(path = "list.html")] +pub struct ListPage { + notes: Vec, +} + +pub async fn list_notes( + State(db): State, + user: Option>, + headers: HeaderMap, +) -> Result { + let Extension(user) = user.ok_or(StatusCode::UNAUTHORIZED)?; + let notes = sqlx::query_as::<_, Paste>( + "SELECT * FROM notes WHERE owner = ? + AND (expiration IS NULL OR expiration > unixepoch()) + AND (max_views IS NULL OR views < max_views) + ORDER BY created_at, id", + ) + .bind(user.username) + .fetch_all(&db) + .await + .map_err(db_error)?; + + if return_html(&headers) { + Ok(ListPage { notes }.into_response()) + } else { + Ok(notes + .into_iter() + .map(|note| format!("{}\n{}\n", note.id, note.content)) + .collect::>() + .join("\n") + .into_response()) + } +} diff --git a/src/routes/owned_note.rs b/src/routes/owned_note.rs new file mode 100644 index 0000000..142503f --- /dev/null +++ b/src/routes/owned_note.rs @@ -0,0 +1,29 @@ +use axum::{Extension, http::StatusCode}; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +use crate::{auth::User, paste::Paste}; + +use super::db_error; + +pub async fn owned_note( + db: &SqlitePool, + id: &str, + user: Option>, +) -> Result, StatusCode> { + let Extension(user) = user.ok_or(StatusCode::UNAUTHORIZED)?; + let mut transaction = db.begin_with("BEGIN IMMEDIATE").await.map_err(db_error)?; + let paste = sqlx::query_as::<_, Paste>( + "SELECT * FROM notes WHERE id = ? + AND (expiration IS NULL OR expiration > unixepoch()) + AND (max_views IS NULL OR views < max_views)", + ) + .bind(id) + .fetch_optional(&mut *transaction) + .await + .map_err(db_error)? + .ok_or(StatusCode::NOT_FOUND)?; + if paste.owner.as_deref() != Some(&user.username) { + return Err(StatusCode::FORBIDDEN); + } + Ok(transaction) +} diff --git a/src/routes/post_note.rs b/src/routes/post_note.rs new file mode 100644 index 0000000..2193bd2 --- /dev/null +++ b/src/routes/post_note.rs @@ -0,0 +1,19 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{create::create, input::PasteInput}; + +pub async fn post_note( + State(db): State, + id: Option>, + user: Option>, + input: PasteInput, +) -> Result { + create(&db, id, user, input, PasteType::TextV1).await +} diff --git a/src/routes/post_once.rs b/src/routes/post_once.rs new file mode 100644 index 0000000..f64d826 --- /dev/null +++ b/src/routes/post_once.rs @@ -0,0 +1,20 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{create::create, input::PasteInput}; + +pub async fn post_once( + State(db): State, + id: Option>, + user: Option>, + mut input: PasteInput, +) -> Result { + input.max_views = Some(1); + create(&db, id, user, input, PasteType::TextV1).await +} diff --git a/src/routes/post_redirect.rs b/src/routes/post_redirect.rs new file mode 100644 index 0000000..08e4da4 --- /dev/null +++ b/src/routes/post_redirect.rs @@ -0,0 +1,20 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{create::create, input::PasteInput}; + +pub async fn post_redirect( + State(db): State, + id: Option>, + user: Option>, + mut input: PasteInput, +) -> Result { + input.validate_url()?; + create(&db, id, user, input, PasteType::UrlV1).await +} diff --git a/src/routes/put_note.rs b/src/routes/put_note.rs new file mode 100644 index 0000000..29810dc --- /dev/null +++ b/src/routes/put_note.rs @@ -0,0 +1,19 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{input::PasteInput, update::update}; + +pub async fn put_note( + State(db): State, + Path(id): Path, + user: Option>, + input: PasteInput, +) -> Result { + update(&db, &id, user, input, PasteType::TextV1).await +} diff --git a/src/routes/put_redirect.rs b/src/routes/put_redirect.rs new file mode 100644 index 0000000..563e592 --- /dev/null +++ b/src/routes/put_redirect.rs @@ -0,0 +1,20 @@ +use axum::{ + Extension, + extract::{Path, State}, + http::StatusCode, +}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{input::PasteInput, update::update}; + +pub async fn put_redirect( + State(db): State, + Path(id): Path, + user: Option>, + mut input: PasteInput, +) -> Result { + input.validate_url()?; + update(&db, &id, user, input, PasteType::UrlV1).await +} diff --git a/src/routes/update.rs b/src/routes/update.rs new file mode 100644 index 0000000..c4a2157 --- /dev/null +++ b/src/routes/update.rs @@ -0,0 +1,37 @@ +use axum::{Extension, http::StatusCode}; +use sqlx::SqlitePool; + +use crate::{auth::User, paste::PasteType}; + +use super::{db_error, input::PasteInput, owned_note::owned_note}; + +pub async fn update( + db: &SqlitePool, + id: &str, + user: Option>, + input: PasteInput, + paste_type: PasteType, +) -> Result { + let expiration = input.expiration()?; + let mut transaction = owned_note(db, id, user).await?; + let result = sqlx::query( + "UPDATE notes SET content = ?, language = COALESCE(?, language), + expiration = COALESCE(?, expiration), max_views = COALESCE(?, max_views), + updated_at = unixepoch() + WHERE id = ? AND type = ?", + ) + .bind(input.content) + .bind(input.language) + .bind(expiration) + .bind(input.max_views) + .bind(id) + .bind(paste_type) + .execute(&mut *transaction) + .await + .map_err(db_error)?; + if result.rows_affected() == 0 { + return Err(StatusCode::NOT_FOUND); + } + transaction.commit().await.map_err(db_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/static/app.css b/static/app.css index 3c5d1ad..ff9f381 100644 --- a/static/app.css +++ b/static/app.css @@ -33,3 +33,30 @@ pre { footer { color: var(--muted); } + +.table-scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 0.5rem; + border-bottom: 1px solid var(--muted); + text-align: left; + vertical-align: top; +} + +td pre { + max-width: 24rem; + max-height: 10rem; + margin: 0; + padding: 0.5rem; +} + +time { + white-space: nowrap; +} diff --git a/templates/list.html b/templates/list.html new file mode 100644 index 0000000..d71855c --- /dev/null +++ b/templates/list.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Your pastes · Pastable{% endblock %} +{% block content %} +

Your pastes and short links

+
+ + + + + + + + + + + + + + + {% for note in notes %} + + {% match note.paste_type %} + {% when PasteType::TextV1 %} + + + {% when PasteType::UrlV1 %} + + + {% endmatch %} + + + + + + + + {% else %} + + {% endfor %} + +
IDTypeContentLanguageViews / limitCreated atUpdated atExpires at
{{ note.id }}Paste{{ note.id }}URL
{{ note.content }}
{% match note.language %}{% when Some with (language) %}{{ language }}{% when None %}—{% endmatch %}{{ note.views }} / {% match note.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}{% match note.updated_at %}{% when Some with (updated) %}{% when None %}—{% endmatch %}{% match note.expiration %}{% when Some with (expiration) %}{% when None %}Never{% endmatch %}
No pastes or short links yet.
+
+ +{% endblock %}