database work

This commit is contained in:
cel 🌸 2024-11-14 17:59:21 +00:00
parent b7a2265e9b
commit 469a3ad339
22 changed files with 450 additions and 38 deletions

16
Cargo.lock generated
View File

@ -306,10 +306,13 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
name = "critch" name = "critch"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"mime",
"poem", "poem",
"ructe", "ructe",
"serde", "serde",
"sqlx", "sqlx",
"time",
"time-humanize",
"tokio", "tokio",
"toml", "toml",
"uuid", "uuid",
@ -1798,6 +1801,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlformat", "sqlformat",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -1882,6 +1886,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@ -1921,6 +1926,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@ -1945,6 +1951,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"time",
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
@ -2058,6 +2065,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-humanize"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e32d019b4f7c100bcd5494e40a27119d45b71fba2b07a4684153129279a4647"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.18" version = "0.2.18"
@ -2298,6 +2311,9 @@ name = "uuid"
version = "1.11.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
dependencies = [
"getrandom",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"

View File

@ -10,9 +10,12 @@ build = "src/build.rs"
ructe = { version = "0.17.2", features = ["sass", "mime03"] } ructe = { version = "0.17.2", features = ["sass", "mime03"] }
[dependencies] [dependencies]
mime = "0.3.17"
poem = { version = "3.1.3", features = ["session"] } poem = { version = "3.1.3", features = ["session"] }
serde = "1.0.215" 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"] } tokio = { version = "1.41.1", features = ["full"] }
toml = { version = "0.8.19", features = ["parse"] } toml = { version = "0.8.19", features = ["parse"] }
uuid = "1.11.0" uuid = { version = "1.11.0", features = ["v4"] }

View File

@ -2,7 +2,8 @@ create extension if not exists "uuid-ossp";
create table artists ( create table artists (
id integer primary key generated always as identity, 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, bio text,
site varchar(256) site varchar(256)
); );
@ -12,6 +13,7 @@ create table artworks (
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,
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(id)
@ -20,24 +22,26 @@ create table artworks (
create table comments ( create table comments (
id integer unique not null, id integer unique not null,
text text not null, text text not null,
thread_id integer not null, artwork_id integer not null,
primary key (id, thread_id), created_at timestamp not null default current_timestamp,
foreign key (thread_id) references artworks(id) primary key (id, artwork_id),
foreign key (artwork_id) references artworks(id)
); );
create table comment_relations ( create table comment_relations (
thread_id integer, artwork_id integer,
foreign key (thread_id) references artworks(id), foreign key (artwork_id) references artworks(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(id),
comment_id integer, comment_id integer,
foreign key (comment_id) references comments(id), 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(), id uuid primary key default gen_random_uuid(),
alt_text text, alt_text text,
extension varchar(16),
artwork_id integer, artwork_id integer,
foreign key (artwork_id) references artworks(id) foreign key (artwork_id) references artworks(id)
); );

View File

@ -1,6 +1,17 @@
use crate::error::Error;
use crate::Result;
#[derive(sqlx::FromRow)]
pub struct Artist { pub struct Artist {
id: Option<usize>, id: Option<i32>,
name: String, pub handle: String,
bio: Option<String>, pub name: Option<String>,
site: Option<String>, pub bio: Option<String>,
pub site: Option<String>,
}
impl Artist {
pub fn id(&self) -> Option<i32> {
self.id
}
} }

View File

@ -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 { pub struct Artwork {
/// artwork id /// artwork id
id: Option<usize>, id: Option<i32>,
/// name of the artwork /// name of the artwork
title: Option<String>, pub title: Option<String>,
/// description of the artwork /// description of the artwork
description: Option<String>, pub description: Option<String>,
/// source url of the artwork /// source url of the artwork
url_source: Option<String>, pub url_source: Option<String>,
/// artwork creation time
created_at: Option<PrimitiveDateTime>,
/// id of the artist /// id of the artist
artist_id: usize, #[sqlx(Flatten)]
pub artist: Artist,
/// ids of files /// ids of files
files: Vec<usize>, #[sqlx(Flatten)]
pub files: Vec<File>,
// /// TODO: comments in thread,
// #[sqlx(Flatten)]
// comments: Vec<Comment>,
} }

View File

@ -1,12 +1,34 @@
use time::OffsetDateTime;
use crate::error::Error;
use crate::Result;
#[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<usize>, id: Option<i32>,
/// text of the comment /// text of the comment
text: String, pub text: String,
/// thread comment is in /// id of artwork thread comment is in
thread: usize, pub artwork_id: i32,
/// comment creation time
created_at: Option<OffsetDateTime>,
/// comments that are mentioned by the comment /// comments that are mentioned by the comment
in_reply_to: Vec<usize>, pub in_reply_to_ids: Vec<i32>,
/// comments that mention the comment /// comments that mention the comment
mentioned_by: Vec<usize>, mentioned_by_ids: Vec<i32>,
}
impl Comment {
pub fn id(&self) -> Option<i32> {
self.id
}
pub fn created_at(&self) -> Option<OffsetDateTime> {
self.created_at
}
pub fn mentioned_by_ids(&self) -> &Vec<i32> {
&self.mentioned_by_ids
}
} }

56
src/db/artists.rs Normal file
View File

@ -0,0 +1,56 @@
use sqlx::{Pool, Postgres};
use crate::artist::Artist;
use crate::Result;
#[derive(Clone)]
pub struct Artists(Pool<Postgres>);
impl Artists {
pub fn new(pool: Pool<Postgres>) -> Self {
Self(pool)
}
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",
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<Artist> {
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<Artist> {
Ok(sqlx::query_as("select * from artists where handle = $1")
.bind(handle)
.fetch_one(&self.0)
.await?)
}
pub async fn read_all(&self) -> Result<Vec<Artist>> {
Ok(sqlx::query_as("select * from artists")
.fetch_all(&self.0)
.await?)
}
pub async fn search(&self, query: &str) -> Result<Vec<Artist>> {
Ok(
sqlx::query_as("select * from artists where handle + name like '%$1%'")
.bind(query)
.fetch_all(&self.0)
.await?,
)
}
}

View File

@ -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<Postgres>);
impl Artworks {
pub fn new(pool: Pool<Postgres>) -> Self {
Self(pool)
}
pub fn downcast(&self) -> Database {
Database(self.0.clone())
}
pub async fn create(&self, artwork: Artwork) -> Result<i32> {
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<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",
)
.fetch_all(&self.0)
.await?)
}
}

