use axum::{ extract::{Form, Path, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use askama::Template; use askama_web::WebTemplate; use serde::Deserialize; use sqlx::FromRow; use crate::auth_guard::{AuthUser, OptionalAuth}; use crate::state::AppState; const MAX_PASTE_SIZE: usize = 512 * 1024; const FREE_PASTE_LIMIT: i64 = 5; const FREE_EXPIRY_HOURS: i64 = 24; #[derive(FromRow)] #[allow(dead_code)] struct Paste { id: String, user_id: String, title: Option, content: String, language: Option, size_bytes: i64, created_at: time::OffsetDateTime, expires_at: Option, } fn format_time(t: time::OffsetDateTime) -> String { format!( "{:04}-{:02}-{:02} {:02}:{:02} UTC", t.year(), t.month() as u8, t.day(), t.hour(), t.minute(), ) } #[derive(Template, WebTemplate)] #[template(path = "create.html")] #[allow(dead_code)] pub struct CreateTemplate { pub logged_in: bool, pub email: Option, } pub async fn index(OptionalAuth(user): OptionalAuth) -> CreateTemplate { CreateTemplate { logged_in: user.is_some(), email: user.and_then(|u| u.email), } } #[derive(Deserialize)] pub struct CreateForm { title: Option, content: String, language: Option, } pub async fn create( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { if form.content.is_empty() { return Err((StatusCode::BAD_REQUEST, "paste content cannot be empty").into_response()); } if form.content.len() > MAX_PASTE_SIZE { return Err((StatusCode::BAD_REQUEST, "paste too large (512KB max)").into_response()); } let is_pro = user.is_pro(); if !is_pro { let count: (i64,) = sqlx::query_as("SELECT count(*) FROM pastes WHERE user_id = $1") .bind(&user.sub) .fetch_one(&state.db) .await .map_err(|e| { tracing::error!("paste count query failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })?; if count.0 >= FREE_PASTE_LIMIT { return Err(( StatusCode::FORBIDDEN, "free plan limited to 5 pastes -- upgrade to pro for unlimited", ) .into_response()); } } let id = nanoid::nanoid!(8); let size_bytes = form.content.len() as i64; let title = form.title.filter(|t| !t.trim().is_empty()); let language = form.language.filter(|l| !l.trim().is_empty()); let expires_at = if is_pro { None } else { Some(time::OffsetDateTime::now_utc() + time::Duration::hours(FREE_EXPIRY_HOURS)) }; sqlx::query( "INSERT INTO pastes (id, user_id, title, content, language, size_bytes, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", ) .bind(&id) .bind(&user.sub) .bind(&title) .bind(&form.content) .bind(&language) .bind(size_bytes) .bind(expires_at) .execute(&state.db) .await .map_err(|e| { tracing::error!("paste insert failed: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() })?; Ok(Redirect::see_other(&format!("/{id}"))) } #[derive(Template, WebTemplate)] #[template(path = "view.html")] pub struct ViewTemplate { pub id: String, pub title: String, pub content: String, pub language: String, pub created_at: String, pub expires_at: Option, pub is_owner: bool, } pub async fn view( State(state): State, OptionalAuth(user): OptionalAuth, Path(id): Path, ) -> Result { let paste = sqlx::query_as::<_, Paste>("SELECT * FROM pastes WHERE id = $1") .bind(&id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!("paste query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let is_owner = user.map(|u| u.sub == paste.user_id).unwrap_or(false); Ok(ViewTemplate { id: paste.id, title: paste.title.unwrap_or_else(|| "untitled".to_string()), content: paste.content, language: paste.language.unwrap_or_else(|| "plaintext".to_string()), created_at: format_time(paste.created_at), expires_at: paste.expires_at.map(format_time), is_owner, }) } pub async fn raw( State(state): State, Path(id): Path, ) -> Result { let paste = sqlx::query_as::<_, Paste>("SELECT * FROM pastes WHERE id = $1") .bind(&id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!("paste query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; Ok(( [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], paste.content, ) .into_response()) } struct PasteListRow { id: String, title: Option, language: Option, size_bytes: i64, created_at: time::OffsetDateTime, expires_at: Option, } impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for PasteListRow { fn from_row(row: &sqlx::postgres::PgRow) -> Result { use sqlx::Row; Ok(Self { id: row.try_get("id")?, title: row.try_get("title")?, language: row.try_get("language")?, size_bytes: row.try_get("size_bytes")?, created_at: row.try_get("created_at")?, expires_at: row.try_get("expires_at")?, }) } } pub struct PasteEntry { pub id: String, pub title: String, pub language: String, pub size: String, pub created_at: String, pub expires_at: Option, } #[derive(Template, WebTemplate)] #[template(path = "my.html")] pub struct MyTemplate { pub pastes: Vec, } fn format_size(bytes: i64) -> String { if bytes < 1024 { format!("{bytes} B") } else { format!("{:.1} KB", bytes as f64 / 1024.0) } } pub async fn my_pastes( State(state): State, AuthUser(user): AuthUser, ) -> Result { let rows = sqlx::query_as::<_, PasteListRow>( "SELECT id, title, language, size_bytes, created_at, expires_at FROM pastes WHERE user_id = $1 ORDER BY created_at DESC", ) .bind(&user.sub) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!("paste list query failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; let pastes = rows .into_iter() .map(|r| PasteEntry { id: r.id, title: r.title.unwrap_or_else(|| "untitled".to_string()), language: r.language.unwrap_or_else(|| "plaintext".to_string()), size: format_size(r.size_bytes), created_at: format_time(r.created_at), expires_at: r.expires_at.map(format_time), }) .collect(); Ok(MyTemplate { pastes }) } pub async fn delete( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { let result = sqlx::query("DELETE FROM pastes WHERE id = $1 AND user_id = $2") .bind(&id) .bind(&user.sub) .execute(&state.db) .await .map_err(|e| { tracing::error!("paste delete failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; if result.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } Ok(Redirect::see_other("/my")) }