kept you waiting huh? (I'm sorry i forgor)
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Hamcha 2023-07-06 20:08:55 +02:00
parent 18c9be898a
commit 58bf506240
Signed by: hamcha
GPG Key ID: 1669C533B8CF6D89
20 changed files with 342 additions and 121 deletions

1
Cargo.lock generated
View File

@ -761,6 +761,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"axum",
"axum-macros",
"chrono",

View File

@ -20,6 +20,7 @@ anyhow = "1.0"
argon2 = { version = "0.5", features = ["std", "alloc"] }
url = "2.4"
thiserror = "1.0"
async-trait = "0.1"
[profile.dev.package.sqlx-macros]
opt-level = 3

View File

@ -1,4 +1,3 @@
pub mod hash;
pub mod http;
pub mod session;
pub mod user;

View File

@ -137,13 +137,6 @@ impl Session {
))
}
pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, ApiError<'static>> {
match User::get_id(pool, self.actor).await? {
Some(user) => Ok(user),
None => Err(USER_NOT_FOUND),
}
}
pub async fn destroy(self: Self, pool: &Pool<Postgres>) -> Result<()> {
sqlx::query!("DELETE FROM sessions WHERE id = $1", self.id)
.execute(pool)

View File

@ -76,13 +76,6 @@ impl User {
})
}
pub async fn get_id(pool: &Pool<Postgres>, id: Uuid) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?)
}
pub async fn find(pool: &Pool<Postgres>, name: &str) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM users WHERE name = $1")
.bind(name)

14
src/content/mod.rs Normal file
View File

@ -0,0 +1,14 @@
pub mod page;
pub mod site;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Resource not found")]
NotFound,
#[error("Resource identifier not available")]
IdentifierNotAvailable,
#[error("Database error: {0}")]
DatabaseError(#[from] sqlx::Error),
}

View File

@ -1,30 +1,10 @@
use chrono::{serde::*, DateTime, NaiveDateTime, Utc};
use async_trait::async_trait;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow};
use uuid::Uuid;
#[derive(Deserialize, Serialize, FromRow)]
pub struct Site {
/// Site internal ID
pub id: Uuid,
/// Site owner (user)
pub owner: Uuid,
/// Site name (unique per instance, shows up in URLs)
pub name: String,
/// Site's displayed name
pub title: String,
/// Site description (like a user's bio)
pub description: Option<String>,
/// Times
pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>,
}
use super::Error;
#[derive(Deserialize, Serialize, FromRow)]
pub struct Page {
@ -58,6 +38,11 @@ pub struct Page {
pub deleted_at: Option<NaiveDateTime>,
}
#[async_trait]
pub trait PageRepository {
async fn get_page_from_url(&self, site: &str, slug: &str) -> Result<Page, Error>;
}
#[derive(Deserialize, Serialize)]
#[serde(tag = "kind")]
pub enum PageBlock {

75
src/content/site.rs Normal file
View File

@ -0,0 +1,75 @@
use async_trait::async_trait;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use super::Error;
#[derive(Deserialize, Serialize, FromRow)]
pub struct Site {
/// Site internal ID
pub id: Uuid,
/// Site owner (user)
pub owner: Uuid,
/// Site name (unique per instance, shows up in URLs)
pub name: String,
/// Site's displayed name
pub title: String,
/// Site description (like a user's bio)
pub description: Option<String>,
/// Times
pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>,
}
/// More useful version of Site for showing to users
#[derive(Serialize)]
pub struct APISite {
pub name: String,
pub owner: String,
pub title: String,
pub description: Option<String>,
pub created_at: NaiveDateTime,
}
/// Data required to create a new site
#[derive(Deserialize)]
pub struct CreateSiteOptions {
pub name: String,
pub title: String,
}
/// Data required to update a site's info
#[derive(Deserialize)]
pub struct UpdateSiteOptions {
pub name: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
#[async_trait]
pub trait SiteRepository {
/// Retrieve site info from site name/slug
async fn get_site_by_name(&self, name: &str) -> Result<APISite, Error>;
/// Create a new instance of a site and store it
async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error>;
/// Update an existing site's info
async fn update_site(
&self,
owner: &Uuid,
name: &str,
options: &UpdateSiteOptions,
) -> Result<(), Error>;
/// Delete a site
async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>;
}

19
src/database/mod.rs Normal file
View File

@ -0,0 +1,19 @@
use sqlx::PgPool;
use std::sync::Arc;
use crate::state::AppState;
pub mod page;
pub mod site;
pub struct Repository {
pool: PgPool,
}
impl From<&Arc<AppState>> for Repository {
fn from(state: &Arc<AppState>) -> Self {
Repository {
pool: state.database.clone(),
}
}
}

26
src/database/page.rs Normal file
View File

@ -0,0 +1,26 @@
use async_trait::async_trait;
use crate::content::{
self,
page::{Page, PageRepository},
};
use super::Repository;
#[async_trait]
impl PageRepository for Repository {
async fn get_page_from_url(&self, site: &str, slug: &str) -> Result<Page, content::Error> {
let page_query = sqlx::query_as(
"SELECT pages.* FROM pages JOIN sites ON site = sites.id WHERE slug = $1 AND name = $2 AND pages.deleted_at IS NULL AND sites.deleted_at IS NULL")
.bind(slug)
.bind(site);
let page: Page = page_query
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => content::Error::NotFound,
_ => e.into(),
})?;
Ok(page)
}
}

98
src/database/site.rs Normal file
View File

@ -0,0 +1,98 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::content::{
site::{APISite, CreateSiteOptions, SiteRepository, UpdateSiteOptions},
Error,
};
use super::Repository;
#[async_trait]
impl SiteRepository for Repository {
async fn get_site_by_name(&self, name: &str) -> Result<APISite, Error> {
let site = sqlx::query_as!(
APISite,
"SELECT sites.name, users.name as owner, title, description, sites.created_at FROM sites JOIN users ON sites.owner = users.id WHERE sites.name = $1 AND sites.deleted_at IS NULL",
name
).fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(),
})?;
Ok(site)
}
async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)",
options.name,
owner,
options.title,
)
.execute(&self.pool)
.await
.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => {
Error::IdentifierNotAvailable
}
_ => err.into(),
})?;
Ok(())
}
async fn update_site(
&self,
owner: &Uuid,
name: &str,
options: &UpdateSiteOptions,
) -> Result<(), Error> {
let result = sqlx::query!(
"UPDATE sites SET name = COALESCE($1, name), title = COALESCE($2, title), description = COALESCE($3, description), modified_at = NOW() WHERE name = $4 AND owner = $5 AND deleted_at IS NULL",
options.name,
options.title,
options.description,
name,
owner,
).execute(&self.pool).await.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => {
Error::IdentifierNotAvailable
}
_ => err.into(),
})?;
if result.rows_affected() == 0 {
return Err(Error::NotFound.into());
}
Ok(())
}
async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error> {
let result = if soft_delete {
sqlx::query!(
// soft delete
"UPDATE sites SET deleted_at = NOW() WHERE name = $1 AND owner = $2 AND deleted_at IS NULL",
name, owner
)
.execute(&self.pool)
.await?
} else {
sqlx::query!(
// hard delete
"DELETE FROM sites WHERE name = $1 AND owner = $2",
name,
owner,
)
.execute(&self.pool)
.await?
};
if result.rows_affected() == 0 {
return Err(Error::NotFound.into());
}
Ok(())
}
}

