renders a table

This commit is contained in:
Brent Schroeter 2025-05-13 00:02:33 -07:00
parent 4afe1df9b9
commit ae4b4707d9
28 changed files with 1044 additions and 558 deletions

850
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,26 +4,29 @@ version = "0.0.1"
edition = "2021"
[workspace]
members = ["catalogs"]
members = ["mdengine"]
[dependencies]
anyhow = "1.0.91"
askama = { version = "0.12.1", features = ["urlencode"] }
askama = { version = "0.14.0", features = ["urlencode"] }
async-session = "3.0.0"
axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
catalogs = { path = "./catalogs" }
mdengine = { path = "./mdengine" }
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.31", features = ["derive"] }
config = "0.14.1"
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
deadpool-postgres = { version = "0.14.1", features = ["serde"] }
derive_builder = "0.20.2"
diesel = { version = "2.2.10", features = ["postgres", "chrono", "uuid", "serde_json"] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] }
dotenvy = "0.15.7"
futures = "0.3.31"
native-tls = "0.2.14"
oauth2 = "4.4.2"
percent-encoding = "2.3.1"
postgres-native-tls = "0.5.1"
rand = "0.8.5"
reqwest = { version = "0.12.8", features = ["json"] }
serde = { version = "1.0.213", features = ["derive"] }
@ -35,3 +38,4 @@ tracing = "0.1.40"
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
uuid = { version = "1.11.0", features = ["serde", "v4", "v7"] }
validator = { version = "0.20.0", features = ["derive"] }
tokio-postgres = { version = "0.7.13", features = ["with-uuid-1", "with-chrono-0_4"] }

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Interim
A friendly, collaborative PostgreSQL front-end for nerds of all stripes.
## Auth
Internally, Interim controls authorization using the `SET ROLE` Postgres command to switch between users. This allows the Interim base user to impersonate roles it has created, without necessarily requiring Postgres superuser privileges itself (unlike if it were to use `SET SESSION AUTHORIZATION`). For each front-end user, Interim creates a Postgres role named (by default) `__interim_{account_uuid}`.

View file

@ -1,6 +0,0 @@
mod catalogs_schema;
pub mod pg_attribute;
pub mod pg_class;
pub mod pg_namespace;
pub mod pg_roles;
pub mod table_privileges;

View file

@ -1,32 +0,0 @@
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use crate::catalogs_schema::pg_attribute;
pub use crate::catalogs_schema::pg_attribute::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(table_name = pg_attribute)]
#[diesel(primary_key(attrelid, attname))]
pub struct PgAttribute {
pub attrelid: u32,
pub attname: String,
pub atttypid: u32,
pub attnum: i16,
pub attndims: i16,
pub attnotnull: bool,
pub atthasdef: bool,
pub attisdropped: bool,
pub attacl: Vec<String>,
}
impl PgAttribute {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
table.select(select)
}
}

View file

@ -1,25 +0,0 @@
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use crate::catalogs_schema::pg_class;
pub use crate::catalogs_schema::pg_class::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(table_name = pg_class)]
#[diesel(primary_key(oid))]
pub struct PgClass {
pub oid: u32,
pub relname: String,
}
impl PgClass {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
table.select(select)
}
}

View file

@ -1,23 +0,0 @@
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use crate::catalogs_schema::pg_namespace;
pub use crate::catalogs_schema::pg_namespace::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(table_name = pg_namespace)]
pub struct PgNamespace {
pub oid: u32,
}
impl PgNamespace {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
table.select(select)
}
}

View file

@ -2,7 +2,7 @@
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
file = "do_not_use.txt"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]

View file

@ -1,5 +1,5 @@
[package]
name = "catalogs"
name = "mdengine"
version = "0.1.0"
edition = "2024"

7
mdengine/README.md Normal file
View file

@ -0,0 +1,7 @@
# Interim Metadata Engine (mdengine)
This crate is responsible for navigating the PostgreSQL `information_schema`
and catalogs tables to extract rich understanding of database structure and
permissions.
It does not fetch data directly from any user defined tables.

41
mdengine/src/lib.rs Normal file
View file

