diff --git a/Cargo.lock b/Cargo.lock index dabbbf8..f741f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,18 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb524613be645939e280b7279f7b017f98cf7f5ef084ec374df373530e73277" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.18", +] + [[package]] name = "base64" version = "0.13.1" @@ -655,6 +667,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-macros", "chrono", "figment", "serde", diff --git a/Cargo.toml b/Cargo.toml index 92d21a8..5283594 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] axum = "0.6" +axum-macros = "0.3" tracing = "0.1" tracing-subscriber = "0.3" tokio = { version = "1.28", features = ["full"] } diff --git a/migrations/20230628223219_create-base.down.sql b/migrations/20230628223219_create-base.down.sql new file mode 100644 index 0000000..81dbd06 --- /dev/null +++ b/migrations/20230628223219_create-base.down.sql @@ -0,0 +1,5 @@ +DROP TABLE pages; +DROP TABLE sites; +DROP TABLE audit; +DROP TABLE users; +DROP TABLE roles; \ No newline at end of file diff --git a/migrations/20230628223219_create-base.up.sql b/migrations/20230628223219_create-base.up.sql new file mode 100644 index 0000000..60ae5e7 --- /dev/null +++ b/migrations/20230628223219_create-base.up.sql @@ -0,0 +1,52 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), + name VARCHAR UNIQUE NOT NULL, + email VARCHAR, + password BYTEA, + display_name VARCHAR, + bio TEXT, + roles UUID[] NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now (), + modified_at TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), + scopes VARCHAR[] NOT NULL +); + +CREATE TABLE sites ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), + owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR UNIQUE NOT NULL, + title VARCHAR NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now (), + modified_at TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE pages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), + site UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + author UUID REFERENCES users(id) ON DELETE SET NULL, + slug VARCHAR NOT NULL, + title VARCHAR NOT NULL, + description TEXT, + tags VARCHAR[] NOT NULL, + blocks JSONB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now (), + modified_at TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE audit ( + actor UUID REFERENCES users(id) ON DELETE SET NULL, + object UUID, + action VARCHAR NOT NULL, + data JSONB, + created_at TIMESTAMP +); \ No newline at end of file diff --git a/migrations/20230628223219_create-page.down.sql b/migrations/20230628223219_create-page.down.sql deleted file mode 100644 index 12b5243..0000000 --- a/migrations/20230628223219_create-page.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE pages; \ No newline at end of file diff --git a/migrations/20230628223219_create-page.up.sql b/migrations/20230628223219_create-page.up.sql deleted file mode 100644 index 4f54c44..0000000 --- a/migrations/20230628223219_create-page.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE pages ( - id UUID PRIMARY KEY, - author UUID NOT NULL, - slug VARCHAR NOT NULL, - title VARCHAR NOT NULL, - description TEXT, - tags VARCHAR[] NOT NULL, - blocks JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - modified_at TIMESTAMPTZ, - deleted_at TIMESTAMPTZ -); \ No newline at end of file diff --git a/src/content.rs b/src/content.rs index d84caeb..288e4dd 100644 --- a/src/content.rs +++ b/src/content.rs @@ -3,11 +3,81 @@ use serde::{Deserialize, Serialize}; use sqlx::{types::Json, FromRow}; use uuid::Uuid; +#[derive(Deserialize, Serialize, FromRow)] +pub struct User { + /// User internal ID + pub id: Uuid, + + /// User name (unique per instance, shows up in URLs) + pub name: String, + + /// User email (for validation/login) + pub email: Option, + + /// Hashed password + pub password: Option>, + + /// User's chosen displayed name + pub display_name: Option, + + /// Biography / User description + pub bio: Option, + + /// User roles (as role IDs) + pub roles: Vec, + + /// Times + #[serde(with = "ts_seconds")] + pub created_at: DateTime, + #[serde(with = "ts_seconds_option")] + pub modified_at: Option>, + #[serde(with = "ts_seconds_option")] + pub deleted_at: Option>, +} + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Role { + /// Role ID + pub id: Uuid, + + /// Role scopes (permissions) + pub scopes: Vec, +} + +#[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, + + /// Times + #[serde(with = "ts_seconds")] + pub created_at: DateTime, + #[serde(with = "ts_seconds_option")] + pub modified_at: Option>, + #[serde(with = "ts_seconds_option")] + pub deleted_at: Option>, +} + #[derive(Deserialize, Serialize, FromRow)] pub struct Page { /// Page ID pub id: Uuid, + /// Site ID + pub site: Uuid, + /// Author ID pub author: Uuid, diff --git a/src/error.rs b/src/error.rs index 74b7bd6..bf94491 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,23 +1,41 @@ -use anyhow::anyhow; use axum::{ http::StatusCode, response::{IntoResponse, Response}, + Json, }; +use serde_json::json; -pub struct InternalError(anyhow::Error); +pub enum AppError { + ServerError(anyhow::Error), + ClientError { + status: StatusCode, + code: String, + message: String, + }, +} -impl IntoResponse for InternalError { +impl IntoResponse for AppError { fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {}", self.0), - ) - .into_response() + match self { + AppError::ServerError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"code":"server-error", "message": err.to_string()})), + ) + .into_response(), + AppError::ClientError { + status, + code, + message, + } => (status, Json(json!({"code":code, "message": message}))).into_response(), + } } } -impl From for InternalError { - fn from(value: sqlx::Error) -> Self { - InternalError(anyhow!("Database error: {}", value)) +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + AppError::ServerError(err.into()) } } diff --git a/src/main.rs b/src/main.rs index c9242dd..b6db33b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,22 @@ mod content; mod error; +mod routes; +mod state; use anyhow::Result; -use axum::{extract::State, routing::get, Router, Server}; -use error::InternalError; +use axum::{ + routing::{get, post}, + Router, Server, +}; use figment::{ providers::{Env, Format, Serialized, Toml}, Figment, }; use serde::{Deserialize, Serialize}; -use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; +use sqlx::postgres::PgPoolOptions; +use state::AppState; use std::{net::SocketAddr, sync::Arc}; -use crate::content::Page; - #[derive(Deserialize, Serialize)] struct Config { bind: String, @@ -29,17 +32,6 @@ impl Default for Config { } } -struct AppState { - database: Pool, -} - -async fn root(State(state): State>) -> Result { - let select_query: sqlx::query::QueryAs<'_, _, Page, _> = - sqlx::query_as::<_, Page>("SELECT * FROM pages"); - let page: Page = select_query.fetch_one(&state.database).await?; - Ok(page.title) -} - #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt::init(); @@ -58,7 +50,10 @@ async fn main() -> Result<()> { let shared_state = Arc::new(AppState { database: pool }); - let app = Router::new().route("/", get(root)).with_state(shared_state); + let app = Router::new() + .route("/pages/:site/:slug", get(routes::content::page)) + .route("/admin/bootstrap", post(routes::admin::bootstrap)) + .with_state(shared_state); let addr: SocketAddr = config.bind.parse()?; tracing::debug!("listening on {}", addr); diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 0000000..e139989 --- /dev/null +++ b/src/routes/admin.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; + +use crate::error::AppError; +use crate::state::AppState; + +pub async fn bootstrap(State(state): State>) -> Result<(), AppError> { + // Only allow this request if the user db is completely empty! + let empty = sqlx::query!( + "SELECT CASE WHEN EXISTS(SELECT 1 FROM users) THEN false ELSE true END AS empty;" + ) + .map(|row| row.empty.unwrap_or(true)) + .fetch_one(&state.database) + .await?; + + match empty { + false => Err(AppError::ClientError { + status: StatusCode::BAD_REQUEST, + code: "already-setup".to_string(), + message: "The instance was already bootstrapped".to_string(), + }), + true => { + //todo add user + Ok(()) + } + } +} diff --git a/src/routes/content.rs b/src/routes/content.rs new file mode 100644 index 0000000..940ce1d --- /dev/null +++ b/src/routes/content.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use crate::{content::Page, error::AppError, state::AppState}; +use axum::extract::{Path, State}; + +pub async fn page( + State(state): State>, + Path((site, slug)): Path<(String, String)>, +) -> Result { + 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?; + Ok(page.title) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..9eebbe2 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod admin; +pub mod content; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..79dd374 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,5 @@ +use sqlx::{Pool, Postgres}; + +pub struct AppState { + pub database: Pool, +}