View File

@ -7,12 +7,19 @@ use axum::{
use serde_json::json;
use thiserror::Error;
use crate::content;
// Generic errors
pub const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError {
const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError {
status: StatusCode::NOT_FOUND,
code: "not-found",
message: "resource not found",
};
const ERR_NOT_AVAILABLE: ApiError<'static> = ApiError::ClientError {
status: StatusCode::CONFLICT,
code: "id-not-available",
message: "the chosen identifier is not available",
};
#[derive(Error, Debug)]
pub enum ApiError<'a> {
@ -67,3 +74,13 @@ impl IntoResponse for ApiError<'_> {
}
}
}
impl From<content::Error> for ApiError<'_> {
fn from(err: content::Error) -> Self {
match err {
content::Error::NotFound => ERR_NOT_FOUND,
content::Error::IdentifierNotAvailable => ERR_NOT_AVAILABLE,
content::Error::DatabaseError(err) => err.into(),
}
}
}

View File

@ -1,2 +1,4 @@
pub mod error;
pub mod extract;
pub mod json;
pub mod repository;
pub mod session;

20
src/http/repository.rs Normal file
View File

@ -0,0 +1,20 @@
use std::sync::Arc;
use async_trait::async_trait;
use axum::{extract::FromRequestParts, http::request::Parts};
use crate::{database::Repository, state::AppState};
use super::error::ApiError;
#[async_trait]
impl FromRequestParts<Arc<AppState>> for Repository {
type Rejection = ApiError<'static>;
async fn from_request_parts(
_parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
Ok(state.into())
}
}

View File

