Authentication

This commit is contained in:
Bad Manners 2025-04-07 23:55:35 -03:00
parent 2722c7d40a
commit a5e3fb2850
19 changed files with 723 additions and 98 deletions

5
Cargo.lock generated
View file

@ -2863,10 +2863,12 @@ name = "samey"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"askama", "askama",
"async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"axum-login", "axum-login",
"chrono", "chrono",
"clap",
"futures-util", "futures-util",
"image", "image",
"itertools 0.14.0", "itertools 0.14.0",
@ -2875,9 +2877,12 @@ dependencies = [
"rand 0.9.0", "rand 0.9.0",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json",
"thiserror 2.0.12", "thiserror 2.0.12",
"time",
"tokio", "tokio",
"tower-http", "tower-http",
"tower-sessions",
] ]
[[package]] [[package]]

View file

@ -12,10 +12,12 @@ members = [".", "migration"]
[dependencies] [dependencies]
askama = "0.13.0" askama = "0.13.0"
async-trait = "0.1.88"
axum = { version = "0.8.3", features = ["multipart", "macros"] } axum = { version = "0.8.3", features = ["multipart", "macros"] }
axum-extra = { version = "0.10.1", features = ["form"] } axum-extra = { version = "0.10.1", features = ["form"] }
axum-login = "0.17.0" axum-login = "0.17.0"
chrono = "0.4.40" chrono = "0.4.40"
clap = "4.5.35"
futures-util = "0.3.31" futures-util = "0.3.31"
image = "0.25.6" image = "0.25.6"
itertools = "0.14.0" itertools = "0.14.0"
@ -29,6 +31,13 @@ sea-orm = { version = "1.1.8", features = [
"with-chrono", "with-chrono",
] } ] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
thiserror = "2.0.12" thiserror = "2.0.12"
time = "0.3.41"
tokio = { version = "1.44.1", features = ["full"] } tokio = { version = "1.44.1", features = ["full"] }
tower-http = { version = "0.6.2", features = ["fs"] } tower-http = { version = "0.6.2", features = ["fs"] }
tower-sessions = "0.14.0"
[profile.release]
strip = true
lto = true

View file

@ -4,7 +4,6 @@ Sam's small image board. Currently a WIP.
## TODO ## TODO
- [ ] Authentication
- [ ] Improve filters - [ ] Improve filters
- [ ] Pools - [ ] Pools
- [ ] Video support - [ ] Video support

View file