@ -0,0 +1,41 @@
use crate::{pg_class::PgClass, table_privileges::TablePrivilege};
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
pub mod pg_attribute;
pub mod pg_class;
pub mod pg_namespace;
pub mod pg_roles;
mod schema;
pub mod table_privileges;
// Still waiting for Postgres to gain class consciousness
#[derive(Clone, Queryable, Selectable)]
pub struct PgClassPrivilege {
#[diesel(embed)]
pub class: PgClass,
#[diesel(embed)]
pub privilege: TablePrivilege,
}
/// Query for the list of tables any of the provided roles has access to. A Vec
/// of grantees is accepted in case you wish to query for multiple roles of
/// which a user is a member.
#[auto_type(no_type_alias)]
pub fn class_privileges_for_grantees(grantees: Vec<String>) -> _ {
let select: AsSelect<PgClassPrivilege, Pg> = PgClassPrivilege::as_select();
pg_class::table
.inner_join(pg_namespace::table.on(pg_namespace::dsl::oid.eq(pg_class::dsl::relnamespace)))
.inner_join(
table_privileges::table.on(table_privileges::dsl::table_schema
.eq(pg_namespace::dsl::nspname)
.and(table_privileges::dsl::table_name.eq(pg_class::dsl::relname))),
)
// Excude indexes, series, etc.
.filter(pg_class::dsl::relkind.eq(b'r'))
.filter(table_privileges::dsl::grantee.eq_any(grantees))
.select(select)
}

View file

@ -0,0 +1,75 @@
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use crate::schema::pg_attribute;
pub use crate::schema::pg_attribute::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(check_for_backend(Pg))]
#[diesel(table_name = pg_attribute)]
pub struct PgAttribute {
/// The table this column belongs to
pub attrelid: u32,
/// The column name
pub attname: String,
/// The data type of this column (zero for a dropped column)
pub atttypid: u32,
/// A copy of pg_type.typlen of this column's type
pub attlen: i16,
/// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers.
pub attnum: i16,
/// Always -1 in storage, but when loaded into a row descriptor in memory this might be updated to cache the offset of the attribute within the row
pub attcacheoff: i32,
/// atttypmod records type-specific data supplied at table creation time (for example, the maximum length of a varchar column). It is passed to type-specific input functions and length coercion functions. The value will generally be -1 for types that do not need atttypmod.
pub atttypmod: i32,
/// Number of dimensions, if the column is an array type; otherwise 0. (Presently, the number of dimensions of an array is not enforced, so any nonzero value effectively means “it's an array”.)
pub attndims: i16,
/// A copy of pg_type.typbyval of this column's type
pub attbyval: bool,
/// A copy of pg_type.typalign of this column's type
pub attalign: u8,
/// Normally a copy of pg_type.typstorage of this column's type. For TOAST-able data types, this can be altered after column creation to control storage policy.
pub attstorage: u8,
/// The current compression method of the column. Typically this is '\0' to specify use of the current default setting (see default_toast_compression). Otherwise, 'p' selects pglz compression, while 'l' selects LZ4 compression. However, this field is ignored whenever attstorage does not allow compression.
pub attcompression: Option<u8>,
/// This represents a not-null constraint.
pub attnotnull: bool,
/// This column has a default expression or generation expression, in which case there will be a corresponding entry in the pg_attrdef catalog that actually defines the expression. (Check attgenerated to determine whether this is a default or a generation expression.)
pub atthasdef: bool,
/// This column has a value which is used where the column is entirely missing from the row, as happens when a column is added with a non-volatile DEFAULT value after the row is created. The actual value used is stored in the attmissingval column.
pub atthasmissing: bool,
/// If a zero byte (''), then not an identity column. Otherwise, a = generated always, d = generated by default.
pub attidentity: Option<u8>,
/// If a zero byte (''), then not a generated column. Otherwise, s = stored. (Other values might be added in the future.)
pub attgenerated: Option<u8>,
/// This column has been dropped and is no longer valid. A dropped column is still physically present in the table, but is ignored by the parser and so cannot be accessed via SQL.
pub attisdropped: bool,
/// This column is defined locally in the relation. Note that a column can be locally defined and inherited simultaneously.
pub attislocal: bool,
/// The number of direct ancestors this column has. A column with a nonzero number of ancestors cannot be dropped nor renamed.
pub attinhcount: i16,
/// The defined collation of the column, or zero if the column is not of a collatable data type
pub attcollation: u32,
/// attstattarget controls the level of detail of statistics accumulated for this column by ANALYZE. A zero value indicates that no statistics should be collected. A null value says to use the system default statistics target. The exact meaning of positive values is data type-dependent. For scalar data types, attstattarget is both the target number of “most common values” to collect, and the target number of histogram bins to create.
pub attstattarget: Option<i16>,
/// Column-level access privileges, if any have been granted specifically on this column
pub attacl: Option<Vec<String>>,
/// Attribute-level options, as “keyword=value” strings
pub attoptions: Option<Vec<String>>,
/// Attribute-level foreign data wrapper options, as “keyword=value” strings
pub attfdwoptions: Option<Vec<String>>,
}
#[auto_type(no_type_alias)]
pub fn attributes_for_rel(oid: u32) -> _ {
let select: AsSelect<PgAttribute, Pg> = PgAttribute::as_select();
pg_attribute::table
.filter(pg_attribute::dsl::attrelid.eq(oid))
.filter(pg_attribute::dsl::attnum.gt(0i16))
.filter(pg_attribute::dsl::attisdropped.eq(false))
.select(select)
}