@ -16,9 +16,11 @@ use cookie::Cookie;
use std::sync::Arc;
use uuid::Uuid;
use crate::{http::error::ApiError, state::AppState};
use super::{session::Session, user::User};
use crate::{
auth::{session::Session, user::User},
http::error::ApiError,
state::AppState,
};
pub const INVALID_SESSION: ApiError = ApiError::ClientError {
status: StatusCode::UNAUTHORIZED,

View File

@ -1,11 +1,12 @@
mod auth;
mod content;
mod database;
mod http;
mod roles;
mod routes;
mod state;
use crate::{auth::http::refresh_sessions, state::Config};
use crate::{http::session::refresh_sessions, state::Config};
use anyhow::Result;
use axum::{middleware, Server};
use figment::{

View File

@ -11,13 +11,12 @@ use serde_json::json;
use std::sync::Arc;
use crate::{
auth::{
hash::verify,
http::{RequireSession, RequireUser},
session::Session,
user::User,
auth::{hash::verify, session::Session, user::User},
http::{
error::ApiError,
json::JsonBody,
session::{RequireSession, RequireUser},
},
http::{error::ApiError, extract::JsonBody},
state::AppState,
};

View File

@ -1,36 +1,35 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::get,
routing::{get, post},
Json, Router,
};
use std::sync::Arc;
use crate::{
content::Page,
http::error::{ApiError, ERR_NOT_FOUND},
content::page::PageRepository,
database::Repository,
http::{error::ApiError, session::RequireUser},
state::AppState,
};
async fn get_page(
State(state): State<Arc<AppState>>,
repository: Repository,
Path((site, slug)): Path<(String, String)>,
) -> Result<impl IntoResponse, ApiError<'static>> {
let page_query = sqlx::query_as(
"SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2",
)
.bind(slug)
.bind(site);
let page: Page = page_query
.fetch_one(&state.database)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => ERR_NOT_FOUND,
_ => e.into(),
})?;
Ok(Json(page))
Ok(Json(repository.get_page_from_url(&site, &slug).await?))
}
async fn create_page(
State(state): State<Arc<AppState>>,
Path(site): Path<String>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, ApiError<'static>> {
Ok(Json("todo"))
}
pub fn router() -> Router<Arc<AppState>> {
Router::new().route("/:site/:slug", get(get_page))
Router::new()
.route("/:site", post(create_page))
.route("/:site/:slug", get(get_page))
}

View File

@ -1,79 +1,56 @@
use axum::{
extract::{Path, State},
http::StatusCode,
extract::Path,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use crate::{
auth::http::RequireUser,
http::{
error::{ApiError, ERR_NOT_FOUND},
extract::JsonBody,
},
content::site::{CreateSiteOptions, SiteRepository, UpdateSiteOptions},
database::Repository,
http::{error::ApiError, json::JsonBody, session::RequireUser},
state::AppState,
};
async fn get_site(
State(state): State<Arc<AppState>>,
repository: Repository,
Path(name): Path<String>,
) -> Result<impl IntoResponse, ApiError<'static>> {
let site = sqlx::query!(
"SELECT sites.id, sites.name, users.name as owner, title, description, sites.created_at FROM sites JOIN users ON sites.owner = users.id WHERE sites.name = $1 ",
name
).fetch_one(&state.database)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => ERR_NOT_FOUND,
_ => e.into(),
})?;
Ok(Json(json!({
"name": site.name,
"owner": site.owner,
"title": site.title,
"description": site.description,
"created_at": site.created_at,
})))
}
#[derive(Deserialize)]
struct CreateSiteRequest {
name: String,
title: String,
Ok(Json(repository.get_site_by_name(&name).await?))
}
async fn create_site(
State(state): State<Arc<AppState>>,
repository: Repository,
RequireUser(user): RequireUser,
JsonBody(payload): JsonBody<CreateSiteRequest>,
JsonBody(options): JsonBody<CreateSiteOptions>,
) -> Result<impl IntoResponse, ApiError<'static>> {
sqlx::query!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)",
payload.name,
user.id,
payload.title,
)
.execute(&state.database)
.await
.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => {
ApiError::ClientError {
status: StatusCode::CONFLICT,
code: "name-not-available",
message: "the chosen name is not available",
}
}
_ => err.into(),
})?;
Ok(Json(json!({"ok": true, "url": "test"})))
repository.create_site(&user.id, &options).await?;
Ok(Json(json!({"ok": true})))
}
async fn update_site(
repository: Repository,
Path(name): Path<String>,
RequireUser(user): RequireUser,
JsonBody(options): JsonBody<UpdateSiteOptions>,
) -> Result<impl IntoResponse, ApiError<'static>> {
repository.update_site(&user.id, &name, &options).await?;
Ok(Json(json!({"ok": true})))
}
async fn delete_site(
repository: Repository,
Path(name): Path<String>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, ApiError<'static>> {
repository.delete_site(&user.id, &name, true).await?;
Ok(Json(json!({"ok": true})))
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", post(create_site))
.route("/:name", get(get_site))
.route("/:name", get(get_site).put(update_site).delete(delete_site))
}