@ -6,20 +6,48 @@ pub struct Migration;
#[async_trait::async_trait] #[async_trait::async_trait]
impl MigrationTrait for Migration { impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 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 manager
.create_table( .create_table(
Table::create() Table::create()
.table(SameyPost::Table) .table(SameyPost::Table)
.if_not_exists() .if_not_exists()
.col(pk_auto(SameyPost::Id)) .col(pk_auto(SameyPost::Id))
.col(integer(SameyPost::UploaderId))
.col(string_len(SameyPost::Media, 255)) .col(string_len(SameyPost::Media, 255))
.col(integer(SameyPost::Width)) .col(integer(SameyPost::Width))
.col(integer(SameyPost::Height)) .col(integer(SameyPost::Height))
.col(string_len(SameyPost::Thumbnail, 255)) .col(string_len(SameyPost::Thumbnail, 255))
.col(string_len_null(SameyPost::Title, 100)) .col(string_len_null(SameyPost::Title, 100))
.col(text_null(SameyPost::Description)) .col(text_null(SameyPost::Description))
.col(boolean(SameyPost::IsPublic)) .col(boolean(SameyPost::IsPublic).default(false))
.col(enumeration( .col(
enumeration(
SameyPost::Rating, SameyPost::Rating,
Rating::Enum, Rating::Enum,
[ [
@ -28,12 +56,21 @@ impl MigrationTrait for Migration {
Rating::Questionable, Rating::Questionable,
Rating::Explicit, Rating::Explicit,
], ],
)) )
.default(Rating::Unrated.into_iden().to_string()),
)
.col(date_time(SameyPost::UploadedAt)) .col(date_time(SameyPost::UploadedAt))
.col(integer_null(SameyPost::ParentId)) .col(integer_null(SameyPost::ParentId))
.foreign_key( .foreign_key(
ForeignKeyCreateStatement::new() 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) .from(SameyPost::Table, SameyPost::ParentId)
.to(SameyPost::Table, SameyPost::Id) .to(SameyPost::Table, SameyPost::Id)
.on_delete(ForeignKeyAction::SetNull), .on_delete(ForeignKeyAction::SetNull),
@ -52,7 +89,7 @@ impl MigrationTrait for Migration {
.col(integer(SameyPostSource::PostId)) .col(integer(SameyPostSource::PostId))
.foreign_key( .foreign_key(
ForeignKeyCreateStatement::new() ForeignKeyCreateStatement::new()
.name("fk-samey_post_source-samey_post") .name("fk-samey_post_source-samey_post-post_id")
.from(SameyPostSource::Table, SameyPostSource::PostId) .from(SameyPostSource::Table, SameyPostSource::PostId)
.to(SameyPost::Table, SameyPostSource::Id) .to(SameyPost::Table, SameyPostSource::Id)
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
@ -83,14 +120,14 @@ impl MigrationTrait for Migration {
.col(integer(SameyTagPost::PostId)) .col(integer(SameyTagPost::PostId))
.foreign_key( .foreign_key(
ForeignKeyCreateStatement::new() ForeignKeyCreateStatement::new()
.name("fk-samey_tag_post-samey_tag") .name("fk-samey_tag_post-samey_tag-tag_id")
.from(SameyTagPost::Table, SameyTagPost::TagId) .from(SameyTagPost::Table, SameyTagPost::TagId)
.to(SameyTag::Table, SameyTag::Id) .to(SameyTag::Table, SameyTag::Id)
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.foreign_key( .foreign_key(
ForeignKeyCreateStatement::new() ForeignKeyCreateStatement::new()
.name("fk-samey_tag_post-samey_post") .name("fk-samey_tag_post-samey_post-post_id")
.from(SameyTagPost::Table, SameyTagPost::PostId) .from(SameyTagPost::Table, SameyTagPost::PostId)
.to(SameyPost::Table, SameyPost::Id) .to(SameyPost::Table, SameyPost::Id)
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
@ -127,15 +164,44 @@ impl MigrationTrait for Migration {
.drop_table(Table::drop().table(SameyPost::Table).to_owned()) .drop_table(Table::drop().table(SameyPost::Table).to_owned())
.await?; .await?;
manager
.drop_table(Table::drop().table(SameyUser::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SameySession::Table).to_owned())
.await?;
Ok(()) 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)] #[derive(DeriveIden)]
enum SameyPost { enum SameyPost {
#[sea_orm(iden = "samey_post")] #[sea_orm(iden = "samey_post")]
Table, Table,
Id, Id,
UploaderId,
Media, Media,
Width, Width,
Height, Height,

226
src/auth.rs Normal file
View file

@ -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<Option<Self::User>, 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<Self>) -> Result<Option<Self::User>, 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<Backend>;
#[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<Option<Record>> {
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(())
}
}

View file

@ -4,5 +4,7 @@ pub mod prelude;
pub mod samey_post; pub mod samey_post;
pub mod samey_post_source; pub mod samey_post_source;
pub mod samey_session;
pub mod samey_tag; pub mod samey_tag;
pub mod samey_tag_post; pub mod samey_tag_post;
pub mod samey_user;

View file

@ -2,5 +2,7 @@
pub use super::samey_post::Entity as SameyPost; pub use super::samey_post::Entity as SameyPost;
pub use super::samey_post_source::Entity as SameyPostSource; 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::Entity as SameyTag;
pub use super::samey_tag_post::Entity as SameyTagPost; pub use super::samey_tag_post::Entity as SameyTagPost;
pub use super::samey_user::Entity as SameyUser;

View file

@ -7,6 +7,7 @@ use sea_orm::entity::prelude::*;
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub uploader_id: i32,
pub media: String, pub media: String,
pub width: i32, pub width: i32,
pub height: i32, pub height: i32,
@ -35,6 +36,14 @@ pub enum Relation {
SameyPostSource, SameyPostSource,
#[sea_orm(has_many = "super::samey_tag_post::Entity")] #[sea_orm(has_many = "super::samey_tag_post::Entity")]
SameyTagPost, 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<super::samey_post_source::Entity> for Entity { impl Related<super::samey_post_source::Entity> for Entity {
@ -49,4 +58,10 @@ impl Related<super::samey_tag_post::Entity> for Entity {
} }
} }
impl Related<super::samey_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyUser.def()
}
}
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}

View file

@ -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 {}

View file

@ -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<super::samey_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -19,10 +19,14 @@ pub enum SameyError {
Multipart(#[from] axum::extract::multipart::MultipartError), Multipart(#[from] axum::extract::multipart::MultipartError),
#[error("Image error: {0}")] #[error("Image error: {0}")]
Image(#[from] image::ImageError), Image(#[from] image::ImageError),
#[error("Internal error: {0}")]
Other(String),
#[error("Not found")] #[error("Not found")]
NotFound, NotFound,
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Not allowed")]
Forbidden,
#[error("Internal error: {0}")]
Other(String),
} }
impl IntoResponse for SameyError { impl IntoResponse for SameyError {
@ -42,6 +46,10 @@ impl IntoResponse for SameyError {
(StatusCode::BAD_REQUEST, "Invalid request").into_response() (StatusCode::BAD_REQUEST, "Invalid request").into_response()
} }
SameyError::NotFound => (StatusCode::NOT_FOUND, "Resource not found").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(),
} }
} }
} }

View file

@ -1,3 +1,4 @@
pub(crate) mod auth;
pub(crate) mod entities; pub(crate) mod entities;
pub(crate) mod error; pub(crate) mod error;
pub(crate) mod query; pub(crate) mod query;
@ -6,20 +7,26 @@ pub(crate) mod views;
use std::sync::Arc; use std::sync::Arc;
use auth::SessionStorage;
use axum::{ use axum::{
Router, Router,
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
routing::{delete, get, post, put}, 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 tokio::fs;
use tower_http::services::ServeDir; 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; pub use crate::error::SameyError;
use crate::views::{ use crate::views::{
add_post_source, delete_post, edit_post_details, get_full_media, get_media, index, add_post_source, delete_post, edit_post_details, get_full_media, get_media, index, login,
post_details, posts, posts_page, remove_field, search_tags, select_tag, submit_post_details, logout, post_details, posts, posts_page, remove_field, search_tags, select_tag,
upload, view_post, submit_post_details, upload, view_post,
}; };
#[derive(Clone)] #[derive(Clone)]
@ -28,21 +35,45 @@ pub(crate) struct AppState {
db: DatabaseConnection, 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<Router, SameyError> { pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result<Router, SameyError> {
let state = AppState { let state = AppState {
files_dir: Arc::new(files_dir.into()), files_dir: Arc::new(files_dir.into()),
db, db: db.clone(),
}; };
fs::create_dir_all(files_dir).await?; 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() Ok(Router::new()
.route("/login", post(login))
.route("/logout", get(logout))
.route( .route(
"/upload", "/upload",
post(upload).layer(DefaultBodyLimit::max(100_000_000)), post(upload).layer(DefaultBodyLimit::max(100_000_000)),
) )
.route("/search_tags", post(search_tags)) .route("/search_tags", post(search_tags))
.route("/select_tag/{new_tag}", post(select_tag)) .route("/select_tag/{new_tag}", post(select_tag))
.route("/posts/{page}", get(posts_page))
.route("/posts", get(posts)) .route("/posts", get(posts))
.route("/posts/{page}", get(posts_page))
.route("/view/{post_id}", get(view_post)) .route("/view/{post_id}", get(view_post))
.route("/post/{post_id}", delete(delete_post)) .route("/post/{post_id}", delete(delete_post))
.route("/post_details/{post_id}/edit", get(edit_post_details)) .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<Route
.route("/media/{post_id}", get(get_media)) .route("/media/{post_id}", get(get_media))
.route("/", get(index)) .route("/", get(index))
.with_state(state) .with_state(state)
.nest_service("/files", ServeDir::new(files_dir))) .nest_service("/files", ServeDir::new(files_dir))
.layer(auth_layer))
} }

View file

@ -1,12 +1,55 @@
use clap::{Parser, Subcommand};
use migration::{Migrator, MigratorTrait}; use migration::{Migrator, MigratorTrait};
use samey::get_router; use samey::{create_user, get_router};
use sea_orm::Database; use sea_orm::Database;
#[derive(Parser)]
struct Config {
#[command(subcommand)]
command: Option<Commands>,
}
#[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] #[tokio::main]
async fn main() { async fn main() {
let db = Database::connect("sqlite:db.sqlite3?mode=rwc") let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
.await .await
.expect("Unable to connect to database"); .expect("Unable to connect to database");
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) Migrator::up(&db, None)
.await .await
.expect("Unable to apply migrations"); .expect("Unable to apply migrations");
@ -18,4 +61,6 @@ async fn main() {
.expect("Unable to listen to port"); .expect("Unable to listen to port");
println!("Listening on http://localhost:3000"); println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}
}
} }

View file

@ -2,13 +2,16 @@ use std::collections::HashSet;
use migration::{Expr, Query}; use migration::{Expr, Query};
use sea_orm::{ use sea_orm::{
ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, QuerySelect, RelationTrait, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, QueryFilter, QueryOrder,
Select, SelectModel, Selector, QuerySelect, RelationTrait, Select, SelectModel, Selector,
}; };
use crate::entities::{ use crate::{
auth::User,
entities::{
prelude::{SameyPost, SameyTag, SameyTagPost}, prelude::{SameyPost, SameyTag, SameyTagPost},
samey_post, samey_tag, samey_tag_post, samey_post, samey_tag, samey_tag_post,
},
}; };
#[derive(Debug, FromQueryResult)] #[derive(Debug, FromQueryResult)]
@ -18,14 +21,17 @@ pub(crate) struct SearchPost {
pub(crate) tags: String, pub(crate) tags: String,
} }
pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<SearchPost>> { pub(crate) fn search_posts(
tags: Option<&Vec<&str>>,
user: Option<User>,
) -> Selector<SelectModel<SearchPost>> {
let tags: HashSet<String> = match tags { let tags: HashSet<String> = match tags {
Some(tags) if !tags.is_empty() => tags.iter().map(|&tag| tag.to_lowercase()).collect(), Some(tags) if !tags.is_empty() => tags.iter().map(|&tag| tag.to_lowercase()).collect(),
_ => HashSet::new(), _ => HashSet::new(),
}; };
if tags.is_empty() { let mut query = if tags.is_empty() {
let query = SameyPost::find() SameyPost::find()
.select_only() .select_only()
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
@ -39,11 +45,8 @@ pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<Sea
samey_tag_post::Relation::SameyTag.def(), samey_tag_post::Relation::SameyTag.def(),
) )
.group_by(samey_post::Column::Id) .group_by(samey_post::Column::Id)
.order_by_desc(samey_post::Column::Id); .order_by_desc(samey_post::Column::Id)
// println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql); } else {
return query.into_model::<SearchPost>();
};
let tags_count = tags.len() as u32; let tags_count = tags.len() as u32;
let subquery = Query::select() let subquery = Query::select()
.column((SameyPost, samey_post::Column::Id)) .column((SameyPost, samey_post::Column::Id))
@ -62,7 +65,7 @@ pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<Sea
.group_by_col((SameyPost, samey_post::Column::Id)) .group_by_col((SameyPost, samey_post::Column::Id))
.and_having(samey_tag::Column::Id.count().eq(tags_count)) .and_having(samey_tag::Column::Id.count().eq(tags_count))
.to_owned(); .to_owned();
let query = SameyPost::find() SameyPost::find()
.select_only() .select_only()
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
@ -77,8 +80,19 @@ pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<Sea
) )
.filter(samey_post::Column::Id.in_subquery(subquery)) .filter(samey_post::Column::Id.in_subquery(subquery))
.group_by(samey_post::Column::Id) .group_by(samey_post::Column::Id)
.order_by_desc(samey_post::Column::Id); .order_by_desc(samey_post::Column::Id)
// println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql); // println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql);
};
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,
};
query.into_model::<SearchPost>() query.into_model::<SearchPost>()
} }

