diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/artist.rs | 18 | ||||
| -rw-r--r-- | src/artwork.rs | 26 | ||||
| -rw-r--r-- | src/comment.rs | 4 | ||||
| -rw-r--r-- | src/db/artists.rs | 4 | ||||
| -rw-r--r-- | src/db/artworks.rs | 59 | ||||
| -rw-r--r-- | src/db/comments.rs | 13 | ||||
| -rw-r--r-- | src/db/mod.rs | 6 | ||||
| -rw-r--r-- | src/error.rs | 30 | ||||
| -rw-r--r-- | src/file.rs | 72 | ||||
| -rw-r--r-- | src/lib.rs | 18 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/routes/admin.rs | 2 | ||||
| -rw-r--r-- | src/routes/artworks.rs | 45 | 
13 files changed, 235 insertions, 65 deletions
| 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<i32>, +    artist_id: Option<i32>,      pub handle: String,      pub name: Option<String>,      pub bio: Option<String>, @@ -11,7 +11,17 @@ pub struct Artist {  }  impl Artist { -    pub fn id(&self) -> Option<i32> { -        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<i32> { +        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<i32>, -    /// name of the artwork -    pub title: Option<String>, -    /// description of the artwork -    pub description: Option<String>, -    /// source url of the artwork -    pub url_source: Option<String>, -    /// artwork creation time -    created_at: Option<PrimitiveDateTime>, -    /// id of the artist -    #[sqlx(Flatten)] -    pub artist: Artist, -    /// ids of files -    #[sqlx(Flatten)] -    pub files: Vec<File>, -    // /// TODO: comments in thread, -    // #[sqlx(Flatten)] -    // comments: Vec<Comment>, -} 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<i32>, +    comment_id: Option<i32>,      /// 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<i32> { -        self.id +        self.comment_id      }      pub fn created_at(&self) -> Option<OffsetDateTime> { 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<i32> {          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<i32>, +    /// name of the artwork +    pub title: Option<String>, +    /// description of the artwork +    pub description: Option<String>, +    /// source url of the artwork +    pub url_source: Option<String>, +    /// artwork creation time +    created_at: Option<PrimitiveDateTime>, +    /// id of the artist +    pub artist: Artist, +    /// ids of files +    pub files: Vec<File>, +    // /// TODO: comments in thread, +    // #[sqlx(Flatten)] +    // comments: Vec<Comment>, +} + +impl Artwork { +    pub fn new(title: Option<String>, description: Option<String>, url_source: Option<String>, artist: Artist, files: Vec<File>) -> Self { +        Self { +            artwork_id: None, +            title, +            description, +            url_source, +            created_at: None, +            artist, +            files, +        } +    } +} +  #[derive(Clone)]  pub struct Artworks(Pool<Postgres>); @@ -21,16 +57,17 @@ impl Artworks {      }      pub async fn create(&self, artwork: Artwork) -> Result<i32> { -        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<Vec<Artwork>> {          // 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<File>"  +            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<i32> {          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<Vec<Comment>> { -        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<Postgres>); 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<sqlx::Error> for Error {          }      }  } + +impl From<ParseMultipartError> 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<i32>,  }  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<Self> { +        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<Self> { +        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())), +        } +    } +} @@ -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<u8>) -> 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<R      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()); +        return Ok(render!(templates::admin_dashboard_html, artworks).into_response());      } else {          return Ok(Redirect::see_other("/admin/login").into_response());      } diff --git a/src/routes/artworks.rs b/src/routes/artworks.rs index 556265f..fae7a10 100644 --- a/src/routes/artworks.rs +++ b/src/routes/artworks.rs @@ -1,10 +1,49 @@ -use poem::{handler, web::Path}; +use poem::web::{Data, Multipart, Redirect}; +use poem::IntoResponse; +use poem::{handler, session::Session, web::Path, Response}; + +use crate::artist::Artist; +use crate::db::artworks::Artwork; +use crate::error::Error; +use crate::file::File; +use crate::{Critch, Result};  pub mod comments;  #[handler] -pub async fn post() { -    todo!() +pub async fn post( +    session: &Session, +    mut multipart: Multipart, +    critch: Data<&Critch>, +) -> Result<Response> { +    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] | 
