diff --git a/sqlx-data.json b/sqlx-data.json index 8406a25..5616124 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -12,80 +12,43 @@ }, "query": "DELETE FROM sessions WHERE id = $1" }, - "23f1a1f2aa96a5a23880f330d04f5dc695a148f4aa50b8c76378b68944569e44": { + "1d21e65cb21ec57986f154395627de9aefbf77f88c892c5e093787ec53d623d3": { "describe": { "columns": [ { - "name": "author_display_name", + "name": "id", "ordinal": 0, - "type_info": "Varchar" + "type_info": "Uuid" }, { - "name": "author_username", + "name": "slug", "ordinal": 1, "type_info": "Varchar" }, { - "name": "slug", + "name": "name", "ordinal": 2, "type_info": "Varchar" }, { - "name": "title", + "name": "parent", "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "tags", - "ordinal": 5, - "type_info": "VarcharArray" - }, - { - "name": "blocks!: Json>", - "ordinal": 6, - "type_info": "Jsonb" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Timestamp" - }, - { - "name": "modified_at", - "ordinal": 8, - "type_info": "Timestamp" - }, - { - "name": "published", - "ordinal": 9, - "type_info": "Bool" + "type_info": "Uuid" } ], "nullable": [ - null, false, false, false, - true, - false, - false, - false, - true, - false + true ], "parameters": { "Left": [ - "Text", - "Text" + "Uuid" ] } }, - "query": "SELECT\n COALESCE(users.display_name,users.name) as author_display_name,\n users.name as author_username,\n pages.slug,\n pages.title,\n pages.description,\n pages.tags,\n pages.blocks as \"blocks!: Json>\",\n pages.created_at,\n pages.modified_at,\n pages.published\n FROM pages \n JOIN\n sites ON site = sites.id\n JOIN\n users ON author = users.id\n WHERE\n slug = $1\n AND sites.name = $2 \n AND pages.deleted_at IS NULL\n AND sites.deleted_at IS NULL" + "query": "SELECT id, slug, name, parent FROM collections WHERE site = $1" }, "28ec2833283df836e457161f44f0fbc75ed37dcc0ba63eb09bf285aae2f7a380": { "describe": { @@ -158,83 +121,6 @@ }, "query": "SELECT\n sites.name,\n users.name as owner,\n COALESCE(users.display_name,users.name) as owner_display_name,\n title,\n description,\n sites.created_at,\n array_agg(row(collections.slug, collections.name)) as \"collections!: Vec\"\n FROM sites\n JOIN\n users ON sites.owner = users.id\n JOIN\n collections ON collections.site = sites.id\n WHERE\n sites.name = $1\n AND sites.deleted_at IS NULL\n GROUP BY\n sites.name, users.name, users.display_name, title, description, sites.created_at" }, - "320625aa3fac73cb43d6a68592767174558ab1a6923d3574ad629d1a1bd0d4a6": { - "describe": { - "columns": [ - { - "name": "author_display_name", - "ordinal": 0, - "type_info": "Varchar" - }, - { - "name": "author_username", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "title", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "tags", - "ordinal": 5, - "type_info": "VarcharArray" - }, - { - "name": "blocks!: Json>", - "ordinal": 6, - "type_info": "Jsonb" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Timestamp" - }, - { - "name": "modified_at", - "ordinal": 8, - "type_info": "Timestamp" - }, - { - "name": "published", - "ordinal": 9, - "type_info": "Bool" - } - ], - "nullable": [ - null, - false, - false, - false, - true, - false, - false, - false, - true, - false - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Timestamp", - "Int8" - ] - } - }, - "query": "SELECT\n COALESCE(users.display_name,users.name) as author_display_name,\n users.name as author_username,\n pages.slug,\n pages.title,\n pages.description,\n pages.tags,\n pages.blocks as \"blocks!: Json>\",\n pages.created_at,\n pages.modified_at,\n pages.published\n FROM pages\n JOIN\n users ON author = users.id\n WHERE\n site = $1\n AND $2 = ANY(collections)\n AND pages.deleted_at IS NULL\n AND published = true\n AND pages.created_at <= $3\n ORDER BY created_at DESC\n LIMIT $4\n " - }, "35deaad55b84124dcba02c555718fe815003e3f50b1c8ee2fc7e540009fa5aef": { "describe": { "columns": [ @@ -323,6 +209,89 @@ }, "query": "DELETE FROM sessions WHERE expires_at < $1" }, + "7aff3e9becb9da6bfba6d50a364fd56f46b739d6e7a5742e5efe1d19858ee427": { + "describe": { + "columns": [ + { + "name": "author_display_name", + "ordinal": 0, + "type_info": "Varchar" + }, + { + "name": "author_username", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "title", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "tags", + "ordinal": 5, + "type_info": "VarcharArray" + }, + { + "name": "blocks!: Json>", + "ordinal": 6, + "type_info": "Jsonb" + }, + { + "name": "created_at", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "modified_at", + "ordinal": 8, + "type_info": "Timestamp" + }, + { + "name": "published", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "collections", + "ordinal": 10, + "type_info": "UuidArray" + } + ], + "nullable": [ + null, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamp", + "Int8" + ] + } + }, + "query": "SELECT\n COALESCE(users.display_name,users.name) as author_display_name,\n users.name as author_username,\n pages.slug,\n pages.title,\n pages.description,\n pages.tags,\n pages.blocks as \"blocks!: Json>\",\n pages.created_at,\n pages.modified_at,\n pages.published,\n pages.collections\n FROM pages\n JOIN\n users ON author = users.id\n JOIN\n sites ON site = sites.id\n JOIN\n collections ON collections.slug = $2 AND collections.site = sites.id\n WHERE\n sites.name = $1\n AND collections.id = ANY(collections)\n AND pages.deleted_at IS NULL\n AND published = true\n AND pages.created_at <= $3\n ORDER BY created_at DESC\n LIMIT $4\n " + }, "7bdb55b060b5e81b50c1bf86cb117215cba7010c91f8384a397efac06a88507d": { "describe": { "columns": [], @@ -366,6 +335,32 @@ }, "query": "INSERT INTO users ( id, name, password, roles )\n VALUES ( $1,$2,$3,$4 ) RETURNING id, created_at" }, + "8c2ad67b10d926e72efdcf63140c6cc34790aca9a90b52f555e745bbb30faffa": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "owner", + "ordinal": 1, + "type_info": "Uuid" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id, owner FROM sites WHERE name = $1 AND deleted_at IS NULL" + }, "9f37c6f3929ca1bf2a6ec55291d652b216419fd13e27b3d61d23c9cf900c501e": { "describe": { "columns": [ @@ -485,6 +480,87 @@ }, "query": "SELECT\n sessions.id AS session_id,\n sessions.actor AS session_actor,\n sessions.secret,\n sessions.created_at AS session_created_at,\n sessions.expires_at,\n users.id AS user_id,\n users.name,\n users.email,\n users.display_name,\n users.bio,\n users.roles,\n users.created_at AS user_created_at,\n users.modified_at,\n users.deleted_at\n FROM\n sessions\n JOIN\n users ON sessions.actor = users.id\n WHERE\n sessions.id = $1" }, + "cb17620d355a66a591cfae86e63dc293664a85777b39d7d19f2493a7388ecaf6": { + "describe": { + "columns": [ + { + "name": "author_display_name", + "ordinal": 0, + "type_info": "Varchar" + }, + { + "name": "author_username", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "title", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "tags", + "ordinal": 5, + "type_info": "VarcharArray" + }, + { + "name": "blocks!: Json>", + "ordinal": 6, + "type_info": "Jsonb" + }, + { + "name": "created_at", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "modified_at", + "ordinal": 8, + "type_info": "Timestamp" + }, + { + "name": "published", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "collections", + "ordinal": 10, + "type_info": "UuidArray" + } + ], + "nullable": [ + null, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "SELECT\n COALESCE(users.display_name,users.name) as author_display_name,\n users.name as author_username,\n pages.slug,\n pages.title,\n pages.description,\n pages.tags,\n pages.blocks as \"blocks!: Json>\",\n pages.created_at,\n pages.modified_at,\n pages.published,\n pages.collections\n FROM pages \n JOIN\n sites ON site = sites.id\n JOIN\n users ON author = users.id\n WHERE\n slug = $1\n AND sites.name = $2 \n AND pages.deleted_at IS NULL\n AND sites.deleted_at IS NULL" + }, "cde4892e52ef14049256499eb4b0529a3b1cefd8f83ef3dc7cb201ee4a8253a3": { "describe": { "columns": [], @@ -571,26 +647,5 @@ } }, "query": "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" - }, - "f3d0f52ab3c9d7ed46086d21cd88207a75d9f2e2990f3fb721f0e14e1ec52e10": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Uuid" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - } - }, - "query": "SELECT id FROM sites WHERE name = $1 AND owner = $2 AND deleted_at IS NULL" } } \ No newline at end of file diff --git a/src/content/collection.rs b/src/content/collection.rs index 270575e..884f0ce 100644 --- a/src/content/collection.rs +++ b/src/content/collection.rs @@ -25,8 +25,18 @@ pub struct Collection { pub parent: Option, } +#[derive(Serialize)] +pub struct CollectionData { + pub id: Uuid, + pub slug: String, + pub name: String, + pub parent: Option, +} + #[async_trait] pub trait CollectionRepository { + async fn list_collections(&self, site: &Uuid) -> Result, Error>; + /// Create default collections for a site async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error>; } diff --git a/src/content/mod.rs b/src/content/mod.rs index 059a6eb..8b86f0a 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -7,6 +7,9 @@ pub enum Error { #[error("Resource not found")] NotFound, + #[error("No access to resource")] + AccessDenied, + #[error("Resource identifier not available")] IdentifierNotAvailable, diff --git a/src/content/post.rs b/src/content/post.rs index da5f082..19c3d5a 100644 --- a/src/content/post.rs +++ b/src/content/post.rs @@ -118,6 +118,7 @@ pub struct PostWithAuthor { pub created_at: NaiveDateTime, pub modified_at: Option, pub published: bool, + pub collections: Vec, } impl AsCursor for PostWithAuthor { @@ -134,8 +135,8 @@ pub trait PostRepository { /// List posts in a collection async fn list_posts_in_collection( &self, - site: &Uuid, - collection: &Uuid, + site: &str, + collection: &str, cursor: Option, limit: i64, ) -> Result, Error>; diff --git a/src/content/site.rs b/src/content/site.rs index fdab1c4..741be25 100644 --- a/src/content/site.rs +++ b/src/content/site.rs @@ -83,5 +83,5 @@ pub trait SiteRepository { async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>; /// Resolve a site's name to its ID - async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result; + async fn get_site_id_and_owner(&self, name: &str) -> Result<(Uuid, Uuid), Error>; } diff --git a/src/cursor.rs b/src/cursor.rs index 2d20bd1..89564aa 100644 --- a/src/cursor.rs +++ b/src/cursor.rs @@ -1,3 +1,6 @@ +use serde::Serialize; + +#[derive(Serialize)] pub struct CursorList { pub items: Vec, pub next_cursor: Option, diff --git a/src/database/collection.rs b/src/database/collection.rs index 78a2112..fc1980a 100644 --- a/src/database/collection.rs +++ b/src/database/collection.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use uuid::Uuid; use crate::content::{ - collection::{CollectionRepository, DEFAULT_COLLECTIONS}, + collection::{CollectionData, CollectionRepository, DEFAULT_COLLECTIONS}, Error, }; @@ -10,6 +10,16 @@ use super::Database; #[async_trait] impl CollectionRepository for Database { + async fn list_collections(&self, site: &Uuid) -> Result, Error> { + Ok(sqlx::query_as!( + CollectionData, + r#"SELECT id, slug, name, parent FROM collections WHERE site = $1"#, + site + ) + .fetch_all(&self.pool) + .await?) + } + async fn create_default_collections(&self, site: &Uuid) -> Result<(), Error> { let mut tx = self.pool.begin().await?; diff --git a/src/database/post.rs b/src/database/post.rs index 977b23b..fa02925 100644 --- a/src/database/post.rs +++ b/src/database/post.rs @@ -20,8 +20,8 @@ use super::Database; impl PostRepository for Database { async fn list_posts_in_collection( &self, - site: &Uuid, - collection: &Uuid, + site: &str, + collection: &str, from: Option, limit: i64, ) -> Result, Error> { @@ -37,13 +37,18 @@ impl PostRepository for Database { pages.blocks as "blocks!: Json>", pages.created_at, pages.modified_at, - pages.published + pages.published, + pages.collections FROM pages JOIN users ON author = users.id + JOIN + sites ON site = sites.id + JOIN + collections ON collections.slug = $2 AND collections.site = sites.id WHERE - site = $1 - AND $2 = ANY(collections) + sites.name = $1 + AND collections.id = ANY(collections) AND pages.deleted_at IS NULL AND published = true AND pages.created_at <= $3 @@ -78,7 +83,8 @@ impl PostRepository for Database { pages.blocks as "blocks!: Json>", pages.created_at, pages.modified_at, - pages.published + pages.published, + pages.collections FROM pages JOIN sites ON site = sites.id diff --git a/src/database/site.rs b/src/database/site.rs index 3c161b2..01c0889 100644 --- a/src/database/site.rs +++ b/src/database/site.rs @@ -153,11 +153,10 @@ impl SiteRepository for Database { Ok(()) } - async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result { + async fn get_site_id_and_owner(&self, name: &str) -> Result<(Uuid, Uuid), Error> { let record = sqlx::query!( - "SELECT id FROM sites WHERE name = $1 AND owner = $2 AND deleted_at IS NULL", + "SELECT id, owner FROM sites WHERE name = $1 AND deleted_at IS NULL", name, - owner ) .fetch_one(&self.pool) .await @@ -165,6 +164,6 @@ impl SiteRepository for Database { sqlx::Error::RowNotFound => Error::NotFound, _ => e.into(), })?; - Ok(record.id) + Ok((record.id, record.owner)) } } diff --git a/src/http/error.rs b/src/http/error.rs index a86ec8b..7a71874 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -20,6 +20,11 @@ const ERR_NOT_AVAILABLE: ApiError<'static> = ApiError::Client { code: "id-not-available", message: "the chosen identifier is not available", }; +const ERR_UNAUTHORIZED: ApiError<'static> = ApiError::Client { + status: StatusCode::FORBIDDEN, + code: "access-denied", + message: "you are not authorized to perform this action", +}; #[derive(Error, Debug)] pub enum ApiError<'a> { @@ -80,6 +85,7 @@ impl From for ApiError<'_> { match err { content::Error::NotFound => ERR_NOT_FOUND, content::Error::IdentifierNotAvailable => ERR_NOT_AVAILABLE, + content::Error::AccessDenied => ERR_UNAUTHORIZED, content::Error::QueryFailed(err) => err.into(), } } diff --git a/src/routes/collections.rs b/src/routes/collections.rs new file mode 100644 index 0000000..d32aba7 --- /dev/null +++ b/src/routes/collections.rs @@ -0,0 +1,76 @@ +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use chrono::NaiveDateTime; +use serde::Deserialize; +use std::sync::Arc; + +use crate::{ + content::{collection::CollectionRepository, post::PostRepository, site::SiteRepository}, + database::Database, + http::error::ApiError, + state::AppState, +}; + +async fn create_collection(repository: Repo) { + todo!() +} + +async fn get_collection(repository: Repo) { + todo!() +} + +async fn list_collections_for_site( + repository: Repo, + Path(site): Path, +) -> Result> { + let (site_id, _) = repository.get_site_id_and_owner(&site).await?; + + Ok(Json(repository.list_collections(&site_id).await?)) +} + +async fn update_collection(repository: Repo) { + todo!() +} + +async fn delete_collection(repository: Repo) { + todo!() +} + +#[derive(Deserialize)] +struct PaginationQuery { + before: Option, + limit: i64, +} + +async fn list_posts_in_collection( + repository: Repo, + Path((site, slug)): Path<(String, String)>, + query: Query, +) -> Result> { + let results = repository + .list_posts_in_collection(&site, &slug, query.before, query.limit) + .await?; + Ok(Json(results)) +} + +pub fn router() -> Router> { + Router::new() + .route( + "/:site", + get(list_collections_for_site::).post(create_collection::), + ) + .route( + "/:site/:slug", + get(get_collection::) + .put(update_collection::) + .delete(delete_collection::), + ) + .route( + "/:site/:slug/posts", + get(list_posts_in_collection::), + ) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 81842dd..2dd0222 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -5,6 +5,7 @@ use crate::state::AppState; mod admin; mod auth; +mod collections; mod posts; mod sites; @@ -14,4 +15,5 @@ pub fn create_router() -> Router> { .nest("/admin", admin::router()) .nest("/posts", posts::router()) .nest("/sites", sites::router()) + .nest("/collections", collections::router()) } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 26a3340..371fd46 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -53,7 +53,11 @@ async fn create_post( JsonBody(data): JsonBody, ) -> Result> { // Resolve site - let site_id = repository.get_site_id(&site, &user.id).await?; + let (site_id, owner) = repository.get_site_id_and_owner(&site).await?; + + if user.id != owner { + return Err(Error::AccessDenied.into()); + } repository.create_post(&user.id, &site_id, data).await?; Ok(Json(json!({ "ok": true }))) @@ -66,7 +70,11 @@ async fn update_post( JsonBody(data): JsonBody, ) -> Result> { // Resolve site - let site_id = repository.get_site_id(&site, &user.id).await?; + let (site_id, owner) = repository.get_site_id_and_owner(&site).await?; + + if user.id != owner { + return Err(Error::AccessDenied.into()); + } repository .update_post(&user.id, &site_id, &slug, data) @@ -80,7 +88,11 @@ async fn delete_post( RequireUser(user): RequireUser, ) -> Result> { // Resolve site - let site_id = repository.get_site_id(&site, &user.id).await?; + let (site_id, owner) = repository.get_site_id_and_owner(&site).await?; + + if user.id != owner { + return Err(Error::AccessDenied.into()); + } repository .delete_post(&user.id, &site_id, &slug, true)