View file

@ -24,6 +24,7 @@ use tokio::task::spawn_blocking;
use crate::{ use crate::{
AppState, AppState,
auth::{AuthSession, Credentials, User},
entities::{ entities::{
prelude::{SameyPost, SameyPostSource, SameyTag, SameyTagPost}, prelude::{SameyPost, SameyPostSource, SameyTag, SameyTagPost},
samey_post, samey_post_source, samey_tag, samey_tag_post, samey_post, samey_post_source, samey_tag, samey_tag_post,
@ -38,18 +39,58 @@ const MAX_THUMBNAIL_DIMENSION: u32 = 192;
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
struct IndexTemplate {} struct IndexTemplate {
user: Option<User>,
}
pub(crate) async fn index() -> Result<impl IntoResponse, SameyError> { pub(crate) async fn index(auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
Ok(Html(IndexTemplate {}.render()?)) Ok(Html(
IndexTemplate {
user: auth_session.user,
}
.render()?,
))
}
// Auth views
pub(crate) async fn login(
mut auth_session: AuthSession,
Form(credentials): Form<Credentials>,
) -> Result<impl IntoResponse, SameyError> {
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<impl IntoResponse, SameyError> {
auth_session
.logout()
.await
.map_err(|_| SameyError::Other("Logout error".into()))?;
Ok(Redirect::to("/"))
} }
// Post upload view // Post upload view
pub(crate) async fn upload( pub(crate) async fn upload(
State(AppState { db, files_dir }): State<AppState>, State(AppState { db, files_dir }): State<AppState>,
auth_session: AuthSession,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let user = match auth_session.user {
Some(user) => user,
None => return Err(SameyError::Forbidden),
};
let mut upload_tags: Option<Vec<samey_tag::Model>> = None; let mut upload_tags: Option<Vec<samey_tag::Model>> = None;
let mut source_file: Option<String> = None; let mut source_file: Option<String> = None;
let mut thumbnail_file: Option<String> = None; let mut thumbnail_file: Option<String> = None;
@ -147,6 +188,7 @@ pub(crate) async fn upload(
height.map(|h| h.get()), height.map(|h| h.get()),
) { ) {
let uploaded_post = SameyPost::insert(samey_post::ActiveModel { let uploaded_post = SameyPost::insert(samey_post::ActiveModel {
uploader_id: Set(user.id),
media: Set(source_file), media: Set(source_file),
width: Set(width), width: Set(width),
height: Set(height), height: Set(height),
@ -263,13 +305,15 @@ pub(crate) struct PostsQuery {
pub(crate) async fn posts( pub(crate) async fn posts(
state: State<AppState>, state: State<AppState>,
auth_session: AuthSession,
query: Query<PostsQuery>, query: Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
posts_page(state, query, Path(1)).await posts_page(state, auth_session, query, Path(1)).await
} }
pub(crate) async fn posts_page( pub(crate) async fn posts_page(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Query(query): Query<PostsQuery>, Query(query): Query<PostsQuery>,
Path(page): Path<u32>, Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
@ -277,7 +321,7 @@ pub(crate) async fn posts_page(
.tags .tags
.as_ref() .as_ref()
.map(|tags| tags.split_whitespace().collect::<Vec<_>>()); .map(|tags| tags.split_whitespace().collect::<Vec<_>>());
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 page_count = pagination.num_pages().await?;
let posts = pagination.fetch_page(page.saturating_sub(1) as u64).await?; let posts = pagination.fetch_page(page.saturating_sub(1) as u64).await?;
let posts = posts let posts = posts
@ -312,10 +356,12 @@ struct ViewPostTemplate {
post: samey_post::Model, post: samey_post::Model,
tags: Vec<samey_tag::Model>, tags: Vec<samey_tag::Model>,
sources: Vec<samey_post_source::Model>, sources: Vec<samey_post_source::Model>,
can_edit: bool,
} }
pub(crate) async fn view_post( pub(crate) async fn view_post(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let tags = get_tags_for_post(post_id as i32).all(&db).await?; let tags = get_tags_for_post(post_id as i32).all(&db).await?;
@ -330,11 +376,21 @@ pub(crate) async fn view_post(
.await? .await?
.ok_or(SameyError::NotFound)?; .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( Ok(Html(
ViewPostTemplate { ViewPostTemplate {
post, post,
tags, tags,
sources, sources,
can_edit,
} }
.render()?, .render()?,
)) ))
@ -345,23 +401,42 @@ pub(crate) async fn view_post(
struct PostDetailsTemplate { struct PostDetailsTemplate {
post: samey_post::Model, post: samey_post::Model,
sources: Vec<samey_post_source::Model>, sources: Vec<samey_post_source::Model>,
can_edit: bool,
} }
pub(crate) async fn post_details( pub(crate) async fn post_details(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32;
let sources = SameyPostSource::find() let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id)) .filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db) .all(&db)
.await?; .await?;
let post = SameyPost::find_by_id(post_id as i32) let post = SameyPost::find_by_id(post_id)
.one(&db) .one(&db)
.await? .await?
.ok_or(SameyError::NotFound)?; .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)] #[derive(Debug, Deserialize)]
@ -381,14 +456,31 @@ struct SubmitPostDetailsTemplate {
post: samey_post::Model, post: samey_post::Model,
sources: Vec<samey_post_source::Model>, sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>, tags: Vec<samey_tag::Model>,
can_edit: bool,
} }
pub(crate) async fn submit_post_details( pub(crate) async fn submit_post_details(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
Form(body): Form<SubmitPostDetailsForm>, Form(body): Form<SubmitPostDetailsForm>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32; 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() { let title = match body.title.trim() {
title if title.is_empty() => None, title if title.is_empty() => None,
title => Some(title.to_owned()), title => Some(title.to_owned()),
@ -473,6 +565,7 @@ pub(crate) async fn submit_post_details(
post, post,
sources, sources,
tags: upload_tags, tags: upload_tags,
can_edit: true,
} }
.render()?, .render()?,
)) ))
@ -492,8 +585,23 @@ struct EditDetailsTemplate {
pub(crate) async fn edit_post_details( pub(crate) async fn edit_post_details(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
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() let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id)) .filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db) .all(&db)
@ -512,11 +620,6 @@ pub(crate) async fn edit_post_details(
.await? .await?
.join(" "); .join(" ");
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html( Ok(Html(
EditDetailsTemplate { EditDetailsTemplate {
post, post,
@ -554,6 +657,7 @@ struct GetMediaTemplate {
pub(crate) async fn get_media( pub(crate) async fn get_media(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32) let post = SameyPost::find_by_id(post_id as i32)
@ -561,6 +665,15 @@ pub(crate) async fn get_media(
.await? .await?
.ok_or(SameyError::NotFound)?; .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()?)) Ok(Html(GetMediaTemplate { post }.render()?))
} }
@ -572,6 +685,7 @@ struct GetFullMediaTemplate {
pub(crate) async fn get_full_media( pub(crate) async fn get_full_media(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32) let post = SameyPost::find_by_id(post_id as i32)
@ -579,17 +693,37 @@ pub(crate) async fn get_full_media(
.await? .await?
.ok_or(SameyError::NotFound)?; .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()?)) Ok(Html(GetFullMediaTemplate { post }.render()?))
} }
pub(crate) async fn delete_post( pub(crate) async fn delete_post(
State(AppState { db, files_dir }): State<AppState>, State(AppState { db, files_dir }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>, Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> { ) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32) let post = SameyPost::find_by_id(post_id as i32)
.one(&db) .one(&db)
.await? .await?
.ok_or(SameyError::NotFound)?; .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?; SameyPost::delete_by_id(post.id).exec(&db).await?;
tokio::spawn(async move { tokio::spawn(async move {
@ -598,5 +732,5 @@ pub(crate) async fn delete_post(
let _ = std::fs::remove_file(base_path.join(post.thumbnail)); let _ = std::fs::remove_file(base_path.join(post.thumbnail));
}); });
Ok(Redirect::to("/posts/1")) Ok(Redirect::to("/"))
} }

View file

@ -49,6 +49,6 @@
<div> <div>
<button>Submit</button> <button>Submit</button>
<button hx-get="/post_details/{{ post.id }}">Cancel</button> <button hx-get="/post_details/{{ post.id }}">Cancel</button>
<button hx-confirm="Are you sure that you want to delete this post? This can't be undone!" hx-delete="/post/{{ post.id }}" hx-target="body" hx-replace-url="/posts/1">Delete post</button> <button hx-confirm="Are you sure that you want to delete this post? This can't be undone!" hx-delete="/post/{{ post.id }}" hx-target="body" hx-replace-url="/">Delete post</button>
</div> </div>
</div> </div>

View file

@ -27,6 +27,7 @@
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>
</article> </article>
{% if let Some(user) = user %}
<article> <article>
<h2>Upload media</h2> <h2>Upload media</h2>
<form method="post" action="/upload" enctype="multipart/form-data"> <form method="post" action="/upload" enctype="multipart/form-data">
@ -51,6 +52,25 @@
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
</article> </article>
<article>
<a href="/logout">Log out</a>
</article>
{% else %}
<article>
<h2>Log in</h2>
<form method="post" action="/login">
<div>
<label>Username</label>
<input id="username" type="text" name="username" />
</div>
<div>
<label>Password</label>
<input id="password" type="password" name="password" />
</div>
<button type="submit">Submit</button>
</form>
</article>
{% endif %}
</main> </main>
</body> </body>
</html> </html>

View file

@ -44,5 +44,7 @@
<label>Upload date</label> <label>Upload date</label>
{{ post.uploaded_at }} {{ post.uploaded_at }}
</div> </div>
{% if can_edit %}
<button hx-get="/post_details/{{ post.id }}/edit">Edit</button> <button hx-get="/post_details/{{ post.id }}/edit">Edit</button>
{% endif %}
</div> </div>

View file

@ -9,7 +9,7 @@
<body> <body>
<article> <article>
<h2>Search</h2> <h2>Search</h2>
<form method="get" action="/posts/1"> <form method="get" action="/posts">
<input <input
class="tags" class="tags"
type="text" type="text"
@ -29,7 +29,7 @@
<h2>Tags</h2> <h2>Tags</h2>
<ul> <ul>
{% if let Some(tags) = tags %} {% for tag in tags %} {% if let Some(tags) = tags %} {% for tag in tags %}
<li><a href="/posts/1?tags={{ tag }}">{{ tag }}</a></li> <li><a href="/posts?tags={{ tag }}">{{ tag }}</a></li>
{% endfor %} {% endif %} {% endfor %} {% endif %}
</ul> </ul>
</article> </article>