implement artwork upload
This commit is contained in:
parent
469a3ad339
commit
67b54449a1
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
/files
|
||||||
|
|
|
@ -411,6 +411,15 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
@ -1089,6 +1098,16 @@ version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
@ -1116,6 +1135,24 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
|
@ -1336,9 +1373,12 @@ dependencies = [
|
||||||
"headers",
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"httpdate",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"mime",
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"multer",
|
||||||
"nix",
|
"nix",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
@ -1353,6 +1393,7 @@ dependencies = [
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
@ -2240,6 +2281,15 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|
|
@ -11,7 +11,7 @@ ructe = { version = "0.17.2", features = ["sass", "mime03"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mime = "0.3.17"
|
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"
|
serde = "1.0.215"
|
||||||
sqlx = { version = "0.8.2", features = ["uuid", "postgres", "runtime-tokio", "time"] }
|
sqlx = { version = "0.8.2", features = ["uuid", "postgres", "runtime-tokio", "time"] }
|
||||||
time = "0.3.36"
|
time = "0.3.36"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
admin_password = "clowning"
|
admin_password = "clowning"
|
||||||
# site_password = "password"
|
# site_password = "password"
|
||||||
files_dir = "./files"
|
files_dir = "/home/cel/src/critch/files"
|
||||||
database_connection = "postgres://critch:critch@localhost/critch"
|
database_connection = "postgres://critch:critch@localhost/critch"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
create extension if not exists "uuid-ossp";
|
create extension if not exists "uuid-ossp";
|
||||||
|
|
||||||
create table artists (
|
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,
|
handle varchar(128) not null unique,
|
||||||
name varchar(128),
|
name varchar(128),
|
||||||
bio text,
|
bio text,
|
||||||
|
@ -9,39 +9,39 @@ create table artists (
|
||||||
);
|
);
|
||||||
|
|
||||||
create table artworks (
|
create table artworks (
|
||||||
id integer primary key generated always as identity,
|
artwork_id integer primary key generated always as identity,
|
||||||
title varchar(256),
|
title varchar(256),
|
||||||
description text,
|
description text,
|
||||||
url_source varchar(256),
|
url_source varchar(256),
|
||||||
created_at timestamp not null default current_timestamp,
|
created_at timestamp not null default current_timestamp,
|
||||||
artist_id integer not null,
|
artist_id integer not null,
|
||||||
comment_number integer not null default 0,
|
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 (
|
create table comments (
|
||||||
id integer unique not null,
|
comment_id integer unique not null,
|
||||||
text text not null,
|
text text not null,
|
||||||
artwork_id integer not null,
|
artwork_id integer not null,
|
||||||
created_at timestamp not null default current_timestamp,
|
created_at timestamp not null default current_timestamp,
|
||||||
primary key (id, artwork_id),
|
primary key (comment_id, artwork_id),
|
||||||
foreign key (artwork_id) references artworks(id)
|
foreign key (artwork_id) references artworks(artwork_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table comment_relations (
|
create table comment_relations (
|
||||||
artwork_id integer,
|
artwork_id integer,
|
||||||
foreign key (artwork_id) references artworks(id),
|
foreign key (artwork_id) references artworks(artwork_id),
|
||||||
in_reply_to_id integer,
|
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,
|
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)
|
primary key (artwork_id, in_reply_to_id, comment_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table artwork_files (
|
create table artwork_files (
|
||||||
id uuid primary key default gen_random_uuid(),
|
file_id uuid primary key default gen_random_uuid(),
|
||||||
alt_text text,
|
alt_text text,
|
||||||
extension varchar(16),
|
extension varchar(16),
|
||||||
artwork_id integer,
|
artwork_id integer,
|
||||||
foreign key (artwork_id) references artworks(id)
|
foreign key (artwork_id) references artworks(artwork_id)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow, sqlx::Type)]
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
id: Option<i32>,
|
artist_id: Option<i32>,
|
||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
|
@ -11,7 +11,17 @@ pub struct Artist {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Artist {
|
impl Artist {
|
||||||
pub fn id(&self) -> Option<i32> {
|
pub fn new(handle: String) -> Self {
|
||||||
self.id
|
Self {
|
||||||
|
artist_id: None,
|
||||||
|
handle,
|
||||||
|
name: None,
|
||||||
|
bio: None,
|
||||||
|
site: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn artist_id(&self) -> Option<i32> {
|
||||||
|
self.artist_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>,
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ use crate::Result;
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
/// id of the comment in the thread
|
/// id of the comment in the thread
|
||||||
id: Option<i32>,
|
comment_id: Option<i32>,
|
||||||
/// text of the comment
|
/// text of the comment
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// id of artwork thread comment is in
|
/// id of artwork thread comment is in
|
||||||
|
@ -21,7 +21,7 @@ pub struct Comment {
|
||||||
|
|
||||||
impl Comment {
|
impl Comment {
|
||||||
pub fn id(&self) -> Option<i32> {
|
pub fn id(&self) -> Option<i32> {
|
||||||
self.id
|
self.comment_id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn created_at(&self) -> Option<OffsetDateTime> {
|
pub fn created_at(&self) -> Option<OffsetDateTime> {
|
||||||
|
|
|
@ -13,7 +13,7 @@ impl Artists {
|
||||||
|
|
||||||
pub async fn create(&self, artist: Artist) -> Result<i32> {
|
pub async fn create(&self, artist: Artist) -> Result<i32> {
|
||||||
let artist_id = sqlx::query!(
|
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.handle,
|
||||||
artist.name,
|
artist.name,
|
||||||
artist.bio,
|
artist.bio,
|
||||||
|
@ -21,7 +21,7 @@ impl Artists {
|
||||||
)
|
)
|
||||||
.fetch_one(&self.0)
|
.fetch_one(&self.0)
|
||||||
.await?
|
.await?
|
||||||
.id;
|
.artist_id;
|
||||||
Ok(artist_id)
|
Ok(artist_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,49 @@
|
||||||
use sqlx::{Pool, Postgres};
|
use sqlx::{Pool, Postgres};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::artist::Artist;
|
|
||||||
use crate::artwork::Artwork;
|
|
||||||
use crate::file::File;
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
use super::Database;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Artworks(Pool<Postgres>);
|
pub struct Artworks(Pool<Postgres>);
|
||||||
|
|
||||||
|
@ -21,16 +57,17 @@ impl Artworks {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(&self, artwork: Artwork) -> Result<i32> {
|
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
|
artist_id
|
||||||
} else {
|
} else {
|
||||||
self.downcast().artists().create(artwork.artist).await?
|
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 {
|
for file in artwork.files {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"insert into artwork_files (id, alt_text, extension, artwork_id) values ($1, $2, $3, $4)",
|
"insert into artwork_files (file_id, alt_text, extension, artwork_id) values ($1, $2, $3, $4)",
|
||||||
file.id(),
|
file.file_id(),
|
||||||
file.alt_text,
|
file.alt_text,
|
||||||
file.extension(),
|
file.extension(),
|
||||||
artwork_id
|
artwork_id
|
||||||
|
@ -43,8 +80,12 @@ impl Artworks {
|
||||||
|
|
||||||
pub async fn read_all(&self) -> Result<Vec<Artwork>> {
|
pub async fn read_all(&self) -> Result<Vec<Artwork>> {
|
||||||
// TODO: join comments and files
|
// TODO: join comments and files
|
||||||
Ok(sqlx::query_as(
|
Ok(sqlx::query_as!(Artwork,
|
||||||
"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",
|
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)
|
.fetch_all(&self.0)
|
||||||
.await?)
|
.await?)
|
||||||
|
|
|
@ -13,13 +13,13 @@ impl Comments {
|
||||||
|
|
||||||
pub async fn create(&self, comment: Comment) -> Result<i32> {
|
pub async fn create(&self, comment: Comment) -> Result<i32> {
|
||||||
let comment_id = sqlx::query!(
|
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.text,
|
||||||
comment.artwork_id
|
comment.artwork_id
|
||||||
)
|
)
|
||||||
.fetch_one(&self.0)
|
.fetch_one(&self.0)
|
||||||
.await?
|
.await?
|
||||||
.id;
|
.comment_id;
|
||||||
for in_reply_to_id in comment.in_reply_to_ids {
|
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?;
|
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>> {
|
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)
|
.fetch_all(&self.0)
|
||||||
.await?)
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,9 @@ use artworks::Artworks;
|
||||||
use comments::Comments;
|
use comments::Comments;
|
||||||
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
|
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
|
||||||
|
|
||||||
mod artists;
|
pub mod artists;
|
||||||
mod artworks;
|
pub mod artworks;
|
||||||
mod comments;
|
pub mod comments;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Database(Pool<Postgres>);
|
pub struct Database(Pool<Postgres>);
|
||||||
|
|
30
src/error.rs
30
src/error.rs
|
@ -1,6 +1,9 @@
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
use poem::{error::ResponseError, http::StatusCode};
|
use poem::{
|
||||||
|
error::{ParseMultipartError, ResponseError},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
use sqlx::postgres::PgDatabaseError;
|
use sqlx::postgres::PgDatabaseError;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -11,6 +14,11 @@ pub enum Error {
|
||||||
DatabaseError(sqlx::Error),
|
DatabaseError(sqlx::Error),
|
||||||
NotFound,
|
NotFound,
|
||||||
MissingField,
|
MissingField,
|
||||||
|
Unauthorized,
|
||||||
|
MultipartError,
|
||||||
|
BadRequest,
|
||||||
|
UnsupportedFileType(String),
|
||||||
|
UploadDirectory(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Error {
|
impl Display for Error {
|
||||||
|
@ -22,6 +30,15 @@ impl Display for Error {
|
||||||
Error::DatabaseError(error) => write!(f, "database error: {}", error),
|
Error::DatabaseError(error) => write!(f, "database error: {}", error),
|
||||||
Error::NotFound => write!(f, "not found"),
|
Error::NotFound => write!(f, "not found"),
|
||||||
Error::MissingField => write!(f, "missing field in row"),
|
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::NotFound => StatusCode::NOT_FOUND,
|
||||||
Error::SQLError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Error::SQLError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Error::MissingField => 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
72
src/file.rs
72
src/file.rs
|
@ -1,26 +1,34 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
use crate::error::Error;
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, sqlx::Type)]
|
||||||
pub struct File {
|
pub struct File {
|
||||||
id: Uuid,
|
file_id: Uuid,
|
||||||
pub alt_text: String,
|
pub alt_text: String,
|
||||||
extension: String,
|
extension: String,
|
||||||
artwork_id: Option<i32>,
|
artwork_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl File {
|
impl File {
|
||||||
pub fn new(file: std::fs::File, extension: String) -> Self {
|
pub fn new(content_type: &str) -> Result<Self> {
|
||||||
let id = Uuid::new_v4();
|
let file_id = Uuid::new_v4();
|
||||||
Self {
|
let content_type: FileType = FromStr::from_str(content_type)?;
|
||||||
id,
|
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(),
|
alt_text: String::new(),
|
||||||
extension,
|
extension,
|
||||||
artwork_id: None,
|
artwork_id: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> Uuid {
|
pub fn file_id(&self) -> Uuid {
|
||||||
self.id
|
self.file_id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extension(&self) -> &str {
|
pub fn extension(&self) -> &str {
|
||||||
|
@ -31,3 +39,49 @@ impl File {
|
||||||
self.artwork_id
|
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())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
18
src/lib.rs
18
src/lib.rs
|
@ -1,5 +1,9 @@
|
||||||
|
#![feature(async_closure)]
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use db::Database;
|
use db::Database;
|
||||||
|
use error::Error;
|
||||||
|
use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
|
|
||||||
mod artist;
|
mod artist;
|
||||||
mod artwork;
|
mod artwork;
|
||||||
|
@ -25,6 +29,20 @@ impl Critch {
|
||||||
|
|
||||||
Self { db, config }
|
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"));
|
include!(concat!(env!("OUT_DIR"), "/templates.rs"));
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use poem::endpoint::StaticFilesEndpoint;
|
||||||
use poem::session::{CookieConfig, CookieSession};
|
use poem::session::{CookieConfig, CookieSession};
|
||||||
use poem::web::cookie::CookieKey;
|
use poem::web::cookie::CookieKey;
|
||||||
use poem::{delete, post, put, EndpointExt};
|
use poem::{delete, post, put, EndpointExt};
|
||||||
|
@ -10,6 +11,7 @@ use critch::Critch;
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
let config = Config::from_file("./critch.toml").unwrap();
|
let config = Config::from_file("./critch.toml").unwrap();
|
||||||
|
let uploads_dir = config.files_dir().to_owned();
|
||||||
let state = Critch::new(config).await;
|
let state = Critch::new(config).await;
|
||||||
|
|
||||||
let cookie_config = CookieConfig::private(CookieKey::generate());
|
let cookie_config = CookieConfig::private(CookieKey::generate());
|
||||||
|
@ -47,6 +49,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||||
"/artworks/:artwork/comments",
|
"/artworks/:artwork/comments",
|
||||||
post(routes::artworks::comments::post).delete(routes::artworks::comments::delete),
|
post(routes::artworks::comments::post).delete(routes::artworks::comments::delete),
|
||||||
)
|
)
|
||||||
|
.nest("/uploads", StaticFilesEndpoint::new(uploads_dir))
|
||||||
.catch_all_error(routes::error::error)
|
.catch_all_error(routes::error::error)
|
||||||
.data(state)
|
.data(state)
|
||||||
.with(cookie_session);
|
.with(cookie_session);
|
||||||
|
|
|
@ -19,7 +19,7 @@ pub async fn get_dashboard(session: &Session, critch: Data<&Critch>) -> Result<R
|
||||||
if let Some(true) = session.get("is_admin") {
|
if let Some(true) = session.get("is_admin") {
|
||||||
let comments = critch.db.comments().read_all().await?;
|
let comments = critch.db.comments().read_all().await?;
|
||||||
let artworks = critch.db.artworks().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 {
|
} else {
|
||||||
return Ok(Redirect::see_other("/admin/login").into_response());
|
return Ok(Redirect::see_other("/admin/login").into_response());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
pub mod comments;
|
||||||
|
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn post() {
|
pub async fn post(
|
||||||
todo!()
|
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]
|
#[handler]
|
||||||
|
|
|
@ -1,8 +1,33 @@
|
||||||
@use super::base_html;
|
@use super::base_html;
|
||||||
|
@use crate::db::artworks::Artwork;
|
||||||
|
|
||||||
@()
|
@(artworks: Vec<Artwork>)
|
||||||
|
|
||||||
@:base_html({
|
@: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">
|
<form action="/admin/logout" method="post">
|
||||||
<button type="logout">log out</button>
|
<button type="logout">log out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
Loading…
Reference in New Issue