14
mdengine/src/pg_class.rs Normal file
View file

@ -0,0 +1,14 @@
use diesel::{pg::Pg, prelude::*};
use crate::schema::pg_class;
pub use crate::schema::pg_class::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(check_for_backend(Pg))]
#[diesel(table_name = pg_class)]
#[diesel(primary_key(oid))]
pub struct PgClass {
pub oid: u32,
pub relname: String,
}

View file

@ -0,0 +1,13 @@
use diesel::{pg::Pg, prelude::*};
use crate::schema::pg_namespace;
pub use crate::schema::pg_namespace::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(check_for_backend(Pg))]
#[diesel(table_name = pg_namespace)]
#[diesel(primary_key(oid))]
pub struct PgNamespace {
pub oid: u32,
}

View file

@ -1,50 +1,39 @@
use chrono::{DateTime, Utc};
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use diesel::{pg::Pg, prelude::*};
use crate::catalogs_schema::pg_roles;
use crate::schema::pg_roles;
pub use crate::catalogs_schema::pg_roles::{dsl, table};
pub use crate::schema::pg_roles::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(check_for_backend(Pg))]
#[diesel(table_name = pg_roles)]
#[diesel(primary_key(oid))]
pub struct PgRole {
/// Role name
rolname: String,
pub rolname: String,
/// Role has superuser privileges
rolsuper: bool,
pub rolsuper: bool,
/// Role automatically inherits privileges of roles it is a member of
rolinherit: bool,
pub rolinherit: bool,
/// Role can create more roles
rolcreaterole: bool,
pub rolcreaterole: bool,
/// Role can create databases
rolcreatedb: bool,
pub rolcreatedb: bool,
/// Role can log in. That is, this role can be given as the initial session authorization identifier
rolcanlogin: bool,
pub rolcanlogin: bool,
/// Role is a replication role. A replication role can initiate replication connections and create and drop replication slots.
rolreplication: bool,
pub rolreplication: bool,
/// For roles that can log in, this sets maximum number of concurrent connections this role can make. -1 means no limit.
rolconnlimit: i32,
pub rolconnlimit: i32,
/// Not the password (always reads as ********)
rolpassword: String,
pub rolpassword: String,
/// Password expiry time (only used for password authentication); null if no expiration
rolvaliduntil: Option<DateTime<Utc>>,
pub rolvaliduntil: Option<DateTime<Utc>>,
/// Role bypasses every row-level security policy, see Section 5.9 for more information.
rolbypassrls: bool,
pub rolbypassrls: bool,
/// Role-specific defaults for run-time configuration variables
rolconfig: Option<Vec<String>>,
pub rolconfig: Option<Vec<String>>,
/// ID of role
oid: u32,
}
impl PgRole {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
table.select(select)
}
pub oid: u32,
}

