diff --git a/Cargo.lock b/Cargo.lock index 360fce2..4cc545a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,10 +2863,12 @@ name = "samey" version = "0.1.0" dependencies = [ "askama", + "async-trait", "axum", "axum-extra", "axum-login", "chrono", + "clap", "futures-util", "image", "itertools 0.14.0", @@ -2875,9 +2877,12 @@ dependencies = [ "rand 0.9.0", "sea-orm", "serde", + "serde_json", "thiserror 2.0.12", + "time", "tokio", "tower-http", + "tower-sessions", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 15d679b..d1402b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,12 @@ members = [".", "migration"] [dependencies] askama = "0.13.0" +async-trait = "0.1.88" axum = { version = "0.8.3", features = ["multipart", "macros"] } axum-extra = { version = "0.10.1", features = ["form"] } axum-login = "0.17.0" chrono = "0.4.40" +clap = "4.5.35" futures-util = "0.3.31" image = "0.25.6" itertools = "0.14.0" @@ -29,6 +31,13 @@ sea-orm = { version = "1.1.8", features = [ "with-chrono", ] } serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" thiserror = "2.0.12" +time = "0.3.41" tokio = { version = "1.44.1", features = ["full"] } tower-http = { version = "0.6.2", features = ["fs"] } +tower-sessions = "0.14.0" + +[profile.release] +strip = true +lto = true diff --git a/README.md b/README.md index 70ddcf5..742b690 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ Sam's small image board. Currently a WIP. ## TODO -- [ ] Authentication - [ ] Improve filters - [ ] Pools - [ ] Video support diff --git a/migration/src/m20250405_000001_create_table.rs b/migration/src/m20250405_000001_create_table.rs index 49c6b91..16ae03c 100644 --- a/migration/src/m20250405_000001_create_table.rs +++ b/migration/src/m20250405_000001_create_table.rs @@ -6,34 +6,71 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(SameySession::Table) + .if_not_exists() + .col(pk_auto(SameySession::Id)) + .col(string_uniq(SameySession::SessionId)) + .col(json(SameySession::Data)) + .col(big_integer(SameySession::ExpiryDate)) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(SameyUser::Table) + .if_not_exists() + .col(pk_auto(SameyUser::Id)) + .col(string_len_uniq(SameyUser::Username, 50)) + .col(string(SameyUser::Password)) + .col(boolean(SameyUser::IsAdmin).default(false)) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() .table(SameyPost::Table) .if_not_exists() .col(pk_auto(SameyPost::Id)) + .col(integer(SameyPost::UploaderId)) .col(string_len(SameyPost::Media, 255)) .col(integer(SameyPost::Width)) .col(integer(SameyPost::Height)) .col(string_len(SameyPost::Thumbnail, 255)) .col(string_len_null(SameyPost::Title, 100)) .col(text_null(SameyPost::Description)) - .col(boolean(SameyPost::IsPublic)) - .col(enumeration( - SameyPost::Rating, - Rating::Enum, - [ - Rating::Unrated, - Rating::Safe, - Rating::Questionable, - Rating::Explicit, - ], - )) + .col(boolean(SameyPost::IsPublic).default(false)) + .col( + enumeration( + SameyPost::Rating, + Rating::Enum, + [ + Rating::Unrated, + Rating::Safe, + Rating::Questionable, + Rating::Explicit, + ], + ) + .default(Rating::Unrated.into_iden().to_string()), + ) .col(date_time(SameyPost::UploadedAt)) .col(integer_null(SameyPost::ParentId)) .foreign_key( ForeignKeyCreateStatement::new() - .name("fk-samey_post-samey_post-parent") + .name("fk-samey_post-samey_user-uploader_id") + .from(SameyPost::Table, SameyPost::UploaderId) + .to(SameyUser::Table, SameyUser::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-samey_post-samey_post-parent_id") .from(SameyPost::Table, SameyPost::ParentId) .to(SameyPost::Table, SameyPost::Id) .on_delete(ForeignKeyAction::SetNull), @@ -52,7 +89,7 @@ impl MigrationTrait for Migration { .col(integer(SameyPostSource::PostId)) .foreign_key( ForeignKeyCreateStatement::new() - .name("fk-samey_post_source-samey_post") + .name("fk-samey_post_source-samey_post-post_id") .from(SameyPostSource::Table, SameyPostSource::PostId) .to(SameyPost::Table, SameyPostSource::Id) .on_delete(ForeignKeyAction::Cascade), @@ -83,14 +120,14 @@ impl MigrationTrait for Migration { .col(integer(SameyTagPost::PostId)) .foreign_key( ForeignKeyCreateStatement::new() - .name("fk-samey_tag_post-samey_tag") + .name("fk-samey_tag_post-samey_tag-tag_id") .from(SameyTagPost::Table, SameyTagPost::TagId) .to(SameyTag::Table, SameyTag::Id) .on_delete(ForeignKeyAction::Cascade), ) .foreign_key( ForeignKeyCreateStatement::new() - .name("fk-samey_tag_post-samey_post") + .name("fk-samey_tag_post-samey_post-post_id") .from(SameyTagPost::Table, SameyTagPost::PostId) .to(SameyPost::Table, SameyPost::Id) .on_delete(ForeignKeyAction::Cascade), @@ -127,15 +164,44 @@ impl MigrationTrait for Migration { .drop_table(Table::drop().table(SameyPost::Table).to_owned()) .await?; + manager + .drop_table(Table::drop().table(SameyUser::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(SameySession::Table).to_owned()) + .await?; + Ok(()) } } +#[derive(DeriveIden)] +enum SameySession { + #[sea_orm(iden = "samey_session")] + Table, + Id, + SessionId, + Data, + ExpiryDate, +} + +#[derive(DeriveIden)] +enum SameyUser { + #[sea_orm(iden = "samey_user")] + Table, + Id, + Username, + Password, + IsAdmin, +} + #[derive(DeriveIden)] enum SameyPost { #[sea_orm(iden = "samey_post")] Table, Id, + UploaderId, Media, Width, Height, diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..b404a02 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,226 @@ +use std::fmt::Debug; + +use axum_login::{AuthUser, AuthnBackend, UserId}; +use migration::Expr; +use password_auth::verify_password; +use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use serde::Deserialize; +use time::OffsetDateTime; +use tower_sessions::{ExpiredDeletion, SessionStore, session::Record, session_store}; + +use crate::{ + SameyError, + entities::{ + prelude::{SameySession, SameyUser}, + samey_session, samey_user, + }, +}; + +#[derive(Debug, Clone)] +pub(crate) struct User { + pub(crate) id: i32, + pub(crate) username: String, + pub(crate) is_admin: bool, +} + +impl AuthUser for User { + type Id = i32; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.username.as_bytes() + } +} + +#[derive(Clone, Deserialize)] +pub(crate) struct Credentials { + pub(crate) username: String, + pub(crate) password: String, +} + +impl Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("username", &self.username) + .field("password", &"[redacted]") + .finish() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct Backend { + db: DatabaseConnection, +} + +impl Backend { + pub(crate) fn new(db: DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait::async_trait] +impl AuthnBackend for Backend { + type User = User; + type Credentials = Credentials; + type Error = SameyError; + + async fn authenticate( + &self, + credentials: Self::Credentials, + ) -> Result, Self::Error> { + let user = SameyUser::find() + .filter(samey_user::Column::Username.eq(credentials.username)) + .one(&self.db) + .await?; + + Ok(user.and_then(|user| { + verify_password(credentials.password, &user.password) + .ok() + .map(|_| User { + id: user.id, + username: user.username, + is_admin: user.is_admin, + }) + })) + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + let user = SameyUser::find_by_id(*user_id).one(&self.db).await?; + + Ok(user.map(|user| User { + id: user.id, + username: user.username, + is_admin: user.is_admin, + })) + } +} + +pub(crate) type AuthSession = axum_login::AuthSession; + +#[derive(Debug, Clone)] +pub(crate) struct SessionStorage { + db: DatabaseConnection, +} + +impl SessionStorage { + pub(crate) fn new(db: DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait::async_trait] +impl SessionStore for SessionStorage { + async fn create(&self, record: &mut Record) -> session_store::Result<()> { + SameySession::insert(samey_session::ActiveModel { + session_id: Set(record.id.to_string()), + data: Set(sea_orm::JsonValue::Object( + record + .data + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + )), + expiry_date: Set(record.expiry_date.unix_timestamp().to_string()), + ..Default::default() + }) + .exec(&self.db) + .await + .map_err(|_| session_store::Error::Backend("Failed to create a new session".into()))?; + Ok(()) + } + + async fn save(&self, record: &Record) -> session_store::Result<()> { + SameySession::update(samey_session::ActiveModel { + data: Set(sea_orm::JsonValue::Object( + record + .data + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + )), + expiry_date: Set(record.expiry_date.unix_timestamp().to_string()), + ..Default::default() + }) + .filter(samey_session::Column::SessionId.eq(record.id.to_string())) + .exec(&self.db) + .await + .map_err(|_| session_store::Error::Backend("Failed to update session".into()))?; + Ok(()) + } + + async fn load( + &self, + session_id: &tower_sessions::session::Id, + ) -> session_store::Result> { + let session = SameySession::find() + .filter(samey_session::Column::SessionId.eq(session_id.to_string())) + .one(&self.db) + .await + .map_err(|_| session_store::Error::Backend("Failed to retrieve session".into()))?; + + let record = match session { + Some(session) => Record { + id: session.session_id.parse().map_err(|_| { + session_store::Error::Backend("Failed to parse session ID".into()) + })?, + data: session + .data + .as_object() + .ok_or(session_store::Error::Backend( + "Failed to parse session data".into(), + ))? + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + expiry_date: match session.expiry_date.parse() { + Ok(timestamp) => { + OffsetDateTime::from_unix_timestamp(timestamp).map_err(|_| { + session_store::Error::Backend( + "Invalid timestamp for expiry date".into(), + ) + })? + } + Err(_) => { + return Err(session_store::Error::Backend( + "Failed to parse session expiry date".into(), + )); + } + }, + }, + None => return Ok(None), + }; + if record.expiry_date > OffsetDateTime::now_utc() { + Ok(Some(record)) + } else { + Ok(None) + } + } + + async fn delete(&self, session_id: &tower_sessions::session::Id) -> session_store::Result<()> { + SameySession::delete_many() + .filter(samey_session::Column::SessionId.eq(session_id.to_string())) + .exec(&self.db) + .await + .map_err(|_| session_store::Error::Backend("Failed to delete session".into()))?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl ExpiredDeletion for SessionStorage { + async fn delete_expired(&self) -> session_store::Result<()> { + SameySession::delete_many() + .filter(Expr::cust( + "DATETIME(\"samey_session\".\"expiry_date\", 'unixepoch') < DATETIME('now')", + )) + .exec(&self.db) + .await + .map_err(|_| { + session_store::Error::Backend("Failed to delete expired sessions".into()) + })?; + Ok(()) + } +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index b7a4afa..aeea177 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -4,5 +4,7 @@ pub mod prelude; pub mod samey_post; pub mod samey_post_source; +pub mod samey_session; pub mod samey_tag; pub mod samey_tag_post; +pub mod samey_user; diff --git a/src/entities/prelude.rs b/src/entities/prelude.rs index cb1860a..f4c570f 100644 --- a/src/entities/prelude.rs +++ b/src/entities/prelude.rs @@ -2,5 +2,7 @@ pub use super::samey_post::Entity as SameyPost; pub use super::samey_post_source::Entity as SameyPostSource; +pub use super::samey_session::Entity as SameySession; pub use super::samey_tag::Entity as SameyTag; pub use super::samey_tag_post::Entity as SameyTagPost; +pub use super::samey_user::Entity as SameyUser; diff --git a/src/entities/samey_post.rs b/src/entities/samey_post.rs index d89acff..2e6caef 100644 --- a/src/entities/samey_post.rs +++ b/src/entities/samey_post.rs @@ -7,6 +7,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: i32, + pub uploader_id: i32, pub media: String, pub width: i32, pub height: i32, @@ -35,6 +36,14 @@ pub enum Relation { SameyPostSource, #[sea_orm(has_many = "super::samey_tag_post::Entity")] SameyTagPost, + #[sea_orm( + belongs_to = "super::samey_user::Entity", + from = "Column::UploaderId", + to = "super::samey_user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + SameyUser, } impl Related for Entity { @@ -49,4 +58,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::SameyUser.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_session.rs b/src/entities/samey_session.rs new file mode 100644 index 0000000..6d21e92 --- /dev/null +++ b/src/entities/samey_session.rs @@ -0,0 +1,18 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "samey_session")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub session_id: String, + pub data: Json, + pub expiry_date: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_user.rs b/src/entities/samey_user.rs new file mode 100644 index 0000000..e623800 --- /dev/null +++ b/src/entities/samey_user.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "samey_user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub username: String, + pub password: String, + pub is_admin: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::samey_post::Entity")] + SameyPost, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SameyPost.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/error.rs b/src/error.rs index 6a70b9a..0ac152e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,10 +19,14 @@ pub enum SameyError { Multipart(#[from] axum::extract::multipart::MultipartError), #[error("Image error: {0}")] Image(#[from] image::ImageError), - #[error("Internal error: {0}")] - Other(String), #[error("Not found")] NotFound, + #[error("Authentication error: {0}")] + Authentication(String), + #[error("Not allowed")] + Forbidden, + #[error("Internal error: {0}")] + Other(String), } impl IntoResponse for SameyError { @@ -42,6 +46,10 @@ impl IntoResponse for SameyError { (StatusCode::BAD_REQUEST, "Invalid request").into_response() } SameyError::NotFound => (StatusCode::NOT_FOUND, "Resource not found").into_response(), + SameyError::Authentication(_) => { + (StatusCode::UNAUTHORIZED, "Not authorized").into_response() + } + SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(), } } } diff --git a/src/lib.rs b/src/lib.rs index 6bd00fc..d06401c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub(crate) mod auth; pub(crate) mod entities; pub(crate) mod error; pub(crate) mod query; @@ -6,20 +7,26 @@ pub(crate) mod views; use std::sync::Arc; +use auth::SessionStorage; use axum::{ Router, extract::DefaultBodyLimit, routing::{delete, get, post, put}, }; -use sea_orm::DatabaseConnection; +use axum_login::AuthManagerLayerBuilder; +use password_auth::generate_hash; +use sea_orm::{ActiveValue::Set, DatabaseConnection, EntityTrait}; use tokio::fs; use tower_http::services::ServeDir; +use tower_sessions::SessionManagerLayer; +use crate::auth::Backend; +use crate::entities::{prelude::SameyUser, samey_user}; pub use crate::error::SameyError; use crate::views::{ - add_post_source, delete_post, edit_post_details, get_full_media, get_media, index, - post_details, posts, posts_page, remove_field, search_tags, select_tag, submit_post_details, - upload, view_post, + add_post_source, delete_post, edit_post_details, get_full_media, get_media, index, login, + logout, post_details, posts, posts_page, remove_field, search_tags, select_tag, + submit_post_details, upload, view_post, }; #[derive(Clone)] @@ -28,21 +35,45 @@ pub(crate) struct AppState { db: DatabaseConnection, } +pub async fn create_user( + db: DatabaseConnection, + username: String, + password: String, + is_admin: bool, +) -> Result<(), SameyError> { + SameyUser::insert(samey_user::ActiveModel { + username: Set(username), + password: Set(generate_hash(password)), + is_admin: Set(is_admin), + ..Default::default() + }) + .exec(&db) + .await?; + Ok(()) +} + pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result { let state = AppState { files_dir: Arc::new(files_dir.into()), - db, + db: db.clone(), }; fs::create_dir_all(files_dir).await?; + + let session_store = SessionStorage::new(db.clone()); + let session_layer = SessionManagerLayer::new(session_store); + let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build(); + Ok(Router::new() + .route("/login", post(login)) + .route("/logout", get(logout)) .route( "/upload", post(upload).layer(DefaultBodyLimit::max(100_000_000)), ) .route("/search_tags", post(search_tags)) .route("/select_tag/{new_tag}", post(select_tag)) - .route("/posts/{page}", get(posts_page)) .route("/posts", get(posts)) + .route("/posts/{page}", get(posts_page)) .route("/view/{post_id}", get(view_post)) .route("/post/{post_id}", delete(delete_post)) .route("/post_details/{post_id}/edit", get(edit_post_details)) @@ -54,5 +85,6 @@ pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result, +} + +#[derive(Subcommand)] +enum Commands { + Run, + Migrate, + AddUser { + #[arg(short, long)] + username: String, + #[arg(short, long)] + password: String, + }, + AddAdminUser { + #[arg(short, long)] + username: String, + #[arg(short, long)] + password: String, + }, +} + #[tokio::main] async fn main() { let db = Database::connect("sqlite:db.sqlite3?mode=rwc") .await .expect("Unable to connect to database"); - Migrator::up(&db, None) - .await - .expect("Unable to apply migrations"); - let app = get_router(db, "files") - .await - .expect("Unable to start router"); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") - .await - .expect("Unable to listen to port"); - println!("Listening on http://localhost:3000"); - axum::serve(listener, app).await.unwrap(); + let config = Config::parse(); + match config.command { + Some(Commands::Migrate) => { + Migrator::up(&db, None) + .await + .expect("Unable to apply migrations"); + } + Some(Commands::AddUser { username, password }) => { + create_user(db, username, password, false) + .await + .expect("Unable to add user"); + } + Some(Commands::AddAdminUser { username, password }) => { + create_user(db, username, password, true) + .await + .expect("Unable to add admin"); + } + Some(Commands::Run) | None => { + Migrator::up(&db, None) + .await + .expect("Unable to apply migrations"); + let app = get_router(db, "files") + .await + .expect("Unable to start router"); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .expect("Unable to listen to port"); + println!("Listening on http://localhost:3000"); + axum::serve(listener, app).await.unwrap(); + } + } } diff --git a/src/query.rs b/src/query.rs index e6b99df..d3cc552 100644 --- a/src/query.rs +++ b/src/query.rs @@ -2,13 +2,16 @@ use std::collections::HashSet; use migration::{Expr, Query}; use sea_orm::{ - ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, QuerySelect, RelationTrait, - Select, SelectModel, Selector, + ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, QueryFilter, QueryOrder, + QuerySelect, RelationTrait, Select, SelectModel, Selector, }; -use crate::entities::{ - prelude::{SameyPost, SameyTag, SameyTagPost}, - samey_post, samey_tag, samey_tag_post, +use crate::{ + auth::User, + entities::{ + prelude::{SameyPost, SameyTag, SameyTagPost}, + samey_post, samey_tag, samey_tag_post, + }, }; #[derive(Debug, FromQueryResult)] @@ -18,14 +21,17 @@ pub(crate) struct SearchPost { pub(crate) tags: String, } -pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector> { +pub(crate) fn search_posts( + tags: Option<&Vec<&str>>, + user: Option, +) -> Selector> { let tags: HashSet = match tags { Some(tags) if !tags.is_empty() => tags.iter().map(|&tag| tag.to_lowercase()).collect(), _ => HashSet::new(), }; - if tags.is_empty() { - let query = SameyPost::find() + let mut query = if tags.is_empty() { + SameyPost::find() .select_only() .column(samey_post::Column::Id) .column(samey_post::Column::Thumbnail) @@ -39,46 +45,54 @@ pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector(); + }; + query = match user { + None => query.filter(samey_post::Column::IsPublic.into_simple_expr()), + Some(user) if !user.is_admin => query.filter( + Condition::any() + .add(samey_post::Column::IsPublic.into_simple_expr()) + .add(samey_post::Column::UploaderId.eq(user.id)), + ), + _ => query, }; - let tags_count = tags.len() as u32; - let subquery = Query::select() - .column((SameyPost, samey_post::Column::Id)) - .from(SameyPost) - .inner_join( - SameyTagPost, - Expr::col((SameyPost, samey_post::Column::Id)) - .equals((SameyTagPost, samey_tag_post::Column::PostId)), - ) - .inner_join( - SameyTag, - Expr::col((SameyTagPost, samey_tag_post::Column::TagId)) - .equals((SameyTag, samey_tag::Column::Id)), - ) - .and_where(samey_tag::Column::NormalizedName.is_in(tags)) - .group_by_col((SameyPost, samey_post::Column::Id)) - .and_having(samey_tag::Column::Id.count().eq(tags_count)) - .to_owned(); - let query = SameyPost::find() - .select_only() - .column(samey_post::Column::Id) - .column(samey_post::Column::Thumbnail) - .column_as( - Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), - "tags", - ) - .inner_join(SameyTagPost) - .join( - sea_orm::JoinType::InnerJoin, - samey_tag_post::Relation::SameyTag.def(), - ) - .filter(samey_post::Column::Id.in_subquery(subquery)) - .group_by(samey_post::Column::Id) - .order_by_desc(samey_post::Column::Id); - // println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql); query.into_model::() } diff --git a/src/views.rs b/src/views.rs index a80c549..995f047 100644 --- a/src/views.rs +++ b/src/views.rs @@ -24,6 +24,7 @@ use tokio::task::spawn_blocking; use crate::{ AppState, + auth::{AuthSession, Credentials, User}, entities::{ prelude::{SameyPost, SameyPostSource, SameyTag, SameyTagPost}, samey_post, samey_post_source, samey_tag, samey_tag_post, @@ -38,18 +39,58 @@ const MAX_THUMBNAIL_DIMENSION: u32 = 192; #[derive(Template)] #[template(path = "index.html")] -struct IndexTemplate {} +struct IndexTemplate { + user: Option, +} -pub(crate) async fn index() -> Result { - Ok(Html(IndexTemplate {}.render()?)) +pub(crate) async fn index(auth_session: AuthSession) -> Result { + Ok(Html( + IndexTemplate { + user: auth_session.user, + } + .render()?, + )) +} + +// Auth views + +pub(crate) async fn login( + mut auth_session: AuthSession, + Form(credentials): Form, +) -> Result { + let user = match auth_session.authenticate(credentials).await { + Ok(Some(user)) => user, + Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())), + Err(_) => return Err(SameyError::Other("Auth session error".into())), + }; + + auth_session + .login(&user) + .await + .map_err(|_| SameyError::Other("Login failed".into()))?; + Ok(Redirect::to("/")) +} + +pub(crate) async fn logout(mut auth_session: AuthSession) -> Result { + auth_session + .logout() + .await + .map_err(|_| SameyError::Other("Logout error".into()))?; + Ok(Redirect::to("/")) } // Post upload view pub(crate) async fn upload( State(AppState { db, files_dir }): State, + auth_session: AuthSession, mut multipart: Multipart, ) -> Result { + let user = match auth_session.user { + Some(user) => user, + None => return Err(SameyError::Forbidden), + }; + let mut upload_tags: Option> = None; let mut source_file: Option = None; let mut thumbnail_file: Option = None; @@ -147,6 +188,7 @@ pub(crate) async fn upload( height.map(|h| h.get()), ) { let uploaded_post = SameyPost::insert(samey_post::ActiveModel { + uploader_id: Set(user.id), media: Set(source_file), width: Set(width), height: Set(height), @@ -263,13 +305,15 @@ pub(crate) struct PostsQuery { pub(crate) async fn posts( state: State, + auth_session: AuthSession, query: Query, ) -> Result { - posts_page(state, query, Path(1)).await + posts_page(state, auth_session, query, Path(1)).await } pub(crate) async fn posts_page( State(AppState { db, .. }): State, + auth_session: AuthSession, Query(query): Query, Path(page): Path, ) -> Result { @@ -277,7 +321,7 @@ pub(crate) async fn posts_page( .tags .as_ref() .map(|tags| tags.split_whitespace().collect::>()); - let pagination = search_posts(tags.as_ref()).paginate(&db, 50); + let pagination = search_posts(tags.as_ref(), auth_session.user).paginate(&db, 50); let page_count = pagination.num_pages().await?; let posts = pagination.fetch_page(page.saturating_sub(1) as u64).await?; let posts = posts @@ -312,10 +356,12 @@ struct ViewPostTemplate { post: samey_post::Model, tags: Vec, sources: Vec, + can_edit: bool, } pub(crate) async fn view_post( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { let tags = get_tags_for_post(post_id as i32).all(&db).await?; @@ -330,11 +376,21 @@ pub(crate) async fn view_post( .await? .ok_or(SameyError::NotFound)?; + let can_edit = match auth_session.user { + None => false, + Some(user) => user.is_admin || post.uploader_id == user.id, + }; + + if !post.is_public && !can_edit { + return Err(SameyError::NotFound); + } + Ok(Html( ViewPostTemplate { post, tags, sources, + can_edit, } .render()?, )) @@ -345,23 +401,42 @@ pub(crate) async fn view_post( struct PostDetailsTemplate { post: samey_post::Model, sources: Vec, + can_edit: bool, } pub(crate) async fn post_details( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { + let post_id = post_id as i32; let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) .await?; - let post = SameyPost::find_by_id(post_id as i32) + let post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; - Ok(Html(PostDetailsTemplate { post, sources }.render()?)) + let can_edit = match auth_session.user { + None => false, + Some(user) => user.is_admin || post.uploader_id == user.id, + }; + + if !post.is_public && !can_edit { + return Err(SameyError::NotFound); + } + + Ok(Html( + PostDetailsTemplate { + post, + sources, + can_edit, + } + .render()?, + )) } #[derive(Debug, Deserialize)] @@ -381,14 +456,31 @@ struct SubmitPostDetailsTemplate { post: samey_post::Model, sources: Vec, tags: Vec, + can_edit: bool, } pub(crate) async fn submit_post_details( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, Form(body): Form, ) -> Result { let post_id = post_id as i32; + + let post = SameyPost::find_by_id(post_id) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + match auth_session.user { + None => return Err(SameyError::Forbidden), + Some(user) => { + if !user.is_admin && post.uploader_id != user.id { + return Err(SameyError::Forbidden); + } + } + } + let title = match body.title.trim() { title if title.is_empty() => None, title => Some(title.to_owned()), @@ -473,6 +565,7 @@ pub(crate) async fn submit_post_details( post, sources, tags: upload_tags, + can_edit: true, } .render()?, )) @@ -492,8 +585,23 @@ struct EditDetailsTemplate { pub(crate) async fn edit_post_details( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { + let post = SameyPost::find_by_id(post_id as i32) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + match auth_session.user { + None => return Err(SameyError::Forbidden), + Some(user) => { + if !user.is_admin && post.uploader_id != user.id { + return Err(SameyError::Forbidden); + } + } + } + let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) @@ -512,11 +620,6 @@ pub(crate) async fn edit_post_details( .await? .join(" "); - let post = SameyPost::find_by_id(post_id as i32) - .one(&db) - .await? - .ok_or(SameyError::NotFound)?; - Ok(Html( EditDetailsTemplate { post, @@ -554,6 +657,7 @@ struct GetMediaTemplate { pub(crate) async fn get_media( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { let post = SameyPost::find_by_id(post_id as i32) @@ -561,6 +665,15 @@ pub(crate) async fn get_media( .await? .ok_or(SameyError::NotFound)?; + let can_edit = match auth_session.user { + None => false, + Some(user) => user.is_admin || post.uploader_id == user.id, + }; + + if !post.is_public && !can_edit { + return Err(SameyError::NotFound); + } + Ok(Html(GetMediaTemplate { post }.render()?)) } @@ -572,6 +685,7 @@ struct GetFullMediaTemplate { pub(crate) async fn get_full_media( State(AppState { db, .. }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { let post = SameyPost::find_by_id(post_id as i32) @@ -579,17 +693,37 @@ pub(crate) async fn get_full_media( .await? .ok_or(SameyError::NotFound)?; + let can_edit = match auth_session.user { + None => false, + Some(user) => user.is_admin || post.uploader_id == user.id, + }; + + if !post.is_public && !can_edit { + return Err(SameyError::NotFound); + } + Ok(Html(GetFullMediaTemplate { post }.render()?)) } pub(crate) async fn delete_post( State(AppState { db, files_dir }): State, + auth_session: AuthSession, Path(post_id): Path, ) -> Result { let post = SameyPost::find_by_id(post_id as i32) .one(&db) .await? .ok_or(SameyError::NotFound)?; + + match auth_session.user { + None => return Err(SameyError::Forbidden), + Some(user) => { + if !user.is_admin && post.uploader_id != user.id { + return Err(SameyError::Forbidden); + } + } + } + SameyPost::delete_by_id(post.id).exec(&db).await?; tokio::spawn(async move { @@ -598,5 +732,5 @@ pub(crate) async fn delete_post( let _ = std::fs::remove_file(base_path.join(post.thumbnail)); }); - Ok(Redirect::to("/posts/1")) + Ok(Redirect::to("/")) } diff --git a/templates/edit_post_details.html b/templates/edit_post_details.html index 808539f..7ee3832 100644 --- a/templates/edit_post_details.html +++ b/templates/edit_post_details.html @@ -49,6 +49,6 @@
- +
diff --git a/templates/index.html b/templates/index.html index a3d767a..a4aafda 100644 --- a/templates/index.html +++ b/templates/index.html @@ -27,6 +27,7 @@ + {% if let Some(user) = user %}

Upload media

@@ -51,6 +52,25 @@
+ + {% else %} +
+

Log in

+
+
+ + +
+
+ + +
+ +
+
+ {% endif %} diff --git a/templates/post_details.html b/templates/post_details.html index 0bab098..a8a479d 100644 --- a/templates/post_details.html +++ b/templates/post_details.html @@ -44,5 +44,7 @@ {{ post.uploaded_at }} + {% if can_edit %} + {% endif %} diff --git a/templates/posts.html b/templates/posts.html index 8aa346d..03a0852 100644 --- a/templates/posts.html +++ b/templates/posts.html @@ -9,7 +9,7 @@

Search

-
+ Tags
    {% if let Some(tags) = tags %} {% for tag in tags %} -
  • {{ tag }}
  • +
  • {{ tag }}
  • {% endfor %} {% endif %}