Func
This commit is contained in:
21 files changed
+590
-70
No files matched your search
@@ -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. |
|
| `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 | Not set | Language for syntax highlighting. |
|
||||||
| `expires` | Integer | No | No expiration | Expiration time in seconds |
|
| `expires` | Integer | No | No expiration | Lifetime in seconds from the request; `0` expires immediately. Must be nonnegative. |
|
||||||
| `max_views` | Integer | No | Unlimited views | 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`. |
|
||||||
|
|
||||||
**Authentication:** optional HTTP Basic authentication. If supplied, the paste is associated with that user. An account is created if the user does not exist.
|
**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.
|
**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:
|
Create a paste from raw text:
|
||||||
|
|
||||||
```sh
|
```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. |
|
| `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. |
|
| `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
|
```sh
|
||||||
curl 'http://localhost:3000/my-paste' \
|
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` | Create a short link with a generated ID. |
|
||||||
| `POST` | `/to/{id}` | Use the requested ID if available; otherwise generate a random 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 |
|
| 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.
|
**Returns:** the assigned ID as plain text. Use it in `/to/{id}` to follow the link.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl 'http://localhost:3000/to' \
|
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
|
### Follow a short link
|
||||||
|
|
||||||
`GET /to/{id}` looks up a short link by its ID.
|
`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
|
### Update a paste
|
||||||
|
|
||||||
@@ -128,21 +136,44 @@ curl 'http://localhost:3000/to' \
|
|||||||
|
|
||||||
**Authentication:** HTTP Basic authentication of the creator is required.
|
**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.
|
**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
|
### 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.
|
**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
|
```sh
|
||||||
curl --user 'alice:example-password' 'http://localhost:3000/list'
|
curl --user 'alice:example-password' 'http://localhost:3000/list'
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check Bearer auth.
|
* Check Basic auth.
|
||||||
* If present, check password.
|
* If present, check password.
|
||||||
* If user doesn't exist, create it.
|
* If user doesn't exist, create it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use sqlx::{
|
|||||||
SqlitePool,
|
SqlitePool,
|
||||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||||
};
|
};
|
||||||
use tokio::time::interval;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::time::interval;
|
||||||
|
|
||||||
pub async fn cleanup_notes(pool: SqlitePool) {
|
pub async fn cleanup_notes(pool: SqlitePool) {
|
||||||
let mut interval = interval(Duration::from_secs(60));
|
let mut interval = interval(Duration::from_secs(60));
|
||||||
|
|||||||
+28
-6
@@ -1,4 +1,8 @@
|
|||||||
use axum::{Router, middleware, routing::get, serve};
|
use axum::{
|
||||||
|
Router, middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
serve,
|
||||||
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
@@ -6,17 +10,35 @@ mod auth;
|
|||||||
mod db;
|
mod db;
|
||||||
mod paste;
|
mod paste;
|
||||||
mod routes;
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
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()));
|
tokio::spawn(db::cleanup_notes(pool.clone()));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home_page))
|
.route("/", get(home_page).post(post_note))
|
||||||
.route("/to/{id}", get(get_redirect))
|
.route("/once", post(post_once))
|
||||||
.route("/{id}", get(get_note))
|
.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"))
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub struct Paste {
|
pub struct Paste {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -19,3 +21,33 @@ pub enum PasteType {
|
|||||||
TextV1 = 0,
|
TextV1 = 0,
|
||||||
UrlV1 = 100,
|
UrlV1 = 100,
|
||||||
}
|
}
|
||||||
|
impl Paste {
|
||||||
|
pub async fn read(
|
||||||
|
db: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
paste_type: PasteType,
|
||||||
|
) -> Result<Option<Self>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,37 @@
|
|||||||
|
use axum::http::{HeaderMap, header::ACCEPT};
|
||||||
|
|
||||||
|
mod create;
|
||||||
|
mod delete_note;
|
||||||
mod get_note;
|
mod get_note;
|
||||||
mod get_redirect;
|
mod get_redirect;
|
||||||
mod home;
|
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_note::get_note;
|
||||||
pub use get_redirect::get_redirect;
|
pub use get_redirect::get_redirect;
|
||||||
pub use home::home_page;
|
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"))
|
||||||
|
}
|
||||||
@@ -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<Path<String>>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
input: PasteInput,
|
||||||
|
paste_type: PasteType,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
+7
-40
@@ -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, header::ACCEPT},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use irosashi::{CodeToHtmlOptions, Highlighter};
|
use irosashi::{CodeToHtmlOptions, Highlighter};
|
||||||
@@ -10,6 +10,8 @@ use sqlx::SqlitePool;
|
|||||||
|
|
||||||
use crate::paste::{Paste, PasteType};
|
use crate::paste::{Paste, PasteType};
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -21,39 +23,12 @@ pub async fn get_note(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let mut transaction = db
|
let paste = Paste::read(&db, &id, PasteType::TextV1)
|
||||||
.begin()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(db_error)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
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)?;
|
|
||||||
|
|
||||||
if paste.max_views.is_some_and(|max| paste.views >= max) {
|
if return_html(&headers) {
|
||||||
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) {
|
|
||||||
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(
|
||||||
@@ -69,11 +44,3 @@ pub async fn get_note(
|
|||||||
Ok(paste.content.into_response())
|
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()
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,18 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::paste::PasteType;
|
use crate::paste::{Paste, PasteType};
|
||||||
|
|
||||||
|
use super::db_error;
|
||||||
|
|
||||||
pub async fn get_redirect(
|
pub async fn get_redirect(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Redirect, StatusCode> {
|
) -> Result<Redirect, StatusCode> {
|
||||||
let url =
|
let paste = Paste::read(&db, &id, PasteType::UrlV1)
|
||||||
sqlx::query_scalar::<_, String>("SELECT content FROM notes WHERE type = ? AND id = ?")
|
.await
|
||||||
.bind(PasteType::UrlV1)
|
.map_err(db_error)?
|
||||||
.bind(id)
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
.fetch_optional(&db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
Ok(Redirect::temporary(&url))
|
Ok(Redirect::temporary(&paste.content))
|
||||||
}
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub expires: Option<i64>,
|
||||||
|
pub max_views: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<i64>, 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<S: Send + Sync> FromRequest<S> for PasteInput {
|
||||||
|
type Rejection = Response;
|
||||||
|
|
||||||
|
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
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::<Self>::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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Paste>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_notes(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Extension<User>>,
|
||||||
|
) -> Result<Transaction<'static, Sqlite>, 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)
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
id: Option<Path<String>>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
input: PasteInput,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
create(&db, id, user, input, PasteType::TextV1).await
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
id: Option<Path<String>>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
mut input: PasteInput,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
input.max_views = Some(1);
|
||||||
|
create(&db, id, user, input, PasteType::TextV1).await
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
id: Option<Path<String>>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
mut input: PasteInput,
|
||||||
|
) -> Result<String, StatusCode> {
|
||||||
|
input.validate_url()?;
|
||||||
|
create(&db, id, user, input, PasteType::UrlV1).await
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
input: PasteInput,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
update(&db, &id, user, input, PasteType::TextV1).await
|
||||||
|
}
|
||||||
@@ -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<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
user: Option<Extension<User>>,
|
||||||
|
mut input: PasteInput,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
input.validate_url()?;
|
||||||
|
update(&db, &id, user, input, PasteType::UrlV1).await
|
||||||
|
}
|
||||||
@@ -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<Extension<User>>,
|
||||||
|
input: PasteInput,
|
||||||
|
paste_type: PasteType,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -33,3 +33,30 @@ pre {
|
|||||||
footer {
|
footer {
|
||||||
color: var(--muted);
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Your pastes · Pastable{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Your pastes and short links</h1>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Content</th>
|
||||||
|
<th scope="col">Language</th>
|
||||||
|
<th scope="col">Views / limit</th>
|
||||||
|
<th scope="col">Created at</th>
|
||||||
|
<th scope="col">Updated at</th>
|
||||||
|
<th scope="col">Expires at</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for note in notes %}
|
||||||
|
<tr>
|
||||||
|
{% match note.paste_type %}
|
||||||
|
{% when PasteType::TextV1 %}
|
||||||
|
<td><a href="/{{ note.id|urlencode_strict }}">{{ note.id }}</a></td>
|
||||||
|
<td>Paste</td>
|
||||||
|
{% when PasteType::UrlV1 %}
|
||||||
|
<td><a href="/to/{{ note.id|urlencode_strict }}">{{ note.id }}</a></td>
|
||||||
|
<td>URL</td>
|
||||||
|
{% endmatch %}
|
||||||
|
<td><pre><code>{{ note.content }}</code></pre></td>
|
||||||
|
<td>{% match note.language %}{% when Some with (language) %}{{ language }}{% when None %}—{% endmatch %}</td>
|
||||||
|
<td>{{ note.views }} / {% match note.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}</td>
|
||||||
|
<td><time data-unix="{{ note.created_at }}">{{ note.created_at }}</time></td>
|
||||||
|
<td>{% match note.updated_at %}{% when Some with (updated) %}<time data-unix="{{ updated }}">{{ updated }}</time>{% when None %}—{% endmatch %}</td>
|
||||||
|
<td>{% match note.expiration %}{% when Some with (expiration) %}<time data-unix="{{ expiration }}">{{ expiration }}</time>{% when None %}Never{% endmatch %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8">No pastes or short links yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('time[data-unix]').forEach(time => {
|
||||||
|
const date = new Date(Number(time.dataset.unix) * 1000);
|
||||||
|
if (!Number.isNaN(date.getTime())) {
|
||||||
|
time.dateTime = date.toISOString();
|
||||||
|
time.textContent = date.toLocaleString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user