This commit is contained in:
maurice committed 2026-11-14 17:54:29 +01:00
1 parent 139404b70f
commit 35e7259775
21 files changed
+590 -70

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ pub struct User {
}
/**
* Check Bearer auth.
* Check Basic auth.
* If present, check password.
* If user doesn't exist, create it.
*/
+1 -1
View File
@@ -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));
+28 -6
View File
@@ -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<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()));
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(),
+32
View File
@@ -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<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)
}
}
+30
View File
@@ -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"))
}
+41
View File
@@ -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;
}
}
+25
View File
@@ -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
View File
@@ -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<String>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
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()
}
+8 -10
View File
@@ -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<SqlitePool>,
Path(id): Path<String>,
) -> Result<Redirect, StatusCode> {
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))
}
+80
View File
@@ -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)
}
}
+51
View File
@@ -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())
}
}
+29
View File
@@ -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)
}
+19
View File
@@ -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
}
+20
View File
@@ -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
}
+20
View File
@@ -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
}
+19
View File
@@ -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
}
+20
View File
@@ -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
}
+37
View File
@@ -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)
}