This commit is contained in:
maurice committed 2026-11-14 17:25:55 +01:00
1 parent 377bd1e198
commit 139404b70f
17 files changed
+672 -48

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
use axum::{
extract::{Request, State},
http::{StatusCode, header::AUTHORIZATION},
middleware::Next,
response::Response,
};
use base64::{Engine, engine::general_purpose::STANDARD};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct User {
pub username: String,
}
/**
* Check Bearer auth.
* If present, check password.
* If user doesn't exist, create it.
*/
pub async fn authorize(
State(db): State<SqlitePool>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let Some(header) = request.headers().get(AUTHORIZATION) else {
return Ok(next.run(request).await);
};
let (_, encoded) = header
.to_str()
.ok()
.and_then(|value| value.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Basic"))
.ok_or(StatusCode::UNAUTHORIZED)?;
let decoded = STANDARD
.decode(encoded)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let credentials = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
let (username, password) = credentials
.split_once(':')
.filter(|(username, _)| !username.is_empty())
.ok_or(StatusCode::UNAUTHORIZED)?;
let argon2 = Argon2::default();
let mut hash = get_password_hash(&db, username).await?;
if hash.is_none() {
let new_hash: PasswordHash = argon2
.hash_password(password.as_bytes())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
sqlx::query(
"INSERT INTO users (username, pass_hash) VALUES (?, ?) ON CONFLICT(username) DO NOTHING",
)
.bind(username)
.bind(new_hash.to_string())
.execute(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
hash = get_password_hash(&db, username).await?;
}
let hash = hash.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let hash = PasswordHash::new(&hash).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
argon2
.verify_password(password.as_bytes(), &hash)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
request.extensions_mut().insert(User {
username: username.to_owned(),
});
Ok(next.run(request).await)
}
async fn get_password_hash(db: &SqlitePool, username: &str) -> Result<Option<String>, StatusCode> {
sqlx::query_scalar("SELECT pass_hash FROM users WHERE username = ?")
.bind(username)
.fetch_optional(db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
+49 -4
View File
@@ -1,9 +1,54 @@
use sqlx::SqlitePool;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
};
use tokio::time::interval;
use std::time::Duration;
pub async fn cleanup_notes(pool: SqlitePool) {
let mut interval = interval(Duration::from_secs(60));
loop {
interval.tick().await;
if let Err(error) =
sqlx::query("DELETE FROM notes WHERE expiration <= unixepoch() OR views >= max_views")
.execute(&pool)
.await
{
eprintln!("Failed to clean up expired notes: {error}");
}
}
}
/**
* Connect to the database
*/
pub async fn connect_db(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options = database_url
.parse::<SqliteConnectOptions>()?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().connect_with(options).await?;
init_db(&pool).await?;
Ok(pool)
}
/**
* Initialize database
*/
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
pass_hash TEXT NOT NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS notes (
@@ -16,12 +61,12 @@ pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
views INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER,
password TEXT
owner TEXT REFERENCES users(username)
)
"#
"#,
)
.execute(pool)
.await?;
Ok(())
}
}
+22 -15
View File
@@ -1,24 +1,31 @@
use axum::{Router, extract::Path, http::{HeaderMap, header::ACCEPT}, response::{Html, IntoResponse}, routing::get, serve};
use axum::{Router, middleware, routing::get, serve};
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
mod auth;
mod db;
mod paste;
mod routes;
use crate::routes::{get_note, get_redirect, home_page};
#[tokio::main]
async fn main() {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = db::connect_db("sqlite://pastable.db").await?;
tokio::spawn(db::cleanup_notes(pool.clone()));
let app = Router::new()
.route("/", get(|| async {}))
.route("/{id}", get(get_note));
.route("/", get(home_page))
.route("/to/{id}", get(get_redirect))
.route("/{id}", get(get_note))
.nest_service("/static", ServeDir::new("static"))
.layer(middleware::from_fn_with_state(
pool.clone(),
auth::authorize,
))
.with_state(pool);
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
let listener = TcpListener::bind("0.0.0.0:3000").await?;
println!("Listening on http://localhost:3000");
serve(listener, app).await.unwrap();
serve(listener, app).await?;
Ok(())
}
async fn get_note(Path(id): Path<String>, headers: HeaderMap) -> impl IntoResponse {
let return_html = headers.get(ACCEPT)
.and_then(|v|v.to_str().ok())
.and_then(|h| Some(h.contains("text/html")))
.unwrap_or_default();
Html::from("test")
}
+19 -6
View File
@@ -1,8 +1,21 @@
/**
* Paste type
*/
#[repr(u8)]
#[derive(sqlx::FromRow)]
pub struct Paste {
pub id: String,
#[sqlx(rename = "type")]
pub paste_type: PasteType,
pub content: String,
pub language: Option<String>,
pub expiration: Option<i64>,
pub max_views: Option<i64>,
pub views: i64,
pub created_at: i64,
pub updated_at: Option<i64>,
pub owner: Option<String>,
}
#[derive(sqlx::Type)]
#[repr(i64)]
pub enum PasteType {
TextV1 = 0,
UrlV1 = 100
}
UrlV1 = 100,
}
+7
View File
@@ -0,0 +1,7 @@
mod get_note;
mod get_redirect;
mod home;
pub use get_note::get_note;
pub use get_redirect::get_redirect;
pub use home::home_page;
+79
View File
@@ -0,0 +1,79 @@
use askama::Template;
use askama_web::WebTemplate;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode, header::ACCEPT},
response::{IntoResponse, Response},
};
use irosashi::{CodeToHtmlOptions, Highlighter};
use sqlx::SqlitePool;
use crate::paste::{Paste, PasteType};
#[derive(Template, WebTemplate)]
#[template(path = "note.html")]
pub struct NotePage {
code: String,
}
pub async fn get_note(
State(db): State<SqlitePool>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
let mut transaction = db
.begin()
.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)?;
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) {
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let code = highlighter
.code_to_html(
&paste.content,
&CodeToHtmlOptions::new(
paste.language.as_deref().unwrap_or("text"),
"github-light",
),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(NotePage { code }.into_response())
} else {
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()
}
+24
View File
@@ -0,0 +1,24 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::Redirect,
};
use sqlx::SqlitePool;
use crate::paste::PasteType;
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)?;
Ok(Redirect::temporary(&url))
}
+10
View File
@@ -0,0 +1,10 @@
use askama::Template;
use askama_web::WebTemplate;
#[derive(Template, WebTemplate)]
#[template(path = "home.html")]
pub struct HomePage;
pub async fn home_page() -> HomePage {
HomePage
}