diff --git a/Cargo.lock b/Cargo.lock index 6d8e15b..a27419f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,10 +306,13 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" name = "critch" version = "0.1.0" dependencies = [ + "mime", "poem", "ructe", "serde", "sqlx", + "time", + "time-humanize", "tokio", "toml", "uuid", @@ -1798,6 +1801,7 @@ dependencies = [ "smallvec", "sqlformat", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -1882,6 +1886,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "uuid", "whoami", @@ -1921,6 +1926,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "uuid", "whoami", @@ -1945,6 +1951,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "time", "tracing", "url", "uuid", @@ -2058,6 +2065,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-humanize" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e32d019b4f7c100bcd5494e40a27119d45b71fba2b07a4684153129279a4647" + [[package]] name = "time-macros" version = "0.2.18" @@ -2298,6 +2311,9 @@ name = "uuid" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] [[package]] name = "vcpkg" diff --git a/Cargo.toml b/Cargo.toml index 8980e23..1fabb93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ build = "src/build.rs" ructe = { version = "0.17.2", features = ["sass", "mime03"] } [dependencies] +mime = "0.3.17" poem = { version = "3.1.3", features = ["session"] } serde = "1.0.215" -sqlx = { version = "0.8.2", features = ["uuid", "postgres", "runtime-tokio"] } +sqlx = { version = "0.8.2", features = ["uuid", "postgres", "runtime-tokio", "time"] } +time = "0.3.36" +time-humanize = "0.1.3" tokio = { version = "1.41.1", features = ["full"] } toml = { version = "0.8.19", features = ["parse"] } -uuid = "1.11.0" +uuid = { version = "1.11.0", features = ["v4"] } diff --git a/migrations/20241113160730_critch.sql b/migrations/20241113160730_critch.sql index 7e4b19e..131daf3 100644 --- a/migrations/20241113160730_critch.sql +++ b/migrations/20241113160730_critch.sql @@ -2,7 +2,8 @@ create extension if not exists "uuid-ossp"; create table artists ( id integer primary key generated always as identity, - artist_name varchar(128) not null unique, + handle varchar(128) not null unique, + name varchar(128), bio text, site varchar(256) ); @@ -12,6 +13,7 @@ create table artworks ( title varchar(256), description text, url_source varchar(256), + created_at timestamp not null default current_timestamp, artist_id integer not null, comment_number integer not null default 0, foreign key (artist_id) references artists(id) @@ -20,24 +22,26 @@ create table artworks ( create table comments ( id integer unique not null, text text not null, - thread_id integer not null, - primary key (id, thread_id), - foreign key (thread_id) references artworks(id) + artwork_id integer not null, + created_at timestamp not null default current_timestamp, + primary key (id, artwork_id), + foreign key (artwork_id) references artworks(id) ); create table comment_relations ( - thread_id integer, - foreign key (thread_id) references artworks(id), + artwork_id integer, + foreign key (artwork_id) references artworks(id), in_reply_to_id integer, foreign key (in_reply_to_id) references comments(id), comment_id integer, foreign key (comment_id) references comments(id), - primary key (thread_id, in_reply_to_id, comment_id) + primary key (artwork_id, in_reply_to_id, comment_id) ); -create table files ( +create table artwork_files ( id uuid primary key default gen_random_uuid(), alt_text text, + extension varchar(16), artwork_id integer, foreign key (artwork_id) references artworks(id) ); diff --git a/src/artist.rs b/src/artist.rs index 06b90e7..476dad9 100644 --- a/src/artist.rs +++ b/src/artist.rs @@ -1,6 +1,17 @@ +use crate::error::Error; +use crate::Result; + +#[derive(sqlx::FromRow)] pub struct Artist { - id: Option, - name: String, - bio: Option, - site: Option, + id: Option, + pub handle: String, + pub name: Option, + pub bio: Option, + pub site: Option, +} + +impl Artist { + pub fn id(&self) -> Option { + self.id + } } diff --git a/src/artwork.rs b/src/artwork.rs index 78b39af..458fd38 100644 --- a/src/artwork.rs +++ b/src/artwork.rs @@ -1,14 +1,27 @@ +use time::{OffsetDateTime, PrimitiveDateTime}; +use uuid::Uuid; + +use crate::{artist::Artist, comment::Comment, file::File}; + +#[derive(sqlx::FromRow)] pub struct Artwork { /// artwork id - id: Option, + id: Option, /// name of the artwork - title: Option, + pub title: Option, /// description of the artwork - description: Option, + pub description: Option, /// source url of the artwork - url_source: Option, + pub url_source: Option, + /// artwork creation time + created_at: Option, /// id of the artist - artist_id: usize, + #[sqlx(Flatten)] + pub artist: Artist, /// ids of files - files: Vec, + #[sqlx(Flatten)] + pub files: Vec, + // /// TODO: comments in thread, + // #[sqlx(Flatten)] + // comments: Vec, } diff --git a/src/comment.rs b/src/comment.rs index 77b0fc0..55c4607 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -1,12 +1,34 @@ +use time::OffsetDateTime; + +use crate::error::Error; +use crate::Result; + +#[derive(sqlx::FromRow)] pub struct Comment { /// id of the comment in the thread - id: Option, + id: Option, /// text of the comment - text: String, - /// thread comment is in - thread: usize, + pub text: String, + /// id of artwork thread comment is in + pub artwork_id: i32, + /// comment creation time + created_at: Option, /// comments that are mentioned by the comment - in_reply_to: Vec, + pub in_reply_to_ids: Vec, /// comments that mention the comment - mentioned_by: Vec, + mentioned_by_ids: Vec, +} + +impl Comment { + pub fn id(&self) -> Option { + self.id + } + + pub fn created_at(&self) -> Option { + self.created_at + } + + pub fn mentioned_by_ids(&self) -> &Vec { + &self.mentioned_by_ids + } } diff --git a/src/db/artists.rs b/src/db/artists.rs new file mode 100644 index 0000000..043f0bd --- /dev/null +++ b/src/db/artists.rs @@ -0,0 +1,56 @@ +use sqlx::{Pool, Postgres}; + +use crate::artist::Artist; +use crate::Result; + +#[derive(Clone)] +pub struct Artists(Pool); + +impl Artists { + pub fn new(pool: Pool) -> Self { + Self(pool) + } + + pub async fn create(&self, artist: Artist) -> Result { + let artist_id = sqlx::query!( + "insert into artists (handle, name, bio, site) values ($1, $2, $3, $4) returning id", + artist.handle, + artist.name, + artist.bio, + artist.site + ) + .fetch_one(&self.0) + .await? + .id; + Ok(artist_id) + } + + pub async fn read(&self, id: i32) -> Result { + Ok(sqlx::query_as("select * from artists where id = $1") + .bind(id) + .fetch_one(&self.0) + .await?) + } + + pub async fn read_handle(&self, handle: &str) -> Result { + Ok(sqlx::query_as("select * from artists where handle = $1") + .bind(handle) + .fetch_one(&self.0) + .await?) + } + + pub async fn read_all(&self) -> Result> { + Ok(sqlx::query_as("select * from artists") + .fetch_all(&self.0) + .await?) + } + + pub async fn search(&self, query: &str) -> Result> { + Ok( + sqlx::query_as("select * from artists where handle + name like '%$1%'") + .bind(query) + .fetch_all(&self.0) + .await?, + ) + } +} diff --git a/src/db/artworks.rs b/src/db/artworks.rs index 8b13789..619f42d 100644 --- a/src/db/artworks.rs +++ b/src/db/artworks.rs @@ -1 +1,52 @@ +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use crate::artist::Artist; +use crate::artwork::Artwork; +use crate::file::File; +use crate::Result; + +use super::Database; + +#[derive(Clone)] +pub struct Artworks(Pool); + +impl Artworks { + pub fn new(pool: Pool) -> Self { + Self(pool) + } + + pub fn downcast(&self) -> Database { + Database(self.0.clone()) + } + + pub async fn create(&self, artwork: Artwork) -> Result { + let artist_id = if let Some(artist_id) = artwork.artist.id() { + artist_id + } else { + self.downcast().artists().create(artwork.artist).await? + }; + let artwork_id = sqlx::query!("insert into artworks (title, description, url_source, artist_id) values ($1, $2, $3, $4) returning id", artwork.title, artwork.description, artwork.url_source, artist_id).fetch_one(&self.0).await?.id; + for file in artwork.files { + sqlx::query!( + "insert into artwork_files (id, alt_text, extension, artwork_id) values ($1, $2, $3, $4)", + file.id(), + file.alt_text, + file.extension(), + artwork_id + ) + .execute(&self.0) + .await?; + } + Ok(artwork_id) + } + + pub async fn read_all(&self) -> Result> { + // TODO: join comments and files + Ok(sqlx::query_as( + "select * from artworks left join artists on artworks.artist_id = artists.id left join artwork_files on artworks.id = artwork_files.artwork_id group by artworks.id, artists.id, artwork_files.id", + ) + .fetch_all(&self.0) + .await?) + } +} diff --git a/src/db/comments.rs b/src/db/comments.rs new file mode 100644 index 0000000..ec07aa0 --- /dev/null +++ b/src/db/comments.rs @@ -0,0 +1,42 @@ +use sqlx::{Pool, Postgres}; + +use crate::comment::Comment; +use crate::Result; + +#[derive(Clone)] +pub struct Comments(Pool); + +impl Comments { + pub fn new(pool: Pool) -> Self { + Self(pool) + } + + pub async fn create(&self, comment: Comment) -> Result { + let comment_id = sqlx::query!( + r#"insert into comments (text, artwork_id) values ($1, $2) returning id"#, + comment.text, + comment.artwork_id + ) + .fetch_one(&self.0) + .await? + .id; + for in_reply_to_id in comment.in_reply_to_ids { + sqlx::query!("insert into comment_relations (artwork_id, in_reply_to_id, comment_id) values ($1, $2, $3)", comment.artwork_id, in_reply_to_id, comment_id).execute(&self.0).await?; + } + Ok(comment_id) + } + + pub async fn read_all(&self) -> Result> { + // TODO: joins to get in_reply_to_ids and mentioned_by_ids + let comments: Vec = sqlx::query_as("select * from comments") + .fetch_all(&self.0) + .await?; + Ok(comments) + } + + pub async fn read_thread(&self, artwork_id: i32) -> Result> { + Ok(sqlx::query_as("select * from comments") + .fetch_all(&self.0) + .await?) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 97a5b25..79e8717 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,11 @@ +use artists::Artists; +use artworks::Artworks; +use comments::Comments; use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; +mod artists; mod artworks; +mod comments; #[derive(Clone)] pub struct Database(Pool); @@ -17,4 +22,16 @@ impl Database { Self(pool) } + + pub fn artists(&self) -> Artists { + Artists::new(self.0.clone()) + } + + pub fn artworks(&self) -> Artworks { + Artworks::new(self.0.clone()) + } + + pub fn comments(&self) -> Comments { + Comments::new(self.0.clone()) + } } diff --git a/src/error.rs b/src/error.rs index 03cad93..ef8ddd3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,22 +1,46 @@ use std::fmt::Display; +use poem::{error::ResponseError, http::StatusCode}; +use sqlx::postgres::PgDatabaseError; + #[derive(Debug)] pub enum Error { IOError(std::io::Error), TOMLError(toml::de::Error), + SQLError(String), + DatabaseError(sqlx::Error), + NotFound, + MissingField, } impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Error::SQLError(error) => write!(f, "SQL Error: {}", error), Error::IOError(error) => write!(f, "IO Error: {}", error), Error::TOMLError(error) => write!(f, "TOML deserialization error: {}", error), + Error::DatabaseError(error) => write!(f, "database error: {}", error), + Error::NotFound => write!(f, "not found"), + Error::MissingField => write!(f, "missing field in row"), } } } impl std::error::Error for Error {} +impl ResponseError for Error { + fn status(&self) -> poem::http::StatusCode { + match self { + Error::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::TOMLError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::NotFound => StatusCode::NOT_FOUND, + Error::SQLError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::MissingField => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + impl From for Error { fn from(e: std::io::Error) -> Self { Self::IOError(e) @@ -28,3 +52,18 @@ impl From for Error { Self::TOMLError(e) } } + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + match e { + sqlx::Error::Database(database_error) => { + let error = database_error.downcast::(); + match error.code() { + code => Error::SQLError(code.to_string()), + } + } + sqlx::Error::RowNotFound => Error::NotFound, + _ => Self::DatabaseError(e), + } + } +} diff --git a/src/file.rs b/src/file.rs index 8a49839..15d457a 100644 --- a/src/file.rs +++ b/src/file.rs @@ -2,6 +2,32 @@ use uuid::Uuid; #[derive(sqlx::FromRow)] pub struct File { - id: Option, - artwork: usize, + id: Uuid, + pub alt_text: String, + extension: String, + artwork_id: Option, +} + +impl File { + pub fn new(file: std::fs::File, extension: String) -> Self { + let id = Uuid::new_v4(); + Self { + id, + alt_text: String::new(), + extension, + artwork_id: None, + } + } + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn extension(&self) -> &str { + &self.extension + } + + pub fn artwork_id(&self) -> Option { + self.artwork_id + } } diff --git a/src/lib.rs b/src/lib.rs index fc2ab75..166cae4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,3 +26,5 @@ impl Critch { Self { db, config } } } + +include!(concat!(env!("OUT_DIR"), "/templates.rs")); diff --git a/src/main.rs b/src/main.rs index 639ba2d..93ab9d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use poem::session::{CookieConfig, CookieSession}; +use poem::web::cookie::CookieKey; use poem::{delete, post, put, EndpointExt}; use poem::{get, listener::TcpListener, Route, Server}; @@ -10,11 +12,16 @@ async fn main() -> Result<(), std::io::Error> { let config = Config::from_file("./critch.toml").unwrap(); let state = Critch::new(config).await; + let cookie_config = CookieConfig::private(CookieKey::generate()); + let cookie_session = CookieSession::new(cookie_config); + let app = Route::new() + .at("/admin", get(routes::admin::get_dashboard)) .at( - "/admin", + "/admin/login", post(routes::admin::login).get(routes::admin::get_login_form), ) + .at("/admin/logout", post(routes::admin::logout)) .at("/", get(routes::artworks::get)) .at( "/artworks", @@ -40,7 +47,9 @@ async fn main() -> Result<(), std::io::Error> { "/artworks/:artwork/comments", post(routes::artworks::comments::post).delete(routes::artworks::comments::delete), ) - .data(state); + .catch_all_error(routes::error::error) + .data(state) + .with(cookie_session); Server::new(TcpListener::bind("0.0.0.0:3000")) .run(app) diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 98cb954..ccca2de 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,11 +1,50 @@ -use poem::handler; +use poem::{ + handler, + http::StatusCode, + session::Session, + web::{Data, Form, Redirect}, + IntoResponse, Response, +}; +use serde::Deserialize; -#[handler] -pub async fn login() { - todo!() +use crate::{ructe_poem::render, templates, Critch, Result}; + +#[derive(Deserialize)] +struct Login { + password: String, } #[handler] -pub async fn get_login_form() { - todo!() +pub async fn get_dashboard(session: &Session, critch: Data<&Critch>) -> Result { + if let Some(true) = session.get("is_admin") { + let comments = critch.db.comments().read_all().await?; + let artworks = critch.db.artworks().read_all().await?; + return Ok(render!(templates::admin_dashboard_html).into_response()); + } else { + return Ok(Redirect::see_other("/admin/login").into_response()); + } +} + +#[handler] +pub fn login(session: &Session, data: Data<&Critch>, form: Form) -> Response { + if form.password == data.config.admin_password() { + session.set("is_admin", true); + return Redirect::see_other("/admin").into_response(); + } else { + return render!(templates::admin_login_html).into_response(); + } +} + +#[handler] +pub fn logout(session: &Session) -> Response { + session.purge(); + Redirect::see_other("/").into_response() +} + +#[handler] +pub fn get_login_form(session: &Session) -> Response { + if let Some(true) = session.get("is_admin") { + return Redirect::see_other("/admin").into_response(); + }; + render!(templates::admin_login_html).into_response() } diff --git a/src/routes/error.rs b/src/routes/error.rs new file mode 100644 index 0000000..3813998 --- /dev/null +++ b/src/routes/error.rs @@ -0,0 +1,9 @@ +use poem::{IntoResponse, Response}; + +use crate::{ructe_poem::render, templates}; + +pub async fn error(err: poem::Error) -> Response { + let status = err.status().to_string(); + let message = err.to_string(); + render!(templates::error_html, &status, &message).into_response() +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fe8e0d1..0844fb7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod admin; pub mod artists; pub mod artworks; +pub mod error; diff --git a/src/ructe_poem.rs b/src/ructe_poem.rs index fc14048..bb2d913 100644 --- a/src/ructe_poem.rs +++ b/src/ructe_poem.rs @@ -2,11 +2,11 @@ use poem::{http::StatusCode, Body, IntoResponse}; macro_rules! render { ($template:path) => {{ - use $crate::axum_ructe::Render; + use $crate::ructe_poem::Render; Render(|o| $template(o)) }}; ($template:path, $($arg:expr),* $(,)*) => {{ - use $crate::axum_ructe::Render; + use $crate::ructe_poem::Render; Render(move |o| $template(o, $($arg),*)) }} } @@ -25,3 +25,5 @@ impl) -> std::io::Result<()> + Send> IntoResponse for Ren } } } + +pub(crate) use render; diff --git a/templates/admin_dashboard.rs.html b/templates/admin_dashboard.rs.html new file mode 100644 index 0000000..cf7d27c --- /dev/null +++ b/templates/admin_dashboard.rs.html @@ -0,0 +1,10 @@ +@use super::base_html; + +@() + +@:base_html({ +
+ +
+}) + diff --git a/templates/admin_login.rs.html b/templates/admin_login.rs.html new file mode 100644 index 0000000..de31c44 --- /dev/null +++ b/templates/admin_login.rs.html @@ -0,0 +1,12 @@ +@use super::base_html; + +@() + +@:base_html({ +
+ + + +
+}) + diff --git a/templates/base.rs.html b/templates/base.rs.html new file mode 100644 index 0000000..1b8e63b --- /dev/null +++ b/templates/base.rs.html @@ -0,0 +1,19 @@ +@use super::statics::*; + +@(body: Content) + + + + + + + + + pinussy + + + + @:body() + + + diff --git a/templates/error.rs.html b/templates/error.rs.html new file mode 100644 index 0000000..66b6861 --- /dev/null +++ b/templates/error.rs.html @@ -0,0 +1,9 @@ +@use super::base_html; +@use poem::http::StatusCode; + +@(status: &str, message: &str) + +@:base_html({ +

error @status

+

@message

+})