Authentication
This commit is contained in:
parent
2722c7d40a
commit
a5e3fb2850
19 changed files with 723 additions and 98 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ Sam's small image board. Currently a WIP.
|
|||
|
||||
## TODO
|
||||
|
||||
- [ ] Authentication
|
||||
- [ ] Improve filters
|
||||
- [ ] Pools
|
||||
- [ ] Video support
|
||||
|
|
|
|||
|
|
@ -6,20 +6,48 @@ 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(
|
||||
.col(boolean(SameyPost::IsPublic).default(false))
|
||||
.col(
|
||||
enumeration(
|
||||
SameyPost::Rating,
|
||||
Rating::Enum,
|
||||
[
|
||||
|
|
@ -28,12 +56,21 @@ impl MigrationTrait for Migration {
|
|||
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,
|
||||
|
|
|
|||
226
src/auth.rs
Normal file
226
src/auth.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 {}
|
||||
|
|
|
|||
18
src/entities/samey_session.rs
Normal file
18
src/entities/samey_session.rs
Normal 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 {}
|
||||
28
src/entities/samey_user.rs
Normal file
28
src/entities/samey_user.rs
Normal 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 {}
|
||||
12
src/error.rs
12
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
src/lib.rs
46
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<Router, SameyError> {
|
||||
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<Route
|
|||
.route("/media/{post_id}", get(get_media))
|
||||
.route("/", get(index))
|
||||
.with_state(state)
|
||||
.nest_service("/files", ServeDir::new(files_dir)))
|
||||
.nest_service("/files", ServeDir::new(files_dir))
|
||||
.layer(auth_layer))
|
||||
}
|
||||
|
|
|
|||
47
src/main.rs
47
src/main.rs
|
|
@ -1,12 +1,55 @@
|
|||
use clap::{Parser, Subcommand};
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
use samey::get_router;
|
||||
use samey::{create_user, get_router};
|
||||
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]
|
||||
async fn main() {
|
||||
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
|
||||
.await
|
||||
.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)
|
||||
.await
|
||||
.expect("Unable to apply migrations");
|
||||
|
|
@ -19,3 +62,5 @@ async fn main() {
|
|||
println!("Listening on http://localhost:3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/query.rs
40
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::{
|
||||
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<SelectModel<SearchPost>> {
|
||||
pub(crate) fn search_posts(
|
||||
tags: Option<&Vec<&str>>,
|
||||
user: Option<User>,
|
||||
) -> Selector<SelectModel<SearchPost>> {
|
||||
let tags: HashSet<String> = 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,11 +45,8 @@ pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<Sea
|
|||
samey_tag_post::Relation::SameyTag.def(),
|
||||
)
|
||||
.group_by(samey_post::Column::Id)
|
||||
.order_by_desc(samey_post::Column::Id);
|
||||
// println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql);
|
||||
return query.into_model::<SearchPost>();
|
||||
};
|
||||
|
||||
.order_by_desc(samey_post::Column::Id)
|
||||
} else {
|
||||
let tags_count = tags.len() as u32;
|
||||
let subquery = Query::select()
|
||||
.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))
|
||||
.and_having(samey_tag::Column::Id.count().eq(tags_count))
|
||||
.to_owned();
|
||||
let query = SameyPost::find()
|
||||
SameyPost::find()
|
||||
.select_only()
|
||||
.column(samey_post::Column::Id)
|
||||
.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))
|
||||
.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);
|
||||
};
|
||||
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>()
|
||||
}
|
||||
|
||||
|
|
|
|||
160
src/views.rs
160
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<User>,
|
||||
}
|
||||
|
||||
pub(crate) async fn index() -> Result<impl IntoResponse, SameyError> {
|
||||
Ok(Html(IndexTemplate {}.render()?))
|
||||
pub(crate) async fn index(auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
|
||||
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
|
||||
|
||||
pub(crate) async fn upload(
|
||||
State(AppState { db, files_dir }): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
mut multipart: Multipart,
|
||||
) -> 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 source_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()),
|
||||
) {
|
||||
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<AppState>,
|
||||
auth_session: AuthSession,
|
||||
query: Query<PostsQuery>,
|
||||
) -> 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(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Query(query): Query<PostsQuery>,
|
||||
Path(page): Path<u32>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
|
|
@ -277,7 +321,7 @@ pub(crate) async fn posts_page(
|
|||
.tags
|
||||
.as_ref()
|
||||
.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 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<samey_tag::Model>,
|
||||
sources: Vec<samey_post_source::Model>,
|
||||
can_edit: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn view_post(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
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<samey_post_source::Model>,
|
||||
can_edit: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn post_details(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
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<samey_post_source::Model>,
|
||||
tags: Vec<samey_tag::Model>,
|
||||
can_edit: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn submit_post_details(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
Form(body): Form<SubmitPostDetailsForm>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
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<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> 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()
|
||||
.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<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
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<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, SameyError> {
|
||||
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<AppState>,
|
||||
auth_session: AuthSession,
|
||||
Path(post_id): Path<u32>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("/"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,6 @@
|
|||
<div>
|
||||
<button>Submit</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>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</article>
|
||||
{% if let Some(user) = user %}
|
||||
<article>
|
||||
<h2>Upload media</h2>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
|
|
@ -51,6 +52,25 @@
|
|||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -44,5 +44,7 @@
|
|||
<label>Upload date</label>
|
||||
{{ post.uploaded_at }}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button hx-get="/post_details/{{ post.id }}/edit">Edit</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<body>
|
||||
<article>
|
||||
<h2>Search</h2>
|
||||
<form method="get" action="/posts/1">
|
||||
<form method="get" action="/posts">
|
||||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<h2>Tags</h2>
|
||||
<ul>
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</article>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue