WIP
This commit is contained in:
17 files changed
+672
-48
No files matched your search
+88
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user