42
src/db/comments.rs Normal file
View File

@ -0,0 +1,42 @@
use sqlx::{Pool, Postgres};
use crate::comment::Comment;
use crate::Result;
#[derive(Clone)]
pub struct Comments(Pool<Postgres>);
impl Comments {
pub fn new(pool: Pool<Postgres>) -> Self {
Self(pool)
}
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"#,
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<Vec<Comment>> {
// TODO: joins to get in_reply_to_ids and mentioned_by_ids
let comments: Vec<Comment> = sqlx::query_as("select * from comments")
.fetch_all(&self.0)
.await?;
Ok(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?)
}
}

View File

@ -1,6 +1,11 @@
use artists::Artists;
use artworks::Artworks;
use comments::Comments;
use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
mod artists;
mod artworks; mod artworks;
mod comments;
#[derive(Clone)] #[derive(Clone)]
pub struct Database(Pool<Postgres>); pub struct Database(Pool<Postgres>);
@ -17,4 +22,16 @@ impl Database {
Self(pool) 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())
}
} }

View File

@ -1,22 +1,46 @@
use std::fmt::Display; use std::fmt::Display;
use poem::{error::ResponseError, http::StatusCode};
use sqlx::postgres::PgDatabaseError;
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
IOError(std::io::Error), IOError(std::io::Error),
TOMLError(toml::de::Error), TOMLError(toml::de::Error),
SQLError(String),
DatabaseError(sqlx::Error),
NotFound,
MissingField,
} }
impl Display for Error { impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Error::SQLError(error) => write!(f, "SQL Error: {}", error),
Error::IOError(error) => write!(f, "IO Error: {}", error), Error::IOError(error) => write!(f, "IO Error: {}", error),
Error::TOMLError(error) => write!(f, "TOML deserialization 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 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<std::io::Error> for Error { impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self { fn from(e: std::io::Error) -> Self {
Self::IOError(e) Self::IOError(e)
@ -28,3 +52,18 @@ impl From<toml::de::Error> for Error {
Self::TOMLError(e) Self::TOMLError(e)
} }
} }
impl From<sqlx::Error> for Error {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::Database(database_error) => {
let error = database_error.downcast::<PgDatabaseError>();
match error.code() {
code => Error::SQLError(code.to_string()),
}
}
sqlx::Error::RowNotFound => Error::NotFound,
_ => Self::DatabaseError(e),
}
}
}

View File

@ -2,6 +2,32 @@ use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub struct File { pub struct File {
id: Option<Uuid>, id: Uuid,
artwork: usize, 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,
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<i32> {
self.artwork_id
}
} }

View File

@ -26,3 +26,5 @@ impl Critch {
Self { db, config } Self { db, config }
} }
} }
include!(concat!(env!("OUT_DIR"), "/templates.rs"));

View File

@ -1,3 +1,5 @@
use poem::session::{CookieConfig, CookieSession};
use poem::web::cookie::CookieKey;
use poem::{delete, post, put, EndpointExt}; use poem::{delete, post, put, EndpointExt};
use poem::{get, listener::TcpListener, Route, Server}; 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 config = Config::from_file("./critch.toml").unwrap();
let state = Critch::new(config).await; let state = Critch::new(config).await;
let cookie_config = CookieConfig::private(CookieKey::generate());
let cookie_session = CookieSession::new(cookie_config);
let app = Route::new() let app = Route::new()
.at("/admin", get(routes::admin::get_dashboard))
.at( .at(
"/admin", "/admin/login",
post(routes::admin::login).get(routes::admin::get_login_form), post(routes::admin::login).get(routes::admin::get_login_form),
) )
.at("/admin/logout", post(routes::admin::logout))
.at("/", get(routes::artworks::get)) .at("/", get(routes::artworks::get))
.at( .at(
"/artworks", "/artworks",
@ -40,7 +47,9 @@ 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),
) )
.data(state); .catch_all_error(routes::error::error)
.data(state)
.with(cookie_session);
Server::new(TcpListener::bind("0.0.0.0:3000")) Server::new(TcpListener::bind("0.0.0.0:3000"))
.run(app) .run(app)

View File

@ -1,11 +1,50 @@
use poem::handler; use poem::{
handler,
http::StatusCode,
session::Session,
web::{Data, Form, Redirect},
IntoResponse, Response,
};
use serde::Deserialize;
#[handler] use crate::{ructe_poem::render, templates, Critch, Result};
pub async fn login() {
todo!() #[derive(Deserialize)]
struct Login {
password: String,
} }
#[handler] #[handler]
pub async fn get_login_form() { pub async fn get_dashboard(session: &Session, critch: Data<&Critch>) -> Result<Response> {
todo!() 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<Login>) -> 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()
} }

9
src/routes/error.rs Normal file
View File

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

View File

@ -1,3 +1,4 @@
pub mod admin; pub mod admin;
pub mod artists; pub mod artists;
pub mod artworks; pub mod artworks;
pub mod error;

View File

@ -2,11 +2,11 @@ use poem::{http::StatusCode, Body, IntoResponse};
macro_rules! render { macro_rules! render {
($template:path) => {{ ($template:path) => {{
use $crate::axum_ructe::Render; use $crate::ructe_poem::Render;
Render(|o| $template(o)) Render(|o| $template(o))
}}; }};
($template:path, $($arg:expr),* $(,)*) => {{ ($template:path, $($arg:expr),* $(,)*) => {{
use $crate::axum_ructe::Render; use $crate::ructe_poem::Render;
Render(move |o| $template(o, $($arg),*)) Render(move |o| $template(o, $($arg),*))
}} }}
} }
@ -25,3 +25,5 @@ impl<T: FnOnce(&mut Vec<u8>) -> std::io::Result<()> + Send> IntoResponse for Ren
} }
} }
} }
pub(crate) use render;

View File

@ -0,0 +1,10 @@
@use super::base_html;
@()
@:base_html({
<form action="/admin/logout" method="post">
<button type="logout">log out</button>
</form>
})

View File

@ -0,0 +1,12 @@
@use super::base_html;
@()
@:base_html({
<form action="/admin/login" method="post">
<label for="password">admin password:</label>
<input type="text" id="password" name="password" required="true" />
<button type="submit">log in</button>
</form>
})

19
templates/base.rs.html Normal file
View File

@ -0,0 +1,19 @@
@use super::statics::*;
@(body: Content)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" href="/static/style.css" />
<title>pinussy</title>
</head>
<body>
@:body()
</body>
</html>

9
templates/error.rs.html Normal file
View File

@ -0,0 +1,9 @@
@use super::base_html;
@use poem::http::StatusCode;
@(status: &str, message: &str)
@:base_html({
<h1>error @status</h1>
<h2>@message</h2>
})