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",
|
"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",
|
||||||
|
|
|
@ -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"] }
|
||||||
|
|
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 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,
|
||||||
|
|
||||||
|
|
36
src/error.rs
36
src/error.rs
|
@ -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 {
|
||||||
|
AppError::ServerError(err) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
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 {
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
29
src/main.rs
29
src/main.rs
|
@ -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
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