View file

@ -133,7 +133,7 @@ table! {
/// Normally a copy of pg_type.typstorage of this column's type. For TOAST-able data types, this can be altered after column creation to control storage policy.
attstorage -> CChar,
/// The current compression method of the column. Typically this is '\0' to specify use of the current default setting (see default_toast_compression). Otherwise, 'p' selects pglz compression, while 'l' selects LZ4 compression. However, this field is ignored whenever attstorage does not allow compression.
attcompression -> CChar,
attcompression -> Nullable<CChar>,
/// This represents a not-null constraint.
attnotnull -> Bool,
/// This column has a default expression or generation expression, in which case there will be a corresponding entry in the pg_attrdef catalog that actually defines the expression. (Check attgenerated to determine whether this is a default or a generation expression.)
@ -141,9 +141,9 @@ table! {
/// This column has a value which is used where the column is entirely missing from the row, as happens when a column is added with a non-volatile DEFAULT value after the row is created. The actual value used is stored in the attmissingval column.
atthasmissing -> Bool,
/// If a zero byte (''), then not an identity column. Otherwise, a = generated always, d = generated by default.
attidentity -> CChar,
attidentity -> Nullable<CChar>,
/// If a zero byte (''), then not a generated column. Otherwise, s = stored. (Other values might be added in the future.)
attgenerated -> CChar,
attgenerated -> Nullable<CChar>,
/// This column has been dropped and is no longer valid. A dropped column is still physically present in the table, but is ignored by the parser and so cannot be accessed via SQL.
attisdropped -> Bool,
/// This column is defined locally in the relation. Note that a column can be locally defined and inherited simultaneously.
@ -153,13 +153,13 @@ table! {
/// The defined collation of the column, or zero if the column is not of a collatable data type
attcollation -> Oid,
/// attstattarget controls the level of detail of statistics accumulated for this column by ANALYZE. A zero value indicates that no statistics should be collected. A null value says to use the system default statistics target. The exact meaning of positive values is data type-dependent. For scalar data types, attstattarget is both the target number of “most common values” to collect, and the target number of histogram bins to create.
attstattarget -> SmallInt,
attstattarget -> Nullable<SmallInt>,
/// Column-level access privileges, if any have been granted specifically on this column
attacl -> Array<Text>,
attacl -> Nullable<Array<Text>>,
/// Attribute-level options, as “keyword=value” strings
attoptions -> Array<Text>,
attoptions -> Nullable<Array<Text>>,
/// Attribute-level foreign data wrapper options, as “keyword=value” strings
attfdwoptions -> Array<Text>,
attfdwoptions -> Nullable<Array<Text>>,
}
}

View file

@ -1,38 +1,27 @@
use diesel::{
dsl::{AsSelect, auto_type},
pg::Pg,
prelude::*,
};
use diesel::{pg::Pg, prelude::*};
use crate::catalogs_schema::table_privileges;
use crate::schema::table_privileges;
pub use crate::catalogs_schema::table_privileges::{dsl, table};
pub use crate::schema::table_privileges::{dsl, table};
#[derive(Clone, Debug, Queryable, Selectable)]
#[diesel(check_for_backend(Pg))]
#[diesel(table_name = table_privileges)]
pub struct TablePrivilege {
/// Name of the role that granted the privilege
grantor: String,
pub grantor: String,
/// Name of the role that the privilege was granted to
grantee: String,
pub grantee: String,
/// Name of the database that contains the table (always the current database)
table_catalog: String,
pub table_catalog: String,
/// Name of the schema that contains the table
table_schema: String,
pub table_schema: String,
/// Name of the table
table_name: String,
pub table_name: String,
/// Type of the privilege: SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, or TRIGGER
privilege_type: String,
pub privilege_type: String,
/// YES if the privilege is grantable, NO if not
is_grantable: String,
pub is_grantable: String,
/// In the SQL standard, WITH HIERARCHY OPTION is a separate (sub-)privilege allowing certain operations on table inheritance hierarchies. In PostgreSQL, this is included in the SELECT privilege, so this column shows YES if the privilege is SELECT, else NO.
with_hierarchy: String,
}
impl TablePrivilege {
#[auto_type(no_type_alias)]
pub fn all() -> _ {
let select: AsSelect<Self, Pg> = Self::as_select();
table.select(select)
}
pub with_hierarchy: String,
}

View file

@ -1,13 +1,7 @@
use catalogs::{
pg_class::{self, PgClass},
pg_namespace,
table_privileges::{self, TablePrivilege},
};
use diesel::{
dsl::{auto_type, AsSelect},
pg::Pg,
prelude::*,
};
use anyhow::{Context as _, Result};
use diesel::{prelude::*, sql_query};
use mdengine::pg_roles::{self, PgRole};
use uuid::Uuid;
pub fn escape_identifier(identifier: &str) -> String {
// Escaping identifiers for Postgres is fairly easy, provided that the input is
@ -15,32 +9,33 @@ pub fn escape_identifier(identifier: &str) -> String {
// remain as-is, and embedded double quotes are escaped simply by doubling
// them (`"` becomes `""`). Refer to the PQescapeInternal() function in
// libpq (fe-exec.c) and Diesel's PgQueryBuilder::push_identifier().
// Here we also add spaces before and after the identifier quotes so that
// the identifier isn't inadvertently merged into a token immediately
// leading or following it.
format!("\"{}\"", identifier.replace('"', "\"\""))
}
// Still waiting for Postgres to gain class consciousness
#[derive(Clone, Queryable, Selectable)]
pub struct PgClassPrivilege {
#[diesel(embed)]
pub class: PgClass,
#[diesel(embed)]
pub privilege: TablePrivilege,
pub fn diesel_set_user_id(
pg_user_role_prefix: &str,
user_id: Uuid,
conn: &mut PgConnection,
) -> Result<()> {
let role = pg_roles::table
.select(PgRole::as_select())
.filter(pg_roles::dsl::rolname.eq(format!("{}{}", pg_user_role_prefix, user_id.simple())))
.first(conn)
.optional()
.context("error reading role")?;
if role.is_none() {
sql_query(format!(
"CREATE ROLE {}",
escape_identifier(&format!("{}{}", pg_user_role_prefix, user_id.simple()))
))
.execute(conn)
.context("error creating role")?;
}
#[auto_type(no_type_alias)]
pub fn class_privileges_for_grantees(grantees: Vec<String>) -> _ {
let select: AsSelect<PgClassPrivilege, Pg> = PgClassPrivilege::as_select();
pg_class::table
.inner_join(pg_namespace::table.on(pg_namespace::dsl::oid.eq(pg_class::dsl::relnamespace)))
.inner_join(
table_privileges::table.on(table_privileges::dsl::table_schema
.eq(pg_namespace::dsl::nspname)
.and(table_privileges::dsl::table_name.eq(pg_class::dsl::relname))),
)
.filter(pg_class::dsl::relkind.eq(b'r'))
.filter(table_privileges::dsl::grantee.eq_any(grantees))
.select(select)
sql_query(format!(
"SET ROLE {}",
escape_identifier(&format!("{}{}", pg_user_role_prefix, user_id.simple()))
))
.execute(conn)
.context("error setting role to user")?;
Ok(())
}

View file

@ -5,19 +5,16 @@ use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use deadpool_diesel::postgres::{Connection, Pool};
use oauth2::basic::BasicClient;
use crate::{
abstract_::escape_identifier, app_error::AppError, auth, nav::NavbarBuilder, sessions::PgStore,
settings::Settings,
};
use crate::{app_error::AppError, auth, nav::NavbarBuilder, sessions::PgStore, settings::Settings};
/// Global app configuration
pub struct App {
pub db_pool: Pool,
pub diesel_pool: deadpool_diesel::postgres::Pool,
pub navbar_template: NavbarBuilder,
pub oauth_client: BasicClient,
pub pg_pool: deadpool_postgres::Pool,
pub reqwest_client: reqwest::Client,
pub session_store: PgStore,
pub settings: Settings,
@ -27,30 +24,40 @@ impl App {
/// Initialize global application functions based on config values
pub async fn from_settings(settings: Settings) -> Result<Self> {
let database_url = settings.database_url.clone();
let manager = deadpool_diesel::postgres::Manager::from_config(
database_url,
let diesel_manager = deadpool_diesel::postgres::Manager::from_config(
database_url.clone(),
deadpool_diesel::Runtime::Tokio1,
deadpool_diesel::ManagerConfig {
// Reset role after each interaction is recycled so that user
// sessions remain isolated by deadpool interaction
recycling_method: deadpool_diesel::RecyclingMethod::CustomQuery(
std::borrow::Cow::Owned(format!(
"SET ROLE {}",
escape_identifier(&settings.pg_root_role)
)),
std::borrow::Cow::Owned("RESET ROLE;".to_owned()),
),
},
);
let db_pool = deadpool_diesel::postgres::Pool::builder(manager).build()?;
let diesel_pool = deadpool_diesel::postgres::Pool::builder(diesel_manager).build()?;
let session_store = PgStore::new(db_pool.clone());
let pg_config = deadpool_postgres::Config {
url: Some(database_url),
manager: Some(deadpool_postgres::ManagerConfig {
recycling_method: deadpool_postgres::RecyclingMethod::Clean,
}),
..Default::default()
};
let pg_pool = pg_config.create_pool(
Some(deadpool_postgres::Runtime::Tokio1),
postgres_native_tls::MakeTlsConnector::new(native_tls::TlsConnector::new()?),
)?;
let session_store = PgStore::new(diesel_pool.clone());
let reqwest_client = reqwest::ClientBuilder::new().https_only(true).build()?;
let oauth_client = auth::new_oauth_client(&settings)?;
Ok(Self {
db_pool,
diesel_pool,
navbar_template: NavbarBuilder::default().with_base_path(&settings.base_path),
oauth_client,
pg_pool,
reqwest_client,
session_store,
settings,
@ -74,17 +81,35 @@ where
}
}
/// Extractor to automatically obtain a Deadpool database connection
pub struct DbConn(pub Connection);
/// Extractor to automatically obtain a Deadpool Diesel connection
pub struct DieselConn(pub deadpool_diesel::postgres::Connection);
impl<S> FromRequestParts<S> for DbConn
impl<S> FromRequestParts<S> for DieselConn
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
async fn from_request_parts(_: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let conn = Into::<AppState>::into(state.clone()).db_pool.get().await?;
let conn = Into::<AppState>::into(state.clone())
.diesel_pool
.get()
.await?;
Ok(Self(conn))
}
}
/// Extractor to automatically obtain a Deadpool tokio-postgres connection
pub struct PgConn(pub deadpool_postgres::Object);
impl<S> FromRequestParts<S> for PgConn
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
async fn from_request_parts(_: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let conn = Into::<AppState>::into(state.clone()).pg_pool.get().await?;
Ok(Self(conn))
}
}

118
src/data_layer.rs Normal file
View file

@ -0,0 +1,118 @@
use std::fmt::Display;
use anyhow::Result;
use chrono::{DateTime, Utc};
use deadpool_postgres::tokio_postgres::{
row::RowIndex,
types::{FromSql, Type},
Row,
};
use derive_builder::Builder;
use uuid::Uuid;
pub enum Contents {
Text(String),
Integer(i32),
Timestamptz(DateTime<Utc>),
Uuid(Uuid),
}
pub struct Value(Option<Contents>);
#[derive(Builder)]
#[builder(pattern = "owned", setter(prefix = "with"))]
pub struct FieldOptions {
/// Format with which to render timestamptz values
#[builder(default = "\"%Y-%m-%dT%H:%M:%S%.f%:z\".to_owned()")]
pub date_format: String,
/// If some, treat text column like an enum
#[builder(default)]
pub select_options: Option<Vec<String>>,
/// Text to display in place of actual column name
#[builder(default)]
pub label: Option<String>,
#[builder(default = "true")]
pub editable: bool,
}
pub trait ToHtmlString {
fn to_html_string(&self, options: &FieldOptions) -> String;
}
#[derive(Clone, Debug)]
pub struct FromSqlError {
message: String,
}
impl std::error::Error for FromSqlError {}
impl Display for FromSqlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl FromSqlError {
fn new(message: &str) -> Self {
Self {
message: message.to_owned(),
}
}
}
impl Value {
pub fn get_from_row<I: RowIndex + Display>(row: &Row, idx: I) -> Result<Self> {
Ok(Self(row.try_get::<_, Option<Contents>>(idx)?))
}
}
impl ToHtmlString for Value {
fn to_html_string(&self, options: &FieldOptions) -> String {
if let Self(Some(contents)) = self {
match contents {
Contents::Text(value) => value.clone(),
&Contents::Integer(value) => value.to_string(),
&Contents::Timestamptz(value) => value.format(&options.date_format).to_string(),
&Contents::Uuid(value) => format!(
"<span class=\"pg-value-uuid\">{}</span>",
value.hyphenated()
),
}
} else {
"<span class=\"pg-value-null\">NULL</span>".to_owned()
}
}
}
impl<'a> FromSql<'a> for Contents {
fn from_sql(
ty: &Type,
raw: &'a [u8],
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
match ty {
&Type::INT4 => Ok(Self::Integer(i32::from_sql(ty, raw)?)),
&Type::TEXT | &Type::VARCHAR => Ok(Self::Text(String::from_sql(ty, raw)?)),
&Type::TIMESTAMPTZ => Ok(Self::Timestamptz(DateTime::<Utc>::from_sql(ty, raw)?)),
&Type::UUID => Ok(Self::Uuid(<Uuid as FromSql>::from_sql(ty, raw)?)),
_ => Err(Box::new(FromSqlError::new(
"unsupported pg type for interim Value",
))),
}
}
fn accepts(ty: &Type) -> bool {
matches!(ty, &Type::TEXT | &Type::VARCHAR | &Type::UUID)
}
}
pub struct Lens {
pub fields: Vec<Field>,
}
pub struct Field {
pub options: FieldOptions,
pub name: String,
}

View file

@ -15,6 +15,7 @@ mod app_error;
mod app_state;
mod auth;
mod cli;
mod data_layer;
mod middleware;
mod migrations;
mod nav;
@ -40,7 +41,7 @@ async fn main() {
if settings.run_database_migrations == Some(1) {
// Run migrations on server startup
let conn = state.db_pool.get().await.unwrap();
let conn = state.diesel_pool.get().await.unwrap();
conn.interact(|conn| conn.run_pending_migrations(MIGRATIONS).and(Ok(())))
.await
.unwrap()

View file

@ -1,19 +1,20 @@
use anyhow::{Context as _, Result};
use askama::Template;
use axum::{
extract::State,
extract::{Path, State},
http::{header::CACHE_CONTROL, HeaderValue},
response::{Html, IntoResponse as _, Response},
routing::get,
Router,
};
use catalogs::{
use deadpool_postgres::{tokio_postgres::Row, GenericClient};
use diesel::prelude::*;
use mdengine::{
class_privileges_for_grantees,
pg_attribute::{attributes_for_rel, PgAttribute},
pg_class::{self, PgClass},
pg_namespace,
pg_roles::{self, PgRole},
table_privileges::{self, TablePrivilege},
};
use diesel::{prelude::*, sql_query};
use serde::Deserialize;
use tower::ServiceBuilder;
use tower_http::{
services::{ServeDir, ServeFile},
@ -21,18 +22,22 @@ use tower_http::{
};
use crate::{
abstract_::{class_privileges_for_grantees, escape_identifier},
abstract_::{diesel_set_user_id, escape_identifier},
app_error::AppError,
app_state::{AppState, DbConn},
app_state::{AppState, DieselConn, PgConn},
auth,
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
settings::Settings,
users::CurrentUser,
};
const FRONTEND_ROW_LIMIT: i64 = 1000;
pub fn new_router(state: AppState) -> Router<()> {
let base_path = state.settings.base_path.clone();
let app = Router::new()
.route("/", get(landing_page))
.route("/c/{oid}/viewer", get(viewer_page))
.nest("/auth", auth::new_router())
.layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL,
@ -71,7 +76,7 @@ async fn landing_page(
pg_user_role_prefix,
..
}): State<Settings>,
DbConn(db_conn): DbConn,
DieselConn(db_conn): DieselConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
let grantees = vec![format!(
@ -80,38 +85,8 @@ async fn landing_page(
current_user.id.simple()
)];
let visible_tables = db_conn
.interact(move |conn| -> Result<Vec<PgClass>> {
let role = PgRole::all()
.filter(pg_roles::dsl::rolname.eq(format!(
"{}{}",
pg_user_role_prefix,
current_user.id.simple()
)))
.first(conn)
.optional()
.context("error reading role")?;
if role.is_none() {
sql_query(format!(
"CREATE ROLE {}",
escape_identifier(&format!(
"{}{}",
pg_user_role_prefix,
current_user.id.simple()
))
))
.execute(conn)
.context("error creating role")?;
}
sql_query(format!(
"SET ROLE {}",
escape_identifier(&format!(
"{}{}",
pg_user_role_prefix,
current_user.id.simple()
))
))
.execute(conn)
.context("error setting role to user")?;
.interact(move |conn| -> Result<Vec<_>> {
diesel_set_user_id(&pg_user_role_prefix, current_user.id, conn)?;
let privileges = class_privileges_for_grantees(grantees)
.load(conn)
.context("error reading classes")?;
@ -134,3 +109,91 @@ async fn landing_page(
)
.into_response())
}
#[derive(Deserialize)]
struct ViewerPagePath {
oid: u32,
}
async fn viewer_page(
State(Settings {
base_path,
pg_user_role_prefix,
..
}): State<Settings>,
DieselConn(diesel_conn): DieselConn,
PgConn(pg_client): PgConn,
CurrentUser(current_user): CurrentUser,
Path(params): Path<ViewerPagePath>,
) -> Result<Response, AppError> {
pg_client
.query(
&format!(
"SET ROLE {};",
escape_identifier(&format!(
"{}{}",
pg_user_role_prefix,
current_user.id.simple()
)),
),
&[],
)
.await?;
// FIXME: Ensure user has access to relation
// One-off helper struct to hold Diesel results
struct RelMeta {
class: PgClass,
attrs: Vec<PgAttribute>,
}
let RelMeta { class, attrs } = diesel_conn
.interact(move |conn| -> Result<_> {
Ok(RelMeta {
class: pg_class::table
.filter(pg_class::dsl::oid.eq(params.oid))
.select(PgClass::as_select())
.first(conn)?,
attrs: attributes_for_rel(params.oid).load(conn)?,
})
})
.await
.unwrap()?;
let query = [
"SELECT",
&attrs
.iter()
.map(|attr| attr.attname.clone())
.collect::<Vec<_>>()
.join(", "),
"FROM",
&escape_identifier(&class.relname),
"LIMIT",
&FRONTEND_ROW_LIMIT.to_string(),
";",
]
.join(" ");
let rows = pg_client.query(&query, &[]).await?;
#[derive(Template)]
#[template(path = "class-viewer.html")]
struct ResponseTemplate {
base_path: String,
fields: Vec<Field>,
rows: Vec<Row>,
}
Ok(Html(
ResponseTemplate {
base_path,
fields: attrs
.into_iter()
.map(|attr| Field {
options: FieldOptionsBuilder::default().build().unwrap(),
name: attr.attname,
})
.collect(),
rows,
}
.render()?,
)
.into_response())
}

View file

@ -104,7 +104,7 @@ where
format!("{}/auth/login", app_state.settings.base_path),
));
};
let db_conn = app_state.db_pool.get().await?;
let db_conn = app_state.diesel_pool.get().await?;
let current_user = db_conn
.interact(move |conn| {
let maybe_current_user = User::all()

View file

@ -6,7 +6,6 @@
<link rel="stylesheet" href="{{ base_path }}/main.css">
</head>
<body>
{% include "nav.html" %}
{% block main %}{% endblock main %}
</body>
</html>

View file

@ -5,12 +5,14 @@
<thead>
<tr>
<th>Name</th>
<th>OID</th>
</tr>
</thead>
<tbody>
{% for relation in relations %}
<tr>
<td>{{ relation.relname }}</td>
<td>{{ relation.oid }}</td>
</tr>
{% endfor %}
</tbody>