AAAAAAAAAAAAAAAAAAAAAAAAAAA

This commit is contained in:
Hamcha 2023-06-29 14:08:59 +02:00
parent 06e8494c01
commit 8edc1dd15f
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
13 changed files with 235 additions and 41 deletions

13
Cargo.lock generated
View file

@ -132,6 +132,18 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "base64" name = "base64"
version = "0.13.1" version = "0.13.1"
@ -655,6 +667,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"axum-macros",
"chrono", "chrono",
"figment", "figment",
"serde", "serde",

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
axum = "0.6" axum = "0.6"
axum-macros = "0.3"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
tokio = { version = "1.28", features = ["full"] } tokio = { version = "1.28", features = ["full"] }

View file

@ -0,0 +1,5 @@
DROP TABLE pages;
DROP TABLE sites;
DROP TABLE audit;
DROP TABLE users;
DROP TABLE roles;

View file

@ -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
);

View file

@ -1 +0,0 @@
DROP TABLE pages;

View file

@ -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
);

View file

@ -3,11 +3,81 @@ use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow}; use sqlx::{types::Json, FromRow};
use uuid::Uuid; 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<String>,
/// Hashed password
pub password: Option<Vec<u8>>,
/// User's chosen displayed name
pub display_name: Option<String>,
/// Biography / User description
pub bio: Option<String>,
/// User roles (as role IDs)
pub roles: Vec<Uuid>,
/// Times
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
#[serde(with = "ts_seconds_option")]
pub modified_at: Option<DateTime<Utc>>,
#[serde(with = "ts_seconds_option")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, FromRow)]
pub struct Role {
/// Role ID
pub id: Uuid,
/// Role scopes (permissions)
pub scopes: Vec<String>,
}
#[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
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
#[serde(with = "ts_seconds_option")]
pub modified_at: Option<DateTime<Utc>>,
#[serde(with = "ts_seconds_option")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, FromRow)] #[derive(Deserialize, Serialize, FromRow)]
pub struct Page { pub struct Page {
/// Page ID /// Page ID
pub id: Uuid, pub id: Uuid,
/// Site ID
pub site: Uuid,
/// Author ID /// Author ID
pub author: Uuid, pub author: Uuid,

View file

@ -1,23 +1,41 @@
use anyhow::anyhow;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, 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 { fn into_response(self) -> Response {
( match self {
StatusCode::INTERNAL_SERVER_ERROR, AppError::ServerError(err) => (
format!("Something went wrong: {}", self.0), StatusCode::INTERNAL_SERVER_ERROR,
) Json(json!({"code":"server-error", "message": err.to_string()})),
.into_response() )
.into_response(),
AppError::ClientError {
status,
code,
message,
} => (status, Json(json!({"code":code, "message": message}))).into_response(),
}
} }
} }
impl From<sqlx::Error> for InternalError { impl<E> From<E> for AppError
fn from(value: sqlx::Error) -> Self { where
InternalError(anyhow!("Database error: {}", value)) E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError::ServerError(err.into())
} }
} }

View file

@ -1,19 +1,22 @@
mod content; mod content;
mod error; mod error;
mod routes;
mod state;
use anyhow::Result; use anyhow::Result;
use axum::{extract::State, routing::get, Router, Server}; use axum::{
use error::InternalError; routing::{get, post},
Router, Server,
};
use figment::{ use figment::{
providers::{Env, Format, Serialized, Toml}, providers::{Env, Format, Serialized, Toml},
Figment, Figment,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; use sqlx::postgres::PgPoolOptions;
use state::AppState;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
use crate::content::Page;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
struct Config { struct Config {
bind: String, bind: String,
@ -29,17 +32,6 @@ impl Default for Config {
} }
} }
struct AppState {
database: Pool<Postgres>,
}
async fn root(State(state): State<Arc<AppState>>) -> Result<String, InternalError> {
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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@ -58,7 +50,10 @@ async fn main() -> Result<()> {
let shared_state = Arc::new(AppState { database: pool }); 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()?; let addr: SocketAddr = config.bind.parse()?;
tracing::debug!("listening on {}", addr); tracing::debug!("listening on {}", addr);

29
src/routes/admin.rs Normal file
View file

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

17
src/routes/content.rs Normal file
View file

@ -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<Arc<AppState>>,
Path((site, slug)): Path<(String, String)>,
) -> Result<String, AppError> {
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)
}

2
src/routes/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod admin;
pub mod content;

5
src/state.rs Normal file
View file

@ -0,0 +1,5 @@
use sqlx::{Pool, Postgres};
pub struct AppState {
pub database: Pool<Postgres>,
}