diff --git a/.gitignore b/.gitignore index ea8c4bf..51124b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/files diff --git a/Cargo.lock b/Cargo.lock index a27419f..b9d61f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1089,6 +1098,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1116,6 +1135,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "version_check", +] + [[package]] name = "nix" version = "0.29.0" @@ -1336,9 +1373,12 @@ dependencies = [ "headers", "http", "http-body-util", + "httpdate", "hyper", "hyper-util", "mime", + "mime_guess", + "multer", "nix", "parking_lot", "percent-encoding", @@ -1353,6 +1393,7 @@ dependencies = [ "serde_urlencoded", "smallvec", "sync_wrapper", + "tempfile", "thiserror", "time", "tokio", @@ -2240,6 +2281,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 1fabb93..9027757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ ructe = { version = "0.17.2", features = ["sass", "mime03"] } [dependencies] mime = "0.3.17" -poem = { version = "3.1.3", features = ["session"] } +poem = { version = "3.1.3", features = ["session", "tempfile", "multipart", "static-files"] } serde = "1.0.215" sqlx = { version = "0.8.2", features = ["uuid", "postgres", "runtime-tokio", "time"] } time = "0.3.36" diff --git a/critch.toml b/critch.toml index 6b578b9..b8789d6 100644 --- a/critch.toml +++ b/critch.toml @@ -1,4 +1,4 @@ admin_password = "clowning" # site_password = "password" -files_dir = "./files" +files_dir = "/home/cel/src/critch/files" database_connection = "postgres://critch:critch@localhost/critch" diff --git a/migrations/20241113160730_critch.sql b/migrations/20241113160730_critch.sql index 131daf3..013fd07 100644 --- a/migrations/20241113160730_critch.sql +++ b/migrations/20241113160730_critch.sql @@ -1,7 +1,7 @@ create extension if not exists "uuid-ossp"; create table artists ( - id integer primary key generated always as identity, + artist_id integer primary key generated always as identity, handle varchar(128) not null unique, name varchar(128), bio text, @@ -9,39 +9,39 @@ create table artists ( ); create table artworks ( - id integer primary key generated always as identity, + artwork_id integer primary key generated always as identity, 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) + foreign key (artist_id) references artists(artist_id) ); create table comments ( - id integer unique not null, + comment_id integer unique not null, text text not null, 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) + primary key (comment_id, artwork_id), + foreign key (artwork_id) references artworks(artwork_id) ); create table comment_relations ( artwork_id integer, - foreign key (artwork_id) references artworks(id), + foreign key (artwork_id) references artworks(artwork_id), in_reply_to_id integer, - foreign key (in_reply_to_id) references comments(id), + foreign key (in_reply_to_id) references comments(comment_id), comment_id integer, - foreign key (comment_id) references comments(id), + foreign key (comment_id) references comments(comment_id), primary key (artwork_id, in_reply_to_id, comment_id) ); create table artwork_files ( - id uuid primary key default gen_random_uuid(), + file_id uuid primary key default gen_random_uuid(), alt_text text, extension varchar(16), artwork_id integer, - foreign key (artwork_id) references artworks(id) + foreign key (artwork_id) references artworks(artwork_id) ); diff --git a/src/artist.rs b/src/artist.rs index 476dad9..0ea9131 100644 --- a/src/artist.rs +++ b/src/artist.rs @@ -1,9 +1,9 @@ use crate::error::Error; use crate::Result; -#[derive(sqlx::FromRow)] +#[derive(sqlx::FromRow, sqlx::Type)] pub struct Artist { - id: Option, + artist_id: Option, pub handle: String, pub name: Option, pub bio: Option, @@ -11,7 +11,17 @@ pub struct Artist { } impl Artist { - pub fn id(&self) -> Option { - self.id + pub fn new(handle: String) -> Self { + Self { + artist_id: None, + handle, + name: None, + bio: None, + site: None, + } + } + + pub fn artist_id(&self) -> Option { + self.artist_id } } diff --git a/src/artwork.rs b/src/artwork.rs index 458fd38..8b13789 100644 --- a/src/artwork.rs +++ b/src/artwork.rs @@ -1,27 +1 @@ -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, - /// name of the artwork - pub title: Option, - /// description of the artwork - pub description: Option, - /// source url of the artwork - pub url_source: Option, - /// artwork creation time - created_at: Option, - /// id of the artist - #[sqlx(Flatten)] - pub artist: Artist, - /// ids of files - #[sqlx(Flatten)] - pub files: Vec, - // /// TODO: comments in thread, - // #[sqlx(Flatten)] - // comments: Vec, -} diff --git a/src/comment.rs b/src/comment.rs index 55c4607..6aa3ee8 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -6,7 +6,7 @@ use crate::Result; #[derive(sqlx::FromRow)] pub struct Comment { /// id of the comment in the thread - id: Option, + comment_id: Option, /// text of the comment pub text: String, /// id of artwork thread comment is in @@ -21,7 +21,7 @@ pub struct Comment { impl Comment { pub fn id(&self) -> Option { - self.id + self.comment_id } pub fn created_at(&self) -> Option { diff --git a/src/db/artists.rs b/src/db/artists.rs index 043f0bd..08a5968 100644 --- a/src/db/artists.rs +++ b/src/db/artists.rs @@ -13,7 +13,7 @@ impl Artists { 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", + "insert into artists (handle, name, bio, site) values ($1, $2, $3, $4) returning artist_id", artist.handle, artist.name, artist.bio, @@ -21,7 +21,7 @@ impl Artists { ) .fetch_one(&self.0) .await? - .id; + .artist_id; Ok(artist_id) } diff --git a/src/db/artworks.rs b/src/db/artworks.rs index 619f42d..0b62d1d 100644 --- a/src/db/artworks.rs +++ b/src/db/artworks.rs @@ -1,13 +1,49 @@ 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; +use time::{OffsetDateTime, PrimitiveDateTime}; + +use crate::{artist::Artist, comment::Comment, file::File}; + +#[derive(sqlx::FromRow)] +pub struct Artwork { + /// artwork id + artwork_id: Option, + /// name of the artwork + pub title: Option, + /// description of the artwork + pub description: Option, + /// source url of the artwork + pub url_source: Option, + /// artwork creation time + created_at: Option, + /// id of the artist + pub artist: Artist, + /// ids of files + pub files: Vec, + // /// TODO: comments in thread, + // #[sqlx(Flatten)] + // comments: Vec, +} + +impl Artwork { + pub fn new(title: Option, description: Option, url_source: Option, artist: Artist, files: Vec) -> Self { + Self { + artwork_id: None, + title, + description, + url_source, + created_at: None, + artist, + files, + } + } +} + #[derive(Clone)] pub struct Artworks(Pool); @@ -21,16 +57,17 @@ impl Artworks { } pub async fn create(&self, artwork: Artwork) -> Result { - let artist_id = if let Some(artist_id) = artwork.artist.id() { + // TODO: efficiency? + let artist_id = if let Some(artist_id) = self.downcast().artists().read_handle(&artwork.artist.handle).await.map(|artist| artist.artist_id()).unwrap_or(artwork.artist.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; + let artwork_id = sqlx::query!("insert into artworks (title, description, url_source, artist_id) values ($1, $2, $3, $4) returning artwork_id", artwork.title, artwork.description, artwork.url_source, artist_id).fetch_one(&self.0).await?.artwork_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(), + "insert into artwork_files (file_id, alt_text, extension, artwork_id) values ($1, $2, $3, $4)", + file.file_id(), file.alt_text, file.extension(), artwork_id @@ -43,8 +80,12 @@ impl Artworks { 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", + Ok(sqlx::query_as!(Artwork, + r#"select artworks.artwork_id, artworks.title, artworks.description, artworks.url_source, artworks.created_at, coalesce(artists.*) as "artist!: Artist", coalesce(array_agg((artwork_files.file_id, artwork_files.alt_text, artwork_files.extension, artwork_files.artwork_id)) filter (where artwork_files.file_id is not null), '{}') as "files!: Vec" + from artworks + left join artists on artworks.artist_id = artists.artist_id + left join artwork_files on artworks.artwork_id = artwork_files.artwork_id + group by artworks.artwork_id, artists.artist_id"#, ) .fetch_all(&self.0) .await?) diff --git a/src/db/comments.rs b/src/db/comments.rs index ec07aa0..3c14852 100644 --- a/src/db/comments.rs +++ b/src/db/comments.rs @@ -13,13 +13,13 @@ impl Comments { 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"#, + r#"insert into comments (text, artwork_id) values ($1, $2) returning comment_id"#, comment.text, comment.artwork_id ) .fetch_one(&self.0) .await? - .id; + .comment_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?; } @@ -35,8 +35,11 @@ impl Comments { } pub async fn read_thread(&self, artwork_id: i32) -> Result> { - Ok(sqlx::query_as("select * from comments") - .fetch_all(&self.0) - .await?) + Ok( + sqlx::query_as("select * from comments where artwork_id = $1") + .bind(artwork_id) + .fetch_all(&self.0) + .await?, + ) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 79e8717..6f794a7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -3,9 +3,9 @@ use artworks::Artworks; use comments::Comments; use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; -mod artists; -mod artworks; -mod comments; +pub mod artists; +pub mod artworks; +pub mod comments; #[derive(Clone)] pub struct Database(Pool); diff --git a/src/error.rs b/src/error.rs index ef8ddd3..2704519 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,9 @@ use std::fmt::Display; -use poem::{error::ResponseError, http::StatusCode}; +use poem::{ + error::{ParseMultipartError, ResponseError}, + http::StatusCode, +}; use sqlx::postgres::PgDatabaseError; #[derive(Debug)] @@ -11,6 +14,11 @@ pub enum Error { DatabaseError(sqlx::Error), NotFound, MissingField, + Unauthorized, + MultipartError, + BadRequest, + UnsupportedFileType(String), + UploadDirectory(String), } impl Display for Error { @@ -22,6 +30,15 @@ impl Display for Error { Error::DatabaseError(error) => write!(f, "database error: {}", error), Error::NotFound => write!(f, "not found"), Error::MissingField => write!(f, "missing field in row"), + Error::Unauthorized => write!(f, "user unauthorized"), + Error::MultipartError => write!(f, "error parsing multipart form data"), + Error::BadRequest => write!(f, "bad request"), + Error::UnsupportedFileType(filetype) => { + write!(f, "unsupported file upload type: {}", filetype) + } + Error::UploadDirectory(directory) => { + write!(f, "invalid uploads directory: {}", directory) + } } } } @@ -37,6 +54,11 @@ impl ResponseError for Error { Error::NotFound => StatusCode::NOT_FOUND, Error::SQLError(_) => StatusCode::INTERNAL_SERVER_ERROR, Error::MissingField => StatusCode::INTERNAL_SERVER_ERROR, + Error::Unauthorized => StatusCode::UNAUTHORIZED, + Error::MultipartError => StatusCode::BAD_REQUEST, + Error::BadRequest => StatusCode::BAD_REQUEST, + Error::UnsupportedFileType(_) => StatusCode::BAD_REQUEST, + Error::UploadDirectory(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -67,3 +89,9 @@ impl From for Error { } } } + +impl From for Error { + fn from(e: ParseMultipartError) -> Self { + Error::MultipartError + } +} diff --git a/src/file.rs b/src/file.rs index 15d457a..2e0dd1e 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,26 +1,34 @@ +use std::str::FromStr; + use uuid::Uuid; -#[derive(sqlx::FromRow)] +use crate::error::Error; +use crate::Result; + +#[derive(sqlx::FromRow, sqlx::Type)] pub struct File { - id: Uuid, + file_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, + pub fn new(content_type: &str) -> Result { + let file_id = Uuid::new_v4(); + let content_type: FileType = FromStr::from_str(content_type)?; + let extension = content_type.extension().to_owned(); + // TODO: check file type really is content-type reported by form. + Ok(Self { + file_id, alt_text: String::new(), extension, artwork_id: None, - } + }) } - pub fn id(&self) -> Uuid { - self.id + pub fn file_id(&self) -> Uuid { + self.file_id } pub fn extension(&self) -> &str { @@ -31,3 +39,49 @@ impl File { self.artwork_id } } + +pub enum FileType { + Audio(String), + Video(String), + Image(String), + // TODO: text types + // Text(String), +} + +impl FileType { + pub fn extension(&self) -> &str { + match self { + FileType::Audio(e) => e, + FileType::Video(e) => e, + FileType::Image(e) => e, + } + } +} + +impl FromStr for FileType { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "mp4" | "video/mp4" => Ok(FileType::Video("mp4".to_owned())), + "mpeg" | "video/mpeg" => Ok(FileType::Video("mpeg".to_owned())), + "webm" | "video/webm" => Ok(FileType::Video("webm".to_owned())), + "ogv" | "video/ogg" => Ok(FileType::Video("ogv".to_owned())), + "mp3" | "audio/mpeg" => Ok(FileType::Audio("mp3".to_owned())), + "weba" | "audio/webm" => Ok(FileType::Audio("weba".to_owned())), + "aac" | "audio/aac" => Ok(FileType::Audio("aac".to_owned())), + "oga" | "audio/ogg" => Ok(FileType::Audio("oga".to_owned())), + "wav" | "audio/wav" => Ok(FileType::Audio("wav".to_owned())), + "webp" | "image/webp" => Ok(FileType::Image("webp".to_owned())), + "apng" | "image/apng" => Ok(FileType::Image("apng".to_owned())), + "avif" | "image/avif" => Ok(FileType::Image("avif".to_owned())), + "bmp" | "image/bmp" => Ok(FileType::Image("bmp".to_owned())), + "gif" | "image/gif" => Ok(FileType::Image("gif".to_owned())), + "jpeg" | "image/jpeg" => Ok(FileType::Image("jpeg".to_owned())), + "png" | "image/png" => Ok(FileType::Image("png".to_owned())), + "svg" | "image/svg+xml" => Ok(FileType::Image("svg".to_owned())), + "tiff" | "image/tiff" => Ok(FileType::Image("tiff".to_owned())), + s => Err(Error::UnsupportedFileType(s.to_owned())), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 166cae4..bf6ec5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ +#![feature(async_closure)] + use config::Config; use db::Database; +use error::Error; +use tokio::{fs::File, io::AsyncWriteExt}; mod artist; mod artwork; @@ -25,6 +29,20 @@ impl Critch { Self { db, config } } + + pub async fn save_file(&self, file_name: &str, file_data: Vec) -> Result<()> { + let upload_dir = self.config.files_dir(); + if upload_dir.is_dir() { + let file_handle = upload_dir.join(file_name); + let mut file = File::create(file_handle).await?; + file.write_all(&file_data).await?; + return Ok(()); + } else { + return Err(Error::UploadDirectory( + self.config.files_dir().to_string_lossy().to_string(), + )); + } + } } include!(concat!(env!("OUT_DIR"), "/templates.rs")); diff --git a/src/main.rs b/src/main.rs index 93ab9d6..e0bf429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use poem::endpoint::StaticFilesEndpoint; use poem::session::{CookieConfig, CookieSession}; use poem::web::cookie::CookieKey; use poem::{delete, post, put, EndpointExt}; @@ -10,6 +11,7 @@ use critch::Critch; #[tokio::main] async fn main() -> Result<(), std::io::Error> { let config = Config::from_file("./critch.toml").unwrap(); + let uploads_dir = config.files_dir().to_owned(); let state = Critch::new(config).await; let cookie_config = CookieConfig::private(CookieKey::generate()); @@ -47,6 +49,7 @@ async fn main() -> Result<(), std::io::Error> { "/artworks/:artwork/comments", post(routes::artworks::comments::post).delete(routes::artworks::comments::delete), ) + .nest("/uploads", StaticFilesEndpoint::new(uploads_dir)) .catch_all_error(routes::error::error) .data(state) .with(cookie_session); diff --git a/src/routes/admin.rs b/src/routes/admin.rs index ccca2de..7ba63ac 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -19,7 +19,7 @@ pub async fn get_dashboard(session: &Session, critch: Data<&Critch>) -> Result, +) -> Result { + if let Some(true) = session.get("is_admin") { + let (mut title, mut handle, mut url_source, mut description, mut files) = + (None, None, None, None, Vec::new()); + while let Some(field) = multipart.next_field().await? { + match field.name() { + Some("title") => title = Some(field.text().await?), + Some("artist") => handle = Some(field.text().await?), + Some("url") => url_source = Some(field.text().await?), + Some("description") => description = Some(field.text().await?), + Some("file") => { + let content_type = field.content_type().ok_or(Error::BadRequest)?.to_owned(); + let file_data = field.bytes().await?; + let file = File::new(&content_type)?; + let file_name = format!("{}.{}", file.file_id(), file.extension()); + critch.save_file(&file_name, file_data).await?; + files.push(file); + } + _ => return Err(Error::BadRequest), + } + } + let artist = Artist::new(handle.ok_or(Error::BadRequest)?); + let artwork = Artwork::new(title, description, url_source, artist, files); + critch.db.artworks().create(artwork).await?; + println!("saved file"); + return Ok(Redirect::see_other("/admin").into_response()); + } else { + return Err(Error::Unauthorized); + } } #[handler] diff --git a/templates/admin_dashboard.rs.html b/templates/admin_dashboard.rs.html index cf7d27c..e8b94a2 100644 --- a/templates/admin_dashboard.rs.html +++ b/templates/admin_dashboard.rs.html @@ -1,8 +1,33 @@ @use super::base_html; +@use crate::db::artworks::Artwork; -@() +@(artworks: Vec) @:base_html({ +
+
+
+
+
+
+
+
+
+ + +
+
    +@for artwork in artworks { +
  • + @if let Some(title) = artwork.title { +

    @title

    + } + @for file in artwork.files { + + } +
  • +} +