AAAAAAAAAAAAAAAAAAAAAAAAAAA
This commit is contained in:
parent
06e8494c01
commit
8edc1dd15f
13 changed files with 235 additions and 41 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
5
migrations/20230628223219_create-base.down.sql
Normal file
5
migrations/20230628223219_create-base.down.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
DROP TABLE pages;
|
||||
DROP TABLE sites;
|
||||
DROP TABLE audit;
|
||||
DROP TABLE users;
|
||||
DROP TABLE roles;
|
52
migrations/20230628223219_create-base.up.sql
Normal file
52
migrations/20230628223219_create-base.up.sql
Normal 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
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
DROP TABLE pages;
|
|
@ -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
|
||||
);
|
|
@ -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<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)]
|
||||
pub struct Page {
|
||||
/// Page ID
|
||||
pub id: Uuid,
|
||||
|
||||
/// Site ID
|
||||
pub site: Uuid,
|
||||
|
||||
/// Author ID
|
||||
pub author: Uuid,
|
||||
|
||||
|
|
36
src/error.rs
36
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 {
|
||||
(
|
||||
match self {
|
||||
AppError::ServerError(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", self.0),
|
||||
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 {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
InternalError(anyhow!("Database error: {}", value))
|
||||
impl<E> From<E> for AppError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
AppError::ServerError(err.into())
|
||||
}
|
||||
}
|
||||
|
|
29
src/main.rs
29
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<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]
|
||||
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);
|
||||
|
|
29
src/routes/admin.rs
Normal file
29
src/routes/admin.rs
Normal 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
17
src/routes/content.rs
Normal 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
2
src/routes/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod admin;
|
||||
pub mod content;
|
5
src/state.rs
Normal file
5
src/state.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
use sqlx::{Pool, Postgres};
|
||||
|
||||
pub struct AppState {
|
||||
pub database: Pool<Postgres>,
|
||||
}
|
Loading…
Reference in a new issue