add collections and private posts
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Hamcha 2023-07-13 14:08:26 +02:00
parent f5c7570d9d
commit 203df76d6c
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
18 changed files with 221 additions and 27 deletions

View file

@ -0,0 +1,5 @@
ALTER TABLE pages
DROP COLUMN collections,
DROP COLUMN published;
DROP TABLE collections;

View file

@ -0,0 +1,13 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- noqa: RF05
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
site UUID NOT NULL REFERENCES sites (id) ON DELETE CASCADE,
slug VARCHAR NOT NULL,
name VARCHAR NOT NULL,
parent UUID REFERENCES collections (id) ON DELETE CASCADE
);
ALTER TABLE pages
ADD COLUMN collections UUID [] NOT NULL DEFAULT array[]::UUID[], -- noqa
ADD COLUMN published BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -0,0 +1,4 @@
-- Remove constraint and index (collections_site_slug_unique, collections_site_slug_idx)
ALTER TABLE collections DROP CONSTRAINT collections_site_slug_unique;
DROP INDEX IF EXISTS collections_site_slug_unique;

View file

@ -0,0 +1,7 @@
-- Add index/constraint on site and collection slug
CREATE UNIQUE INDEX collections_site_slug_idx
ON collections (site, slug);
ALTER TABLE collections
ADD CONSTRAINT collections_site_slug_unique UNIQUE
USING INDEX collections_site_slug_idx;

32
src/content/collection.rs Normal file
View file

@ -0,0 +1,32 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use super::Error;
pub const DEFAULT_COLLECTIONS: [(&str, &str); 2] = [("blog", "Blog"), ("pages", "Pages")];
#[derive(Deserialize, Serialize, FromRow)]
pub struct Collection {
/// Collection ID
pub id: Uuid,
/// Site ID
pub site: Uuid,
/// Collection URL name
pub slug: String,
/// Collection display name
pub name: String,
/// Collection parent (eg. blog categories have blog as parent)
pub parent: Option<Uuid>,
}
#[async_trait]
pub trait CollectionRepository {
/// Create default collections for a site
async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error>;
}

View file

@ -1,3 +1,4 @@
pub mod collection;
pub mod post; pub mod post;
pub mod site; pub mod site;

View file

@ -32,6 +32,12 @@ pub struct Post {
/// Post blocks (content) /// Post blocks (content)
pub blocks: Json<Vec<PostBlock>>, pub blocks: Json<Vec<PostBlock>>,
/// Collections the post belongs to
pub collections: Vec<Uuid>,
/// Is the post published?
pub published: bool,
/// Times /// Times
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>, pub modified_at: Option<NaiveDateTime>,
@ -81,6 +87,8 @@ pub struct CreatePostData {
pub description: Option<String>, pub description: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub blocks: Vec<PostBlock>, pub blocks: Vec<PostBlock>,
pub collections: Vec<Uuid>,
pub published: bool,
} }
/// Data to update a new post /// Data to update a new post
@ -91,6 +99,8 @@ pub struct UpdatePostData {
pub description: Option<String>, pub description: Option<String>,
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub blocks: Option<Vec<PostBlock>>, pub blocks: Option<Vec<PostBlock>>,
pub collections: Option<Vec<Uuid>>,
pub published: Option<bool>,
} }
/// Post with site and author dereferenced for convenience /// Post with site and author dereferenced for convenience
@ -106,6 +116,7 @@ pub struct PostWithAuthor {
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>, pub modified_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
pub published: bool,
} }
#[async_trait] #[async_trait]

View file

