implement artwork upload

This commit is contained in:
cel 🌸 2024-11-14 21:43:54 +00:00
parent 469a3ad339
commit 67b54449a1
19 changed files with 325 additions and 79 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
/files

50
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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)
);

View File

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

View File

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

View File

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

View File

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

View File

@ -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?)

View File

@ -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")
Ok(
sqlx::query_as("select * from comments where artwork_id = $1")
.bind(artwork_id)
.fetch_all(&self.0)
.await?)
.await?,
)
}
}

View File

@ -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>);

View File

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

View File

@ -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())),
}
}
}

View File

@ -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"));

View File

@ -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);

View File

@ -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());
}

View File

@ -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]

View File

@ -1,8 +1,33 @@
@use super::base_html;
@use crate::db::artworks::Artwork;
@()
@(artworks: Vec<Artwork>)
@:base_html({
<form action="/artworks" method="post" enctype="multipart/form-data">
<label for="title">title:</label><br>
<input type="text" id="title" name="title"><br>
<label for="artist">artist handle:</label><br>
<input type="text" id="artist" name="artist" required><br>
<label for="url">url source:</label><br>
<input type="text" id="url" name="url"><br>
<label for="description">description:</label><br>
<textarea id="description" name="description"></textarea><br>
<input type="file" name="file" multiple>
<button type="post">post artwork</button>
</form>
<ul>
@for artwork in artworks {
<li>
@if let Some(title) = artwork.title {
<h2>@title</h2>
}
@for file in artwork.files {
<img src="/uploads/@file.file_id().@file.extension()">
}
</li>
}
</ul>
<form action="/admin/logout" method="post">
<button type="logout">log out</button>
</form>