@ -31,13 +31,20 @@ pub struct Site {
/// More useful version of Site for showing to users /// More useful version of Site for showing to users
#[derive(Serialize)] #[derive(Serialize)]
pub struct SiteWithAuthor { pub struct SiteData {
pub name: String, pub name: String,
pub owner: String, pub owner: String,
pub owner_display_name: Option<String>, pub owner_display_name: Option<String>,
pub title: String, pub title: String,
pub description: Option<String>, pub description: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub collections: Vec<CollectionNameAndSlug>,
}
#[derive(Debug, Serialize)]
pub struct CollectionNameAndSlug {
pub name: String,
pub slug: String,
} }
/// Data required to create a new site /// Data required to create a new site
@ -59,10 +66,10 @@ pub struct UpdateSiteData {
#[async_trait] #[async_trait]
pub trait SiteRepository { pub trait SiteRepository {
/// Retrieve site info from site name/slug /// Retrieve site info from site name/slug
async fn get_site_by_name(&self, name: &str) -> Result<SiteWithAuthor, Error>; async fn get_site_by_name(&self, name: &str) -> Result<SiteData, Error>;
/// Create a new instance of a site and store it /// Create a new instance of a site and store it
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error>; async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<Uuid, Error>;
/// Update an existing site's info /// Update an existing site's info
async fn update_site( async fn update_site(

View file

@ -0,0 +1,31 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::content::{
collection::{CollectionRepository, DEFAULT_COLLECTIONS},
Error,
};
use super::Database;
#[async_trait]
impl CollectionRepository for Database {
async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error> {
let mut tx = self.pool.begin().await?;
for (slug, name) in DEFAULT_COLLECTIONS {
sqlx::query!(
r#"INSERT INTO collections (site, slug, name) VALUES ($1, $2, $3)"#,
site,
slug,
name
)
.execute(&mut tx)
.await?;
}
tx.commit().await?;
Ok(())
}
}

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::state::AppState; use crate::state::AppState;
pub mod collection;
pub mod post; pub mod post;
pub mod site; pub mod site;

View file

@ -31,7 +31,8 @@ impl PostRepository for Database {
pages.blocks as "blocks!: Json<Vec<PostBlock>>", pages.blocks as "blocks!: Json<Vec<PostBlock>>",
pages.created_at, pages.created_at,
pages.modified_at, pages.modified_at,
pages.deleted_at pages.deleted_at,
pages.published
FROM pages FROM pages
JOIN JOIN
sites ON site = sites.id sites ON site = sites.id
@ -61,7 +62,7 @@ impl PostRepository for Database {
data: CreatePostData, data: CreatePostData,
) -> Result<(), Error> { ) -> Result<(), Error> {
sqlx::query!( sqlx::query!(
"INSERT INTO pages (author, site, slug, title, description, tags, blocks) VALUES ($1, $2, $3, $4, $5, $6, $7)", "INSERT INTO pages (author, site, slug, title, description, tags, blocks, collections, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
owner, owner,
site, site,
data.slug, data.slug,
@ -69,6 +70,8 @@ impl PostRepository for Database {
data.description.unwrap_or_else(|| "".to_string()), data.description.unwrap_or_else(|| "".to_string()),
&data.tags, &data.tags,
json!(data.blocks), json!(data.blocks),
&data.collections,
data.published
).execute(&self.pool).await.map_err(|err| match err { ).execute(&self.pool).await.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => { sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
Error::IdentifierNotAvailable Error::IdentifierNotAvailable
@ -86,15 +89,18 @@ impl PostRepository for Database {
data: UpdatePostData, data: UpdatePostData,
) -> Result<(), Error> { ) -> Result<(), Error> {
let result = sqlx::query!( let result = sqlx::query!(
"UPDATE pages SET title = COALESCE($1, title), description = COALESCE($2, description), tags = COALESCE($3, tags), blocks = COALESCE($4, blocks) WHERE author = $5 AND site = $6 AND slug = $7 AND deleted_at IS NULL", "UPDATE pages SET title = COALESCE($1, title), description = COALESCE($2, description), tags = COALESCE($3, tags), blocks = COALESCE($4, blocks), collections = COALESCE($8, collections), published = COALESCE($9, published) WHERE author = $5 AND site = $6 AND slug = $7 AND deleted_at IS NULL",
data.title, data.title,
data.description, data.description,
data.tags.as_deref(), data.tags.as_deref(),
data.blocks.map(|x| json!(x)), data.blocks.map(|x| json!(x)),
owner, owner,
site, site,
slug slug,
).execute(&self.pool).await.map_err(|err| match err { data.collections.as_deref(),
data.published
)
.execute(&self.pool).await.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => { sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
Error::IdentifierNotAvailable Error::IdentifierNotAvailable
} }

View file

@ -1,31 +1,53 @@
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::Postgres;
use uuid::Uuid; use uuid::Uuid;
use crate::content::{ use crate::content::{
site::{CreateSiteData, SiteRepository, SiteWithAuthor, UpdateSiteData}, site::{CollectionNameAndSlug, CreateSiteData, SiteData, SiteRepository, UpdateSiteData},
Error, Error,
}; };
use super::Database; use super::Database;
impl sqlx::Type<Postgres> for CollectionNameAndSlug {
fn type_info() -> sqlx::postgres::PgTypeInfo {
sqlx::postgres::PgTypeInfo::with_name("collection_name_slug")
}
}
impl<'r> sqlx::Decode<'r, Postgres> for CollectionNameAndSlug {
fn decode(
value: sqlx::postgres::PgValueRef<'r>,
) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
let mut decoder = sqlx::postgres::types::PgRecordDecoder::new(value)?;
let slug = decoder.try_decode::<String>()?;
let name = decoder.try_decode::<String>()?;
Ok(Self { name, slug })
}
}
#[async_trait] #[async_trait]
impl SiteRepository for Database { impl SiteRepository for Database {
async fn get_site_by_name(&self, name: &str) -> Result<SiteWithAuthor, Error> { async fn get_site_by_name(&self, name: &str) -> Result<SiteData, Error> {
let site = sqlx::query_as!( let site = sqlx::query!(
SiteWithAuthor,
r#"SELECT r#"SELECT
sites.name, sites.name,
users.name as owner, users.name as owner,
COALESCE(users.display_name,users.name) as owner_display_name, COALESCE(users.display_name,users.name) as owner_display_name,
title, title,
description, description,
sites.created_at sites.created_at,
array_agg(row(collections.slug, collections.name)) as "collections!: Vec<CollectionNameAndSlug>"
FROM sites FROM sites
JOIN JOIN
users ON sites.owner = users.id users ON sites.owner = users.id
JOIN
collections ON collections.site = sites.id
WHERE WHERE
sites.name = $1 sites.name = $1
AND sites.deleted_at IS NULL"#, AND sites.deleted_at IS NULL
GROUP BY
sites.name, users.name, users.display_name, title, description, sites.created_at"#,
name name
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
@ -34,18 +56,26 @@ impl SiteRepository for Database {
sqlx::Error::RowNotFound => Error::NotFound, sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(), _ => e.into(),
})?; })?;
Ok(site) Ok(SiteData {
name: site.name,
owner: site.owner,
owner_display_name: site.owner_display_name,
title: site.title,
description: site.description,
created_at: site.created_at,
collections: site.collections,
})
} }
async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error> { async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<Uuid, Error> {
sqlx::query!( let result = sqlx::query!(
"INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4)", "INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4) RETURNING id",
options.name, options.name,
owner, owner,
options.title, options.title,
options.description, options.description,
) )
.execute(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|err| match err { .map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => { sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => {
@ -53,7 +83,7 @@ impl SiteRepository for Database {
} }
_ => err.into(), _ => err.into(),
})?; })?;
Ok(()) Ok(result.id)
} }
async fn update_site( async fn update_site(

View file

@ -49,6 +49,23 @@ where
} }
} }
pub struct OptionalUser(pub Option<User>);
#[async_trait]
impl<S> FromRequestParts<S> for OptionalUser
where
S: Send + Sync,
{
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match Extension::<User>::from_request_parts(parts, state).await {
Ok(Extension(user)) => Ok(OptionalUser(Some(user))),
_ => Ok(OptionalUser(None)),
}
}
}
pub struct RequireSession(pub Session); pub struct RequireSession(pub Session);
#[async_trait] #[async_trait]

View file

@ -1,8 +1,8 @@
mod auth; mod auth;
mod builtins;
mod content; mod content;
mod database; mod database;
mod http; mod http;
mod roles;
mod routes; mod routes;
mod state; mod state;

View file

@ -8,8 +8,8 @@ use std::sync::Arc;
use crate::{ use crate::{
auth::{hash::random, user::User}, auth::{hash::random, user::User},
builtins::ROLE_SUPERADMIN,
http::error::ApiError, http::error::ApiError,
roles::ROLE_SUPERADMIN,
state::AppState, state::AppState,
}; };

View file

@ -11,17 +11,39 @@ use crate::{
content::{ content::{
post::{CreatePostData, PostRepository, UpdatePostData}, post::{CreatePostData, PostRepository, UpdatePostData},
site::SiteRepository, site::SiteRepository,
Error,
}, },
database::Database, database::Database,
http::{error::ApiError, json::JsonBody, session::RequireUser}, http::{
error::ApiError,
json::JsonBody,
session::{OptionalUser, RequireUser},
},
state::AppState, state::AppState,
}; };
async fn get_post<Repo: PostRepository>( async fn get_post<Repo: PostRepository>(
repository: Repo, repository: Repo,
OptionalUser(user): OptionalUser,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
Ok(Json(repository.get_post_from_url(&site, &slug).await?)) let post = repository.get_post_from_url(&site, &slug).await?;
// If post has been published, show to everyone, otherwise only show to owner/author
if post.published {
return Ok(Json(post));
}
if let Some(user) = user {
// TODO This will need to be changed if we want more users managing the same site
// Also, we should have a better way to match the user!! (mapping func? prolly)
if user.name == post.author_username {
return Ok(Json(post));
}
}
// 404'd!!
Err(Error::NotFound.into())
} }
async fn create_post<Repo: PostRepository + SiteRepository>( async fn create_post<Repo: PostRepository + SiteRepository>(

View file

@ -8,7 +8,10 @@ use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
content::site::{CreateSiteData, SiteRepository, UpdateSiteData}, content::{
collection::CollectionRepository,
site::{CreateSiteData, SiteRepository, UpdateSiteData},
},
database::Database, database::Database,
http::{error::ApiError, json::JsonBody, session::RequireUser}, http::{error::ApiError, json::JsonBody, session::RequireUser},
state::AppState, state::AppState,
@ -21,12 +24,16 @@ async fn get_site<Repo: SiteRepository>(
Ok(Json(repository.get_site_by_name(&name).await?)) Ok(Json(repository.get_site_by_name(&name).await?))
} }
async fn create_site<Repo: SiteRepository>( async fn create_site<Repo: SiteRepository + CollectionRepository>(
repository: Repo, repository: Repo,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
JsonBody(options): JsonBody<CreateSiteData>, JsonBody(options): JsonBody<CreateSiteData>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
repository.create_site(&user.id, options).await?; let id = repository.create_site(&user.id, options).await?;
// Create default content for the new site
repository.create_default_collections(&id).await?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }