refactor db clients

This commit is contained in:
Brent Schroeter 2025-08-04 13:59:42 -07:00
parent aa8bf34642
commit 53b4dfa130
49 changed files with 1241 additions and 1256 deletions

View file

@ -1,4 +1,4 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.85.0 AS chef
FROM lukemathwalker/cargo-chef:latest-rust-1.87.0 AS chef
WORKDIR /app
FROM chef AS planner
@ -18,5 +18,9 @@ FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y libpq-dev
WORKDIR /app
COPY --from=builder /app/target/release/interim /usr/local/bin
COPY ./css_dist ./css_dist
COPY ./js_dist ./js_dist
COPY ./static ./static
ENTRYPOINT ["/usr/local/bin/interim"]

View file

@ -1,12 +0,0 @@
use chrono::{DateTime, Utc};
use sqlx::{Encode, postgres::Postgres};
use uuid::Uuid;
pub enum Sqlizable {
Integer(i32),
Text(String),
Timestamptz(DateTime<Utc>),
Uuid(Uuid),
}
impl Encode<'a, Postgres> for Sqlizable {}

113
interim-models/src/base.rs Normal file
View file

@ -0,0 +1,113 @@
use derive_builder::Builder;
use sqlx::query_as;
use uuid::Uuid;
use crate::client::AppDbClient;
#[derive(Clone, Debug)]
pub struct Base {
pub id: Uuid,
pub name: String,
pub url: String,
pub owner_id: Uuid,
pub user_role_prefix: String,
}
impl Base {
pub fn insertable_builder() -> InsertableBaseBuilder {
InsertableBaseBuilder::default()
}
pub fn with_id(id: Uuid) -> WithIdQuery {
WithIdQuery { id }
}
pub fn with_permission_in<I: IntoIterator<Item = &'static str>>(
perms: I,
) -> WithPermissionInQueryPartial {
let perms: Vec<String> = perms.into_iter().map(ToOwned::to_owned).collect();
WithPermissionInQueryPartial { perms }
}
}
pub struct WithPermissionInQueryPartial {
perms: Vec<String>,
}
impl WithPermissionInQueryPartial {
pub fn for_user(self, user_id: Uuid) -> WithPermissionInQuery {
WithPermissionInQuery {
perms: self.perms,
user_id,
}
}
}
pub struct WithPermissionInQuery {
perms: Vec<String>,
user_id: Uuid,
}
impl WithPermissionInQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Base>, sqlx::Error> {
query_as!(
Base,
"
select bases.*
from bases inner join base_user_perms as p
on p.base_id = bases.id
where p.user_id = $1 and perm = ANY($2)
",
self.user_id,
self.perms.as_slice(),
)
.fetch_all(&mut *app_db.conn)
.await
}
}
pub struct WithIdQuery {
id: Uuid,
}
impl WithIdQuery {
pub async fn fetch_optional(
self,
app_db: &mut AppDbClient,
) -> Result<Option<Base>, sqlx::Error> {
query_as!(Base, "select * from bases where id = $1", &self.id)
.fetch_optional(&mut *app_db.conn)
.await
}
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Base, sqlx::Error> {
query_as!(Base, "select * from bases where id = $1", &self.id)
.fetch_one(&mut *app_db.conn)
.await
}
}
#[derive(Builder)]
pub struct InsertableBase {
url: String,
owner_id: Uuid,
}
impl InsertableBase {
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Base, sqlx::Error> {
query_as!(
Base,
"
insert into bases
(id, url, owner_id)
values ($1, $2, $3)
returning *
",
Uuid::now_v7(),
self.url,
self.owner_id
)
.fetch_one(&mut *app_db.conn)
.await
}
}

View file

@ -0,0 +1,15 @@
use sqlx::{PgConnection, Postgres, pool::PoolConnection};
pub struct AppDbClient {
pub(crate) conn: PoolConnection<Postgres>,
}
impl AppDbClient {
pub fn from_pool_conn(conn: PoolConnection<Postgres>) -> Self {
Self { conn }
}
pub fn get_conn(&mut self) -> &mut PgConnection {
&mut self.conn
}
}

View file

@ -2,12 +2,12 @@ use chrono::{DateTime, Utc};
use derive_builder::Builder;
use interim_pgtypes::pg_attribute::PgAttribute;
use serde::{Deserialize, Serialize};
use sqlx::{
Decode, PgExecutor, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query_as,
};
use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query_as};
use thiserror::Error;
use uuid::Uuid;
use crate::client::AppDbClient;
pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -49,27 +49,6 @@ impl Field {
vec![]
}
// pub fn render(&self, value: &Encodable) -> String {
// match (self.field_type.0.clone(), value) {
// (FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(),
// (FieldType::Integer, Encodable::Integer(None)) => "".to_owned(),
// (FieldType::Integer, _) => "###".to_owned(),
// (FieldType::InterimUser, Encodable::Text(value)) => todo!(),
// (FieldType::InterimUser, _) => "###".to_owned(),
// (FieldType::Text, Encodable::Text(Some(value))) => value.clone(),
// (FieldType::Text, Encodable::Text(None)) => "".to_owned(),
// (FieldType::Text, _) => "###".to_owned(),
// (FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value
// .map(|value| value.format(&format).to_string())
// .unwrap_or("".to_owned()),
// (FieldType::Timestamp { .. }, _) => "###".to_owned(),
// (FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(),
// (FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(),
// (FieldType::Uuid, _) => "###".to_owned(),
// (FieldType::Unknown, _) => "###".to_owned(),
// }
// }
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
let value_ref = row
.try_get_raw(self.name.as_str())
@ -89,6 +68,36 @@ impl Field {
_ => return Err(ParseError::UnknownType),
})
}
pub fn belonging_to_lens(lens_id: Uuid) -> BelongingToLensQuery {
BelongingToLensQuery { lens_id }
}
}
#[derive(Clone, Debug)]
pub struct BelongingToLensQuery {
lens_id: Uuid,
}
impl BelongingToLensQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Field>, sqlx::Error> {
query_as!(
Field,
r#"
select
id,
name,
label,
field_type as "field_type: sqlx::types::Json<FieldType>",
width_px
from fields
where lens_id = $1
"#,
self.lens_id
)
.fetch_all(&mut *app_db.conn)
.await
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -148,7 +157,7 @@ pub struct InsertableField {
}
impl InsertableField {
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Field, sqlx::Error> {
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Field, sqlx::Error> {
query_as!(
Field,
r#"
@ -169,7 +178,7 @@ returning
sqlx::types::Json::<_>(self.field_type) as sqlx::types::Json<FieldType>,
self.width_px,
)
.fetch_one(app_db)
.fetch_one(&mut *app_db.conn)
.await
}
}

View file

@ -1,9 +1,9 @@
use derive_builder::Builder;
use serde::Serialize;
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query_as};
use uuid::Uuid;
use crate::field::{Field, FieldType};
use crate::client::AppDbClient;
#[derive(Clone, Debug, Serialize)]
pub struct Lens {
@ -19,12 +19,27 @@ impl Lens {
InsertableLensBuilder::default()
}
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
pub fn with_id(id: Uuid) -> WithIdQuery {
WithIdQuery { id }
}
pub fn belonging_to_base(base_id: Uuid) -> BelongingToBaseQuery {
BelongingToBaseQuery { base_id }
}
}
#[derive(Clone, Debug)]
pub struct WithIdQuery {
id: Uuid,
app_db: E,
) -> Result<Option<Self>, sqlx::Error> {
}
impl WithIdQuery {
pub async fn fetch_optional(
self,
app_db: &mut AppDbClient,
) -> Result<Option<Lens>, sqlx::Error> {
query_as!(
Self,
Lens,
r#"
select
id,
@ -35,19 +50,56 @@ select
from lenses
where id = $1
"#,
id
self.id
)
.fetch_optional(app_db)
.fetch_optional(&mut *app_db.conn)
.await
}
pub async fn fetch_by_rel<'a, E: PgExecutor<'a>>(
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Lens, sqlx::Error> {
query_as!(
Lens,
r#"
select
id,
name,
base_id,
class_oid,
display_type as "display_type: LensDisplayType"
from lenses
where id = $1
"#,
self.id
)
.fetch_one(&mut *app_db.conn)
.await
}
}
#[derive(Clone, Debug)]
pub struct BelongingToBaseQuery {
base_id: Uuid,
}
impl BelongingToBaseQuery {
pub fn belonging_to_rel(self, rel_oid: Oid) -> BelongingToRelQuery {
BelongingToRelQuery {
base_id: self.base_id,
rel_oid,
}
}
}
#[derive(Clone, Debug)]
pub struct BelongingToRelQuery {
base_id: Uuid,
rel_oid: Oid,
app_db: E,
) -> Result<Vec<Self>, sqlx::Error> {
}
impl BelongingToRelQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Lens>, sqlx::Error> {
query_as!(
Self,
Lens,
r#"
select
id,
@ -58,32 +110,10 @@ select
from lenses
where base_id = $1 and class_oid = $2
"#,
base_id,
rel_oid
self.base_id,
self.rel_oid
)
.fetch_all(app_db)
.await
}
pub async fn fetch_fields<'a, E: PgExecutor<'a>>(
&self,
app_db: E,
) -> Result<Vec<Field>, sqlx::Error> {
query_as!(
Field,
r#"
select
id,
name,
label,
field_type as "field_type: sqlx::types::Json<FieldType>",
width_px
from fields
where lens_id = $1
"#,
self.id
)
.fetch_all(app_db)
.fetch_all(&mut *app_db.conn)
.await
}
}
@ -103,7 +133,7 @@ pub struct InsertableLens {
}
impl InsertableLens {
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Lens, sqlx::Error> {
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Lens, sqlx::Error> {
query_as!(
Lens,
r#"
@ -123,7 +153,7 @@ returning
self.name,
self.display_type as LensDisplayType
)
.fetch_one(app_db)
.fetch_one(&mut *app_db.conn)
.await
}
}

View file

@ -1,5 +1,8 @@
pub mod base;
pub mod client;
pub mod field;
pub mod lens;
// pub mod selection;
pub mod rel_invitation;
pub mod user;
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();

View file

@ -1,9 +1,11 @@
use chrono::{DateTime, Utc};
use derive_builder::Builder;
use interim_pgtypes::pg_acl::PgPrivilegeType;
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query_as};
use uuid::Uuid;
use crate::client::AppDbClient;
#[derive(Clone, Debug)]
pub struct RelInvitation {
pub id: Uuid,
@ -16,20 +18,8 @@ pub struct RelInvitation {
}
impl RelInvitation {
pub async fn fetch_by_class_oid<'a, E: PgExecutor<'a>>(
oid: Oid,
app_db: E,
) -> Result<Vec<Self>, sqlx::Error> {
query_as!(
Self,
"
select * from rel_invitations
where class_oid = $1
",
oid
)
.fetch_all(app_db)
.await
pub fn belonging_to_rel(rel_oid: Oid) -> BelongingToRelQuery {
BelongingToRelQuery { rel_oid }
}
pub fn upsertable() -> UpsertableRelInvitationBuilder {
@ -37,6 +27,29 @@ where class_oid = $1
}
}
#[derive(Clone, Debug)]
pub struct BelongingToRelQuery {
rel_oid: Oid,
}
impl BelongingToRelQuery {
pub async fn fetch_all(
self,
app_db: &mut AppDbClient,
) -> Result<Vec<RelInvitation>, sqlx::Error> {
query_as!(
RelInvitation,
"
select * from rel_invitations
where class_oid = $1
",
self.rel_oid
)
.fetch_all(&mut *app_db.conn)
.await
}
}
#[derive(Builder, Clone, Debug)]
pub struct UpsertableRelInvitation {
email: String,
@ -49,10 +62,7 @@ pub struct UpsertableRelInvitation {
}
impl UpsertableRelInvitation {
pub async fn upsert<'a, E: PgExecutor<'a>>(
self,
app_db: E,
) -> Result<RelInvitation, sqlx::Error> {
pub async fn upsert(self, app_db: &mut AppDbClient) -> Result<RelInvitation, sqlx::Error> {
query_as!(
RelInvitation,
"
@ -72,7 +82,7 @@ returning *
self.created_by,
self.expires_at,
)
.fetch_one(app_db)
.fetch_one(&mut *app_db.conn)
.await
}
}

View file

@ -0,0 +1,38 @@
use sqlx::query_as;
use uuid::Uuid;
use crate::client::AppDbClient;
#[derive(Clone, Debug)]
pub struct User {
pub id: Uuid,
pub uid: String,
pub email: String,
}
impl User {
pub fn with_id_in<I: IntoIterator<Item = Uuid>>(ids: I) -> WithIdInQuery {
let ids: Vec<Uuid> = ids.into_iter().collect();
WithIdInQuery { ids }
}
}
#[derive(Clone, Debug)]
pub struct WithIdInQuery {
ids: Vec<Uuid>,
}
impl WithIdInQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<User>, sqlx::Error> {
query_as!(
User,
"
select * from users
where id = any($1)
",
self.ids.as_slice()
)
.fetch_all(&mut *app_db.conn)
.await
}
}

View file

@ -0,0 +1,55 @@
use sqlx::{PgConnection, Postgres, Row as _, pool::PoolConnection, query};
use crate::escape_identifier;
pub struct BaseClient {
pub(crate) conn: PoolConnection<Postgres>,
}
impl BaseClient {
pub fn from_pool_conn(conn: PoolConnection<Postgres>) -> Self {
Self { conn }
}
pub fn get_conn(&mut self) -> &mut PgConnection {
&mut self.conn
}
/// Runs the Postgres `set role` command for the underlying connection. If
/// the given role does not exist, it is created and granted to the
/// session_user.
///
/// Note that while using `set role` simulates impersonation for most data
/// access and RLS purposes, it is both incomplete and easily reversible:
/// some commands and system tables will still behave according to the
/// privileges of the session user, and clients relying on this abstraction
/// should **NEVER** execute untrusted SQL.
pub async fn init_role(&mut self, rolname: &str) -> Result<(), sqlx::Error> {
let session_user = query!("select session_user;")
.fetch_one(&mut *self.conn)
.await?
.session_user
.unwrap();
if !query("select exists(select 1 from pg_roles where rolname = $1)")
.bind(rolname)
.fetch_one(&mut *self.conn)
.await?
.try_get(0)?
{
query(&format!("create role {}", escape_identifier(rolname)))
.execute(&mut *self.conn)
.await?;
query(&format!(
"grant {} to {}",
escape_identifier(rolname),
escape_identifier(&session_user),
))
.execute(&mut *self.conn)
.await?;
}
query(&format!("set role {}", escape_identifier(rolname)))
.execute(&mut *self.conn)
.await?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
pub mod client;
pub mod pg_acl;
pub mod pg_attribute;
pub mod pg_class;

View file

@ -1,5 +1,7 @@
use serde::Serialize;
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query_as};
use crate::client::BaseClient;
#[derive(Clone, Serialize)]
pub struct PgAttribute {
@ -37,10 +39,23 @@ pub struct PgAttribute {
pub attfdwoptions: Option<Vec<String>>,
}
pub async fn fetch_attributes_for_rel<'a, E: PgExecutor<'a>>(
oid: Oid,
client: E,
) -> Result<Vec<PgAttribute>, sqlx::Error> {
impl PgAttribute {
pub fn all_for_rel(rel_oid: Oid) -> AllForRelQuery {
AllForRelQuery { rel_oid }
}
pub fn pkeys_for_rel(rel_oid: Oid) -> PkeysForRelQuery {
PkeysForRelQuery { rel_oid }
}
}
#[derive(Clone, Debug)]
pub struct AllForRelQuery {
rel_oid: Oid,
}
impl AllForRelQuery {
pub async fn fetch_all(self, client: &mut BaseClient) -> Result<Vec<PgAttribute>, sqlx::Error> {
query_as!(
PgAttribute,
r#"
@ -63,16 +78,20 @@ select
from pg_attribute
where attrelid = $1 and attnum > 0 and not attisdropped
"#,
&oid
&self.rel_oid
)
.fetch_all(client)
.fetch_all(&mut *client.conn)
.await
}
}
pub async fn fetch_primary_keys_for_rel<'a, E: PgExecutor<'a>>(
oid: Oid,
client: E,
) -> Result<Vec<PgAttribute>, sqlx::Error> {
#[derive(Clone, Debug)]
pub struct PkeysForRelQuery {
rel_oid: Oid,
}
impl PkeysForRelQuery {
pub async fn fetch_all(self, client: &mut BaseClient) -> Result<Vec<PgAttribute>, sqlx::Error> {
query_as!(
PgAttribute,
r#"
@ -98,8 +117,9 @@ from pg_attribute a
and a.attnum = any(i.indkey)
where i.indrelid = $1 and i.indisprimary;
"#,
&oid
&self.rel_oid
)
.fetch_all(client)
.fetch_all(&mut *client.conn)
.await
}
}

View file

@ -1,7 +1,8 @@
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query_as};
use crate::{escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace};
use crate::{client::BaseClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace};
#[derive(Clone, Debug)]
pub struct PgClass {
/// Row identifier
pub oid: Oid,
@ -41,12 +42,46 @@ pub struct PgClass {
}
impl PgClass {
pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>(
pub async fn fetch_namespace(
&self,
client: &mut BaseClient,
) -> Result<PgNamespace, sqlx::Error> {
PgNamespace::fetch_by_oid(self.relnamespace, &mut *client.conn)
.await?
// If client has access to the class, it would expect to have access
// to the namespace that contains it. If not, that's an error.
.ok_or(sqlx::Error::RowNotFound)
}
/// Get ecaped identifier, including namespace.
pub fn get_identifier(&self) -> String {
format!(
"{0}.{1}",
escape_identifier(&self.regnamespace),
escape_identifier(&self.relname)
)
}
pub fn with_oid(oid: Oid) -> WithOidQuery {
WithOidQuery { oid }
}
pub fn with_kind_in<I: IntoIterator<Item = PgRelKind>>(kinds: I) -> WithKindInQuery {
WithKindInQuery {
kinds: kinds.into_iter().collect(),
}
}
}
pub struct WithOidQuery {
oid: Oid,
client: E,
) -> Result<Option<Self>, sqlx::Error> {
}
// Extracted as macro so that fetch_one() and fetch_optional() methods can
// reuse the same code.
macro_rules! with_oid_sqlx_query {
($value:expr) => {
query_as!(
Self,
PgClass,
r#"
select
oid,
@ -71,22 +106,41 @@ from pg_class
where
oid = $1
"#,
oid,
$value,
)
.fetch_optional(client)
};
}
impl WithOidQuery {
pub async fn fetch_one(self, client: &mut BaseClient) -> Result<PgClass, sqlx::Error> {
with_oid_sqlx_query!(self.oid)
.fetch_one(&mut *client.conn)
.await
}
pub async fn fetch_all_by_kind_any<'a, I: IntoIterator<Item = PgRelKind>, E: PgExecutor<'a>>(
kinds: I,
client: E,
) -> Result<Vec<Self>, sqlx::Error> {
let kinds_i8 = kinds
pub async fn fetch_optional(
self,
client: &mut BaseClient,
) -> Result<Option<PgClass>, sqlx::Error> {
with_oid_sqlx_query!(self.oid)
.fetch_optional(&mut *client.conn)
.await
}
}
pub struct WithKindInQuery {
kinds: Vec<PgRelKind>,
}
impl WithKindInQuery {
pub async fn fetch_all(self, client: &mut BaseClient) -> Result<Vec<PgClass>, sqlx::Error> {
let kinds_i8: Vec<_> = self
.kinds
.into_iter()
.map(|kind| kind.to_u8() as i8)
.collect::<Vec<i8>>();
.collect();
query_as!(
Self,
PgClass,
r#"
select
oid,
@ -113,29 +167,9 @@ where
"#,
kinds_i8.as_slice(),
)
.fetch_all(client)
.fetch_all(&mut *client.conn)
.await
}
pub async fn fetch_namespace<'a, E: PgExecutor<'a>>(
&self,
client: E,
) -> Result<PgNamespace, sqlx::Error> {
PgNamespace::fetch_by_oid(self.relnamespace, client)
.await?
// If client has access to the class, it would expect to have access
// to the namespace that contains it. If not, that's an error.
.ok_or(sqlx::Error::RowNotFound)
}
/// Get ecaped identifier, including namespace.
pub fn get_identifier(&self) -> String {
format!(
"{0}.{1}",
escape_identifier(&self.regnamespace),
escape_identifier(&self.relname)
)
}
}
pub enum PgRelKind {

View file

@ -1,6 +1,6 @@
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query_as};
use crate::pg_acl::PgAclItem;
use crate::{client::BaseClient, pg_acl::PgAclItem};
#[derive(Clone, Debug)]
pub struct PgDatabase {
@ -39,9 +39,16 @@ pub struct PgDatabase {
}
impl PgDatabase {
pub async fn fetch_current<'a, E: PgExecutor<'a>>(
client: E,
) -> Result<PgDatabase, sqlx::Error> {
pub fn current() -> CurrentQuery {
CurrentQuery {}
}
}
#[derive(Clone, Debug)]
pub struct CurrentQuery {}
impl CurrentQuery {
pub async fn fetch_one(self, client: &mut BaseClient) -> Result<PgDatabase, sqlx::Error> {
query_as!(
PgDatabase,
r#"
@ -66,7 +73,7 @@ from pg_database
where datname = current_database()
"#,
)
.fetch_one(client)
.fetch_one(&mut *client.conn)
.await
}
}

View file

@ -1,8 +1,10 @@
use chrono::{DateTime, Utc};
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as, PgExecutor};
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
use thiserror::Error;
use uuid::Uuid;
use crate::client::BaseClient;
#[derive(Clone, Debug, Eq, Hash, FromRow, PartialEq)]
pub struct PgRole {
/// ID of role
@ -30,10 +32,18 @@ pub struct PgRole {
}
impl PgRole {
pub async fn fetch_by_names_any<'a, E: PgExecutor<'a>>(
pub fn with_name_in(names: Vec<String>) -> WithNameInQuery {
WithNameInQuery { names }
}
}
#[derive(Clone, Debug)]
pub struct WithNameInQuery {
names: Vec<String>,
client: E,
) -> Result<Vec<PgRole>, sqlx::Error> {
}
impl WithNameInQuery {
pub async fn fetch_all(&self, client: &mut BaseClient) -> Result<Vec<PgRole>, sqlx::Error> {
query_as!(
PgRole,
r#"
@ -50,9 +60,9 @@ select
rolvaliduntil,
rolbypassrls as "rolbypassrls!"
from pg_roles where rolname = any($1)"#,
names.as_slice()
self.names.as_slice()
)
.fetch_all(client)
.fetch_all(&mut *client.conn)
.await
}
}
@ -64,7 +74,7 @@ pub struct RoleTree {
pub inherit: bool,
}
#[derive(Debug, FromRow)]
#[derive(Clone, Debug, FromRow)]
struct RoleTreeRow {
#[sqlx(flatten)]
role: PgRole,
@ -73,9 +83,37 @@ struct RoleTreeRow {
}
impl RoleTree {
pub async fn fetch_members<'a, E: PgExecutor<'a>>(
pub fn members_of(role_oid: Oid) -> MembersOfQuery {
MembersOfQuery { role_oid }
}
pub fn granted_to(role_oid: Oid) -> GrantedToQuery {
GrantedToQuery { role_oid }
}
pub fn flatten_inherited(&self) -> Vec<&PgRole> {
[
vec![&self.role],
self.branches
.iter()
.filter(|member| member.inherit)
.map(|member| member.flatten_inherited())
.collect::<Vec<_>>()
.concat(),
]
.concat()
}
}
#[derive(Clone, Debug)]
pub struct MembersOfQuery {
role_oid: Oid,
client: E,
}
impl MembersOfQuery {
pub async fn fetch_tree(
self,
client: &mut BaseClient,
) -> Result<Option<RoleTree>, sqlx::Error> {
let rows: Vec<RoleTreeRow> = query_as(
"
@ -95,8 +133,8 @@ from (
join pg_roles on pg_roles.oid = subquery.roleid
",
)
.bind(role_oid)
.fetch_all(client)
.bind(self.role_oid)
.fetch_all(&mut *client.conn)
.await?;
Ok(rows
.iter()
@ -107,10 +145,17 @@ from (
inherit: root_row.inherit,
}))
}
}
pub async fn fetch_granted<'a, E: PgExecutor<'a>>(
#[derive(Clone, Debug)]
pub struct GrantedToQuery {
role_oid: Oid,
client: E,
}
impl GrantedToQuery {
pub async fn fetch_tree(
self,
client: &mut BaseClient,
) -> Result<Option<RoleTree>, sqlx::Error> {
let rows: Vec<RoleTreeRow> = query_as(
"
@ -130,8 +175,8 @@ from (
join pg_roles on pg_roles.oid = subquery.roleid
",
)
.bind(role_oid)
.fetch_all(client)
.bind(self.role_oid)
.fetch_all(&mut *client.conn)
.await?;
Ok(rows
.iter()
@ -142,19 +187,6 @@ from (
inherit: root_row.inherit,
}))
}
pub fn flatten_inherited(&self) -> Vec<&PgRole> {
[
vec![&self.role],
self.branches
.iter()
.filter(|member| member.inherit)
.map(|member| member.flatten_inherited())
.collect::<Vec<_>>()
.concat(),
]
.concat()
}
}
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {

View file

@ -1 +0,0 @@

View file

@ -5,8 +5,9 @@ use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use interim_models::client::AppDbClient;
use oauth2::basic::BasicClient;
use sqlx::{pool::PoolConnection, postgres::PgPoolOptions, Postgres};
use sqlx::postgres::PgPoolOptions;
use crate::{
app_error::AppError, auth, base_pooler::BasePooler, sessions::PgStore, settings::Settings,
@ -64,7 +65,7 @@ where
}
/// Extractor to automatically obtain a Deadpool Diesel connection
pub struct AppDbConn(pub PoolConnection<Postgres>);
pub struct AppDbConn(pub AppDbClient);
impl<S> FromRequestParts<S> for AppDbConn
where
@ -77,6 +78,6 @@ where
.app_db
.acquire()
.await?;
Ok(Self(conn))
Ok(Self(AppDbClient::from_pool_conn(conn)))
}
}

View file

@ -1,17 +1,19 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::{Context as _, Result};
use anyhow::Result;
use axum::extract::FromRef;
use sqlx::{pool::PoolConnection, postgres::PgPoolOptions, raw_sql, Executor, PgPool, Postgres};
use interim_models::{base::Base, client::AppDbClient};
use interim_pgtypes::client::BaseClient;
use sqlx::{Executor, PgPool, postgres::PgPoolOptions, raw_sql};
use tokio::sync::{OnceCell, RwLock};
use uuid::Uuid;
use crate::{app_state::AppState, bases::Base};
use crate::app_state::AppState;
const MAX_CONNECTIONS: u32 = 4;
const IDLE_SECONDS: u64 = 3600;
// NOTE: The Arc<RwLock> this uses will probably need to be cleaned up for
// TODO: The Arc<RwLock> this uses will probably need to be cleaned up for
// performance eventually.
/// A collection of multiple SQLx Pools.
@ -31,9 +33,8 @@ impl BasePooler {
async fn get_pool_for(&mut self, base_id: Uuid) -> Result<PgPool> {
let init_cell = || async {
let base = Base::fetch_by_id(base_id, &self.app_db)
.await?
.context("no such base")?;
let mut app_db = AppDbClient::from_pool_conn(self.app_db.acquire().await?);
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
Ok(PgPoolOptions::new()
.min_connections(0)
.max_connections(MAX_CONNECTIONS)
@ -78,9 +79,29 @@ discard sequences;
.clone())
}
pub async fn acquire_for(&mut self, base_id: Uuid) -> Result<PoolConnection<Postgres>> {
/// Note that while using `set role` simulates impersonation for most data
/// access and RLS purposes, it is both incomplete and easily reversible:
/// some commands and system tables will still behave according to the
/// privileges of the session user, and clients relying on this abstraction
/// should **NEVER** execute untrusted SQL.
pub async fn acquire_for(
&mut self,
base_id: Uuid,
set_role: RoleAssignment,
) -> Result<BaseClient> {
let mut app_db = AppDbClient::from_pool_conn(self.app_db.acquire().await?);
let pool = self.get_pool_for(base_id).await?;
Ok(pool.acquire().await?)
let mut client = BaseClient::from_pool_conn(pool.acquire().await?);
match set_role {
RoleAssignment::User(id) => {
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let prefix = base.user_role_prefix;
let user_id = id.simple();
client.init_role(&format!("{prefix}{user_id}")).await?;
}
RoleAssignment::Root => {}
}
Ok(client)
}
pub async fn close_for(&mut self, base_id: Uuid) -> Result<()> {
@ -109,3 +130,8 @@ where
Into::<AppState>::into(state.clone()).base_pooler.clone()
}
}
pub enum RoleAssignment {
Root,
User(Uuid),
}

View file

@ -1,16 +1,16 @@
use std::collections::HashSet;
use anyhow::{Context as _, Result};
use anyhow::Result;
use interim_models::{base::Base, client::AppDbClient};
use interim_pgtypes::{
client::BaseClient,
pg_acl::PgPrivilegeType,
pg_database::PgDatabase,
pg_role::{PgRole, RoleTree, user_id_from_rolname},
};
use sqlx::{PgConnection, query};
use sqlx::query;
use uuid::Uuid;
use crate::bases::Base;
pub struct BaseUserPerm {
pub id: Uuid,
pub base_id: Uuid,
@ -20,13 +20,13 @@ pub struct BaseUserPerm {
pub async fn sync_perms_for_base(
base_id: Uuid,
app_db: &mut PgConnection,
client: &mut PgConnection,
app_db: &mut AppDbClient,
base_client: &mut BaseClient,
) -> Result<()> {
let db = PgDatabase::fetch_current(&mut *client).await?;
let explicit_roles = PgRole::fetch_by_names_any(
let db = PgDatabase::current().fetch_one(base_client).await?;
let explicit_roles = PgRole::with_name_in(
db.datacl
.unwrap_or(vec![])
.unwrap_or_default()
.into_iter()
.filter(|item| {
item.privileges
@ -35,20 +35,21 @@ pub async fn sync_perms_for_base(
})
.map(|item| item.grantee)
.collect(),
&mut *client,
)
.fetch_all(base_client)
.await?;
let mut all_roles: HashSet<PgRole> = HashSet::new();
for explicit_role in explicit_roles {
if let Some(role_tree) = RoleTree::fetch_members(explicit_role.oid, &mut *client).await? {
if let Some(role_tree) = RoleTree::members_of(explicit_role.oid)
.fetch_tree(base_client)
.await?
{
for implicit_role in role_tree.flatten_inherited() {
all_roles.insert(implicit_role.clone());
}
}
}
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.context("base with that id not found")?;
let base = Base::with_id(base_id).fetch_one(app_db).await?;
let user_ids: Vec<Uuid> = all_roles
.iter()
.filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok())
@ -58,7 +59,7 @@ pub async fn sync_perms_for_base(
base_id,
user_ids.as_slice(),
)
.execute(&mut *app_db)
.execute(app_db.get_conn())
.await?;
for user_id in user_ids {
query!(
@ -72,7 +73,7 @@ on conflict (base_id, user_id, perm) do nothing
base.id,
user_id
)
.execute(&mut *app_db)
.execute(app_db.get_conn())
.await?;
}
Ok(())

View file

@ -1,75 +0,0 @@
use derive_builder::Builder;
use sqlx::{query_as, PgExecutor};
use uuid::Uuid;
pub struct Base {
pub id: Uuid,
pub name: String,
pub url: String,
pub owner_id: Uuid,
pub user_role_prefix: String,
}
impl Base {
pub fn insertable_builder() -> InsertableBaseBuilder {
InsertableBaseBuilder::default()
}
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
id: Uuid,
app_db: E,
) -> Result<Option<Base>, sqlx::Error> {
query_as!(Self, "select * from bases where id = $1", &id)
.fetch_optional(app_db)
.await
}
pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>(
user_id: Uuid,
perms: Vec<&str>,
app_db: E,
) -> Result<Vec<Base>, sqlx::Error> {
let perms = perms
.into_iter()
.map(ToOwned::to_owned)
.collect::<Vec<String>>();
query_as!(
Self,
"
select bases.*
from bases inner join base_user_perms as p
on p.base_id = bases.id
where p.user_id = $1 and perm = ANY($2)
",
user_id,
perms.as_slice(),
)
.fetch_all(app_db)
.await
}
}
#[derive(Builder)]
pub struct InsertableBase {
url: String,
owner_id: Uuid,
}
impl InsertableBase {
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Base, sqlx::Error> {
query_as!(
Base,
"
insert into bases
(id, url, owner_id)
values ($1, $2, $3)
returning *
",
Uuid::now_v7(),
self.url,
self.owner_id
)
.fetch_one(app_db)
.await
}
}

View file

@ -1,142 +0,0 @@
use std::fmt::Display;
use anyhow::Result;
use chrono::{DateTime, Utc};
use interim_models::selection::SelectionDisplayType;
use serde::{Deserialize, Serialize};
use sqlx::{
ColumnIndex, Decode, Encode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
error::BoxDynError,
postgres::{PgRow, PgTypeInfo, PgValueRef},
};
use uuid::Uuid;
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z";
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "t", content = "c")]
pub enum Value {
Text(Option<String>),
Integer(Option<i32>),
Timestamptz(Option<DateTime<Utc>>),
Uuid(Option<Uuid>),
}
pub trait ToHtmlString {
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String;
}
// TODO rewrite with thiserror
#[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: ColumnIndex<PgRow> + Display>(
row: &PgRow,
idx: I,
) -> Result<Self, BoxDynError> {
let value_ref = row.try_get_raw(idx)?;
Self::decode(value_ref)
}
pub fn webc_tag(&self) -> &'static str {
match self {
Self::Text(_) => "cell-text",
Self::Integer(_) => todo!(),
Self::Timestamptz(_) => todo!(),
Self::Uuid(_) => "cell-uuid",
}
}
pub fn as_json(&self) -> Result<String, serde_json::Error> {
match self {
Self::Text(value) => serde_json::to_string(&value),
Self::Integer(value) => serde_json::to_string(&value),
Self::Timestamptz(value) => serde_json::to_string(&value),
Self::Uuid(value) => serde_json::to_string(&value),
}
}
}
impl ToHtmlString for Value {
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
match self {
Self::Text(Some(value)) => value.clone(),
Self::Integer(Some(value)) => format!("{value}"),
Self::Timestamptz(_) => todo!(),
Self::Uuid(Some(value)) => value.to_string(),
_ => "-".to_owned(),
}
}
}
impl sqlx::Type<Postgres> for Value {
fn type_info() -> <Postgres as sqlx::Database>::TypeInfo {
PgTypeInfo::with_name("XXX");
todo!()
}
}
impl<'a> Decode<'a, Postgres> for Value {
fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> {
let type_info = value.type_info();
let ty = type_info.name();
match ty {
"INT" | "INT4" => Ok(Self::Integer(if value.is_null() {
None
} else {
Some(<i32 as Decode<Postgres>>::decode(value)?)
})),
"TEXT" | "VARCHAR" => Ok(Self::Text(if value.is_null() {
None
} else {
Some(<String as Decode<Postgres>>::decode(value)?)
})),
"TIMESTAMPTZ" => Ok(Self::Timestamptz(if value.is_null() {
None
} else {
Some(<DateTime<Utc> as Decode<Postgres>>::decode(value)?)
})),
"UUID" => Ok(Self::Uuid(if value.is_null() {
None
} else {
Some(<Uuid as Decode<Postgres>>::decode(value)?)
})),
_ => Err(Box::new(FromSqlError::new(
"unsupported pg type for interim Value",
))),
}
}
}
impl<'a> Encode<'a, Postgres> for Value {
fn encode_by_ref(
&self,
buf: &mut <Postgres as sqlx::Database>::ArgumentBuffer<'a>,
) -> std::result::Result<sqlx::encode::IsNull, BoxDynError> {
match self {
Self::Text(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf),
Self::Integer(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf),
Self::Timestamptz(value) => value.encode_by_ref(buf),
Self::Uuid(value) => value.encode_by_ref(buf),
}
}
}

View file

@ -1,31 +0,0 @@
use interim_pgtypes::escape_identifier;
use sqlx::{PgConnection, Row as _, query};
pub async fn init_role(rolname: &str, client: &mut PgConnection) -> Result<(), sqlx::Error> {
let session_user = query!("select session_user;")
.fetch_one(&mut *client)
.await?
.session_user
.unwrap();
if !query("select exists(select 1 from pg_roles where rolname = $1)")
.bind(rolname)
.fetch_one(&mut *client)
.await?
.try_get(0)?
{
query(&format!("create role {}", escape_identifier(rolname)))
.execute(&mut *client)
.await?;
query(&format!(
"grant {} to {}",
escape_identifier(rolname),
escape_identifier(&session_user),
))
.execute(&mut *client)
.await?;
}
query(&format!("set role {}", escape_identifier(rolname)))
.execute(&mut *client)
.await?;
Ok(())
}

View file

@ -1,33 +0,0 @@
use std::collections::HashMap;
use diesel::{
pg::{Pg,
row::{NamedRow, Row},
QueryableByName,
};
/// Internally a HashMap mapping field names to a custom sum type capable of
/// deserializing common SQL types. This allows Diesel to load rows without a
/// hard-coded structure.
pub struct FlexiRow {
internal: HashMap<String, FlexiField>,
}
/// Sum type representing a range of SQL data types.
pub enum FlexiField {
Text(String),
Int(i32),
Unknown,
}
impl QueryableByName<Pg> for FlexiRow {
fn build<'a>(row: &impl NamedRow<'a, Pg>) -> diesel::deserialize::Result<Self> {
let mut hm: HashMap<String, FlexiField> = HashMap::new();
for i in 0..row.field_count() {
if let Some(field) = diesel::row::Row::<'a, Pg>::get(&row, i) {
let name = field.field_name().or("Unnamed");
}
}
diesel::deserialize::Result::Ok(FlexiRow { internal: hm })
}
}

View file

@ -1,50 +0,0 @@
use anyhow::Result;
use sqlx::{postgres::types::Oid, query, query_as, PgConnection, Row as _};
use crate::abstract_::escape_identifier;
pub struct PgRole {
oid: Option<Oid>,
}
#[derive(Clone)]
pub struct DbSession {
conn: PgConnection,
}
impl DbSession {
pub async fn set_role(&mut self, rolname: &str) -> Result<()> {
if !query("select exists(select 1 from pg_roles where rolname = $1)")
.bind(&rolname)
.fetch_one(&mut self.conn)
.await?
.try_get(0)?
{
query(&format!("create role {}", escape_identifier(&rolname)))
.execute(&mut self.conn)
.await?;
}
query(&format!("set role {}", escape_identifier(&rolname)))
.execute(&mut self.conn)
.await?;
Ok(())
}
pub async fn get_users_with_db_connect(
&mut self,
user_role_prefix: &str,
) -> Result<Vec<PgRole>> {
Ok(query_as!(
PgRole,
"
select oid
from pg_roles
where has_database_privilege(rolname, current_database(), 'connect')
and starts_with(rolname, $1)
",
&user_role_prefix,
)
.fetch_all(&mut self.conn)
.await?)
}
}

View file

@ -1,72 +0,0 @@
use derive_builder::Builder;
use interim_pgtypes::pg_attribute::PgAttribute;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize)]
pub struct Selection {
pub id: Uuid,
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
pub label: Option<String>,
pub display_type: Option<SelectionDisplayType>,
pub visible: bool,
}
impl Selection {
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
if self.visible {
let mut filtered_attrs = all_attrs.to_owned();
for attr_filter in self.attr_filters.0.clone() {
filtered_attrs.retain(|attr| attr_filter.matches(attr));
}
filtered_attrs
.into_iter()
.map(|attr| Field {
name: attr.attname.clone(),
label: self.label.clone(),
display_type: self.display_type.clone(),
})
.collect()
} else {
vec![]
}
}
}
#[derive(Clone, Debug, Serialize, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum SelectionDisplayType {
Text,
InterimUser,
Timestamp,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum AttrFilter {
NameEq(String),
NameMatches(String),
TypeEq(String),
}
impl AttrFilter {
pub fn matches(&self, attr: &PgAttribute) -> bool {
match self {
Self::NameEq(name) => &attr.attname == name,
Self::NameMatches(pattern) => Regex::new(pattern)
.map(|re| re.is_match(&attr.attname))
.unwrap_or(false),
Self::TypeEq(_) => todo!("attr type filter is not yet implemented"),
}
}
}
/// A single column which can be passed to a front-end viewer. A Selection may
/// resolve to zero or more Fields.
#[derive(Clone, Debug, Serialize)]
pub struct Field {
pub name: String,
pub label: Option<String>,
pub display_type: Option<SelectionDisplayType>,
}

View file

@ -14,18 +14,15 @@ mod app_state;
mod auth;
mod base_pooler;
mod base_user_perms;
mod bases;
mod cli;
mod db_conns;
mod lenses;
mod middleware;
mod navbar;
mod navigator;
mod rel_invitations;
mod router;
mod routes;
mod sessions;
mod settings;
mod users;
mod user;
mod worker;
/// Run CLI

View file

@ -1,236 +0,0 @@
use std::collections::HashMap;
use axum::extract::FromRef;
use crate::app_state::AppState;
pub const NAVBAR_ITEM_TEAMS: &str = "teams";
pub const NAVBAR_ITEM_PROJECTS: &str = "projects";
pub const NAVBAR_ITEM_CHANNELS: &str = "channels";
pub const NAVBAR_ITEM_TEAM_MEMBERS: &str = "team-members";
#[derive(Clone, Debug)]
pub struct BreadcrumbTrail {
base_path: String,
breadcrumbs: Vec<Breadcrumb>,
}
impl BreadcrumbTrail {
/// Initialize with a non-empty base path.
pub fn from_base_path(base_path: &str) -> Self {
Self {
base_path: base_path.to_owned(),
breadcrumbs: Vec::new(),
}
}
/// Append an i18n path segment to the base path.
pub fn with_i18n_slug(mut self, language_code: &str) -> Self {
self.base_path.push('/');
self.base_path.push_str(language_code);
self
}
/// Add a breadcrumb by name and slug. If other breadcrumbs have already
/// been added, href will be generated by appending it to the previous href
/// as "<previous>/<slug>". Otherwise, it will be appended to the base path
/// with i18n slug (if any).
pub fn push_slug(mut self, label: &str, slug: &str) -> Self {
let href = if let Some(prev_breadcrumb) = self.iter().last() {
format!(
"{}/{}",
prev_breadcrumb.href,
percent_encoding::percent_encode(
slug.as_bytes(),
percent_encoding::NON_ALPHANUMERIC
)
)
} else {
format!("{}/{}", self.base_path, slug)
};
self.breadcrumbs.push(Breadcrumb {
label: label.to_owned(),
href,
});
self
}
pub fn iter(&self) -> std::slice::Iter<'_, Breadcrumb> {
self.breadcrumbs.iter()
}
/// Get an absolute URI path, starting from the child of the last
/// breadcrumb. For example, if the last breadcrumb has an href of
/// "/en/teams/team123" and the relative path is "../team456", the result
/// will be "/en/teams/team456". If no breadcrumbs exist, the base path
/// with i18n slug (if any) will be used.
pub fn join(&self, rel_path: &str) -> String {
let base = if let Some(breadcrumb) = self.iter().last() {
&breadcrumb.href
} else {
&self.base_path
};
let mut path_buf: Vec<&str> = base.split('/').collect();
for rel_segment in rel_path.split('/') {
if rel_segment == "." {
continue;
} else if rel_segment == ".." {
path_buf.pop();
} else {
path_buf.push(rel_segment);
}
}
path_buf.join("/")
}
}
impl IntoIterator for BreadcrumbTrail {
type Item = Breadcrumb;
type IntoIter = std::vec::IntoIter<Breadcrumb>;
fn into_iter(self) -> Self::IntoIter {
self.breadcrumbs.into_iter()
}
}
#[derive(Clone, Debug)]
pub struct Breadcrumb {
pub href: String,
pub label: String,
}
#[derive(Clone, Debug)]
pub struct NavbarBuilder {
base_path: String,
items: Vec<NavbarItem>,
active_item: Option<String>,
params: HashMap<String, String>,
}
impl NavbarBuilder {
pub fn new() -> Self {
Self {
base_path: "".to_owned(),
items: Vec::new(),
active_item: None,
params: HashMap::new(),
}
}
pub fn with_base_path(mut self, base_path: &str) -> Self {
self.base_path = base_path.to_owned();
self
}
/// Add a navbar item. Subpath is a path relative to the base path, and it
/// may contain placeholders for path params, such as "/{lang}/teams".
/// The navbar item will only be displayed if all corresponding path params
/// are registered using .with_param().
pub fn push_item(mut self, id: &str, label: &str, subpath: &str) -> Self {
self.items.push(NavbarItem {
id: id.to_owned(),
href: subpath.to_owned(),
label: label.to_owned(),
});
self
}
/// Registers a path param with the navbar builder.
pub fn with_param(mut self, k: &str, v: &str) -> Self {
self.params.insert(k.to_owned(), v.to_owned());
self
}
/// If a visible navbar item matches the provided ID, it will render as
/// active. Calling this method overrides any previously specified value.
pub fn with_active_item(mut self, item_id: &str) -> Self {
self.active_item = Some(item_id.to_owned());
self
}
pub fn build(self) -> Navbar {
let mut built_items: Vec<NavbarItem> = Vec::with_capacity(self.items.len());
for item in self.items {
let path_segments = item.href.split('/');
let substituted_segments: Vec<Option<&str>> = path_segments
.map(|segment| {
if segment.starts_with("{") && segment.ends_with("}") {
let param_k = segment[1..segment.len() - 1].trim();
self.params.get(param_k).map(|v| v.as_str())
} else {
Some(segment)
}
})
.collect();
if substituted_segments.iter().all(|segment| segment.is_some()) {
built_items.push(NavbarItem {
id: item.id,
href: format!(
"{}{}",
self.base_path,
substituted_segments
.into_iter()
.map(|segment| {
segment.expect(
"should already have checked that all path segments are Some",
)
})
.collect::<Vec<_>>()
.join("/")
),
label: item.label,
});
}
}
Navbar {
active_item: self.active_item,
items: built_items,
}
}
}
impl Default for NavbarBuilder {
fn default() -> Self {
Self::new()
.push_item(NAVBAR_ITEM_TEAMS, "Teams", "/en/teams")
.push_item(
NAVBAR_ITEM_PROJECTS,
"Projects",
"/en/teams/{team_id}/projects",
)
.push_item(
NAVBAR_ITEM_CHANNELS,
"Channels",
"/en/teams/{team_id}/channels",
)
.push_item(
NAVBAR_ITEM_TEAM_MEMBERS,
"Team Members",
"/en/teams/{team_id}/members",
)
}
}
impl<S> FromRef<S> for NavbarBuilder
where
S: Into<AppState> + Clone,
{
fn from_ref(state: &S) -> Self {
Into::<AppState>::into(state.clone())
.navbar_template
.clone()
}
}
#[derive(Clone, Debug)]
pub struct Navbar {
pub items: Vec<NavbarItem>,
pub active_item: Option<String>,
}
#[derive(Clone, Debug)]
pub struct NavbarItem {
pub href: String,
pub id: String,
pub label: String,
}

View file

@ -0,0 +1,108 @@
use std::collections::HashMap;
use anyhow::Result;
use askama::Template;
use derive_builder::Builder;
use interim_models::{base::Base, client::AppDbClient, lens::Lens};
use interim_pgtypes::{
client::BaseClient,
pg_class::{PgClass, PgRelKind},
};
use sqlx::postgres::types::Oid;
use uuid::Uuid;
#[derive(Builder, Clone, Template)]
#[template(path = "navbar.html")]
pub struct Navbar {
pub base: Base,
pub namespaces: Vec<NamespaceItem>,
#[builder(setter(strip_option))]
pub current: Option<NavLocation>,
pub root_path: String,
}
impl Navbar {
pub fn builder() -> NavbarBuilder {
NavbarBuilder::default()
}
}
#[derive(Clone, Debug)]
pub struct NamespaceItem {
pub name: String,
pub rels: Vec<RelItem>,
}
#[derive(Clone, Debug)]
pub struct RelItem {
pub name: String,
pub class_oid: Oid,
pub lenses: Vec<LensItem>,
}
#[derive(Clone, Debug)]
pub struct LensItem {
pub name: String,
pub id: Uuid,
}
#[derive(Clone, Debug, PartialEq)]
pub enum NavLocation {
Rel(Oid, Option<RelLocation>),
}
#[derive(Clone, Debug, PartialEq)]
pub enum RelLocation {
Lens(Uuid),
Rbac,
}
impl NavbarBuilder {
/// Helper function to populate relations and lenses automatically.
pub async fn populate_rels(
&mut self,
app_db: &mut AppDbClient,
base_client: &mut BaseClient,
) -> Result<&mut Self> {
let rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
.fetch_all(base_client)
.await?;
let mut namespaces: HashMap<String, Vec<RelItem>> = HashMap::new();
for rel in rels {
if rel.regnamespace.as_str() != "pg_catalog"
&& rel.regnamespace.as_str() != "information_schema"
{
let lenses = Lens::belonging_to_base(
self.base
.as_ref()
.ok_or(NavbarBuilderError::UninitializedField("base"))?
.id,
)
.belonging_to_rel(rel.oid)
.fetch_all(app_db)
.await?;
let rel_items = namespaces.entry(rel.regnamespace).or_default();
rel_items.push(RelItem {
name: rel.relname,
class_oid: rel.oid,
lenses: lenses
.into_iter()
.map(|lens| LensItem {
name: lens.name,
id: lens.id,
})
.collect(),
});
}
}
Ok(self.namespaces(
namespaces
.into_iter()
.map(|(name, rel_items)| NamespaceItem {
name,
rels: rel_items,
})
.collect(),
))
}
}

View file

@ -65,10 +65,6 @@ pub fn new_router(state: AppState) -> Router<()> {
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
get(routes::lenses::lens_page),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-lens",
post(routes::lenses::update_lens_page_post),
)
// .route(
// "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
// post(routes::lenses::add_selection_page_post),

View file

@ -5,15 +5,19 @@ use axum::{
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::base::Base;
use interim_pgtypes::escape_identifier;
use serde::Deserialize;
use sqlx::{query, query_scalar};
use uuid::Uuid;
use crate::{
app_error::AppError, app_state::AppDbConn, base_pooler::BasePooler,
base_user_perms::sync_perms_for_base, bases::Base, db_conns::init_role, settings::Settings,
users::CurrentUser,
app_error::AppError,
app_state::AppDbConn,
base_pooler::{self, BasePooler},
base_user_perms::sync_perms_for_base,
settings::Settings,
user::CurrentUser,
};
pub async fn list_bases_page(
@ -21,8 +25,9 @@ pub async fn list_bases_page(
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<Response, AppError> {
let bases =
Base::fetch_by_perm_any(current_user.id, vec!["configure", "connect"], &mut *app_db)
let bases = Base::with_permission_in(["configure", "connect"])
.for_user(current_user.id)
.fetch_all(&mut app_db)
.await?;
#[derive(Template)]
#[template(path = "list_bases.html")]
@ -43,7 +48,7 @@ pub async fn add_base_page(
.url("".to_owned())
.owner_id(current_user.id)
.build()?
.insert(&mut *app_db)
.insert(&mut app_db)
.await?;
query!(
"
@ -54,7 +59,7 @@ values ($1, $2, $3, 'configure')",
base.id,
current_user.id
)
.execute(&mut *app_db)
.execute(app_db.get_conn())
.await?;
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response())
}
@ -67,13 +72,11 @@ pub struct BaseConfigPagePath {
pub async fn base_config_page_get(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
CurrentUser(_current_user): CurrentUser,
Path(params): Path<BaseConfigPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::fetch_by_id(params.base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let base = Base::with_id(params.base_id).fetch_one(&mut app_db).await?;
#[derive(Template)]
#[template(path = "base_config.html")]
struct ResponseTemplate {
@ -99,39 +102,38 @@ pub async fn base_config_page_post(
) -> Result<Response, AppError> {
// FIXME: CSRF
// FIXME: auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
query!(
"update bases set name = $1, url = $2 where id = $3",
&form.name,
&form.url,
&base_id
)
.execute(&mut *app_db)
.execute(app_db.get_conn())
.await?;
if form.url != base.url {
base_pooler.close_for(base_id).await?;
let mut client = base_pooler.acquire_for(base.id).await?;
let rolname = format!("{}{}", base.user_role_prefix, current_user.id.simple());
// Bootstrap user role with database connect privilege. If the user was
// able to successfully authenticate a connection string, it should be
// safe to say that they should be allowed to connect as an Interim
// user.
init_role(&rolname, &mut client).await?;
let mut root_client = base_pooler
.acquire_for(base.id, base_pooler::RoleAssignment::Root)
.await?;
let db_name: String = query_scalar!("select current_database()")
.fetch_one(&mut *client)
.fetch_one(root_client.get_conn())
.await?
.context("unable to select current_database()")?;
query!("reset role").execute(&mut *client).await?;
query(&format!(
"grant connect on database {} to {}",
escape_identifier(&db_name),
escape_identifier(&rolname)
))
.execute(&mut *client)
.execute(root_client.get_conn())
.await?;
sync_perms_for_base(base.id, &mut app_db, &mut client).await?;
sync_perms_for_base(base.id, &mut app_db, &mut root_client).await?;
}
Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response())
}

View file

@ -4,18 +4,15 @@ use askama::Template;
use axum::{
Json,
extract::{Path, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::Form;
use interim_models::{
base::Base,
field::{Encodable, Field, FieldType, InsertableFieldBuilder, RFC_3339_S},
lens::{Lens, LensDisplayType},
};
use interim_pgtypes::{
escape_identifier,
pg_attribute::{PgAttribute, fetch_attributes_for_rel, fetch_primary_keys_for_rel},
pg_class::PgClass,
};
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
use serde::Deserialize;
use serde_json::json;
use sqlx::{
@ -25,14 +22,13 @@ use sqlx::{
use uuid::Uuid;
use crate::{
app_error::{AppError, bad_request, not_found},
app_error::{AppError, bad_request},
app_state::AppDbConn,
base_pooler::BasePooler,
bases::Base,
db_conns::init_role,
base_pooler::{BasePooler, RoleAssignment},
navbar::{NavLocation, Navbar, RelLocation},
navigator::Navigator,
settings::Settings,
users::CurrentUser,
user::CurrentUser,
};
#[derive(Deserialize)]
@ -47,7 +43,10 @@ pub async fn lenses_page(
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let lenses = Lens::fetch_by_rel(base_id, Oid(class_oid), &mut *app_db).await?;
let lenses = Lens::belonging_to_base(base_id)
.belonging_to_rel(Oid(class_oid))
.fetch_all(&mut app_db)
.await?;
#[derive(Template)]
#[template(path = "lenses.html")]
struct ResponseTemplate {
@ -100,18 +99,24 @@ pub struct AddLensPagePostForm {
}
pub async fn add_lens_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
Form(AddLensPagePostForm { name }): Form<AddLensPagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
let mut client = base_pooler.acquire_for(base_id).await?;
let mut client = base_pooler
.acquire_for(base_id, RoleAssignment::User(current_user.id))
.await?;
let attrs = fetch_attributes_for_rel(Oid(class_oid), &mut *client).await?;
let attrs = PgAttribute::all_for_rel(Oid(class_oid))
.fetch_all(&mut client)
.await?;
let lens = Lens::insertable_builder()
.base_id(base_id)
@ -119,14 +124,14 @@ pub async fn add_lens_page_post(
.name(name)
.display_type(LensDisplayType::Table)
.build()?
.insert(&mut *app_db)
.insert(&mut app_db)
.await?;
for attr in attrs {
InsertableFieldBuilder::default_from_attr(&attr)
.lens_id(lens.id)
.build()?
.insert(&mut *app_db)
.insert(&mut app_db)
.await?;
}
@ -145,37 +150,32 @@ pub async fn lens_page(
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
Path(LensPagePath {
lens_id,
base_id,
class_oid,
}): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await?
.ok_or(not_found!("lens not found"))?;
let base = Base::fetch_by_id(lens.base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with that id"))?;
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut client = base_pooler.acquire_for(lens.base_id).await?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_user.id.simple()),
&mut client,
)
let mut base_client = base_pooler
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
.await?;
let rel = PgClass::with_oid(lens.class_oid)
.fetch_one(&mut base_client)
.await?;
let class = PgClass::fetch_by_oid(lens.class_oid, &mut *client)
.await?
.ok_or(AppError::NotFound(
"no relation found with that oid".to_owned(),
))?;
let namespace = class.fetch_namespace(&mut *client).await?;
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await?
.ok_or(not_found!("no lens found with that id"))?;
let attrs = fetch_attributes_for_rel(lens.class_oid, &mut *client).await?;
let fields = lens.fetch_fields(&mut *app_db).await?;
let pkey_attrs = fetch_primary_keys_for_rel(lens.class_oid, &mut *client).await?;
let attrs = PgAttribute::all_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
let fields = Field::belonging_to_lens(lens.id)
.fetch_all(&mut app_db)
.await?;
let pkey_attrs = PgAttribute::pkeys_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
const FRONTEND_ROW_LIMIT: i64 = 1000;
let rows: Vec<PgRow> = query(&format!(
@ -186,11 +186,11 @@ pub async fn lens_page(
.map(|attr| escape_identifier(&attr.attname))
.collect::<Vec<_>>()
.join(", "),
escape_identifier(&namespace.nspname),
escape_identifier(&class.relname),
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname),
))
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(&mut *client)
.fetch_all(base_client.get_conn())
.await?;
let pkeys: Vec<HashMap<String, Encodable>> = rows
.iter()
@ -212,6 +212,7 @@ pub async fn lens_page(
rows: Vec<PgRow>,
pkeys: Vec<HashMap<String, Encodable>>,
settings: Settings,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
@ -219,6 +220,16 @@ pub async fn lens_page(
fields,
pkeys,
rows,
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(
Oid(class_oid),
Some(RelLocation::Lens(lens.id)),
))
.build()?,
settings,
}
.render()?,
@ -250,7 +261,6 @@ fn try_field_type_from_form(form: &AddColumnPageForm) -> Result<FieldType, AppEr
}
pub async fn add_column_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
@ -262,23 +272,16 @@ pub async fn add_column_page_post(
// FIXME csrf
// FIXME validate column name length is less than 64
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await?
.ok_or(not_found!("lens not found"))?;
let base = Base::fetch_by_id(lens.base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with that id"))?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let base = Base::with_id(lens.base_id).fetch_one(&mut app_db).await?;
let mut client = base_pooler.acquire_for(base.id).await?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_user.id.simple()),
&mut client,
)
let mut base_client = base_pooler
.acquire_for(base.id, RoleAssignment::User(current_user.id))
.await?;
let class = PgClass::fetch_by_oid(lens.class_oid, &mut *client)
.await?
.ok_or(not_found!("pg class not found"))?;
let class = PgClass::with_oid(lens.class_oid)
.fetch_one(&mut base_client)
.await?;
let field_type = try_field_type_from_form(&form)?;
let data_type_fragment = field_type.attr_data_type_fragment().ok_or(bad_request!(
@ -294,7 +297,7 @@ add column if not exists {1} {2}
escape_identifier(&form.name),
data_type_fragment
))
.execute(&mut *client)
.execute(base_client.get_conn())
.await?;
Field::insertable_builder()
@ -307,7 +310,7 @@ add column if not exists {1} {2}
})
.field_type(field_type)
.build()?
.insert(&mut *app_db)
.insert(&mut app_db)
.await?;
Ok(navigator.lens_page(&lens).redirect_to())
@ -350,28 +353,6 @@ add column if not exists {1} {2}
// .into_response())
// }
pub async fn update_lens_page_post(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id,
class_oid,
lens_id,
}): Path<LensPagePath>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
dbg!(&form);
// FIXME auth
// FIXME csrf
Ok(Redirect::to(&format!(
"{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
settings.root_path
))
.into_response())
}
#[derive(Deserialize)]
pub struct UpdateValuePageForm {
column: String,
@ -380,44 +361,37 @@ pub struct UpdateValuePageForm {
}
pub async fn update_value_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id,
class_oid,
lens_id: _,
base_id, class_oid, ..
}): Path<LensPagePath>,
Json(body): Json<UpdateValuePageForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with that id"))?;
let mut client = base_pooler.acquire_for(base_id).await?;
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
.await?
.ok_or(not_found!("unable to load table"))?;
let namespace = class.fetch_namespace(&mut *client).await?;
let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?;
let mut base_client = base_pooler
.acquire_for(base_id, RoleAssignment::User(current_user.id))
.await?;
let rel = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut base_client)
.await?;
let pkey_attrs = PgAttribute::pkeys_for_rel(rel.oid)
.fetch_all(&mut base_client)
.await?;
body.pkeys
.get(&pkey_attrs.first().unwrap().attname)
.unwrap()
.bind_onto(body.value.bind_onto(query(&format!(
r#"update {0}.{1} set {2} = $1 where {3} = $2"#,
escape_identifier(&namespace.nspname),
escape_identifier(&class.relname),
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname),
escape_identifier(&body.column),
escape_identifier(&pkey_attrs.first().unwrap().attname),
))))
.execute(&mut *client)
.execute(base_client.get_conn())
.await?;
Ok(Json(json!({ "ok": true })).into_response())

View file

@ -7,6 +7,7 @@ use axum::{
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::{base::Base, rel_invitation::RelInvitation, user::User};
use interim_pgtypes::{
pg_acl::PgPrivilegeType,
pg_class::{PgClass, PgRelKind},
@ -17,14 +18,11 @@ use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app_error::{AppError, not_found},
app_error::AppError,
app_state::AppDbConn,
base_pooler::BasePooler,
bases::Base,
db_conns::init_role,
rel_invitations::RelInvitation,
base_pooler::{self, BasePooler},
settings::Settings,
users::{CurrentUser, User},
user::CurrentUser,
};
#[derive(Deserialize)]
@ -40,16 +38,18 @@ pub async fn list_relations_page(
Path(ListRelationsPagePath { base_id }): Path<ListRelationsPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let mut client = base_pooler.acquire_for(base_id).await?;
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
init_role(&rolname, &mut client).await?;
let mut client = base_pooler
.acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id))
.await?;
let roles = PgRole::fetch_by_names_any(vec![rolname], &mut *client).await?;
let roles = PgRole::with_name_in(vec![rolname])
.fetch_all(&mut client)
.await?;
let role = roles.first().context("role not found in pg_roles")?;
let granted_role_tree = RoleTree::fetch_granted(role.oid, &mut *client)
let granted_role_tree = RoleTree::granted_to(role.oid)
.fetch_tree(&mut client)
.await?
.context("unable to construct role tree")?;
let granted_roles: HashSet<String> = granted_role_tree
@ -58,7 +58,9 @@ pub async fn list_relations_page(
.map(|role| role.rolname.clone())
.collect();
let all_rels = PgClass::fetch_all_by_kind_any([PgRelKind::OrdinaryTable], &mut *client).await?;
let all_rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
.fetch_all(&mut client)
.await?;
let accessible_rels: Vec<PgClass> = all_rels
.into_iter()
.filter(|rel| {
@ -117,15 +119,13 @@ pub async fn rel_rbac_page(
Path(RelPagePath { base_id, class_oid }): Path<RelPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with id {}", base_id))?;
let mut client = base_pooler.acquire_for(base_id).await?;
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
init_role(&rolname, &mut client).await?;
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
.await?
.ok_or(not_found!("no relation found with oid {}", class_oid))?;
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let mut client = base_pooler
.acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id))
.await?;
let class = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut client)
.await?;
let user_ids: Vec<Uuid> = class
.relacl
.clone()
@ -133,7 +133,7 @@ pub async fn rel_rbac_page(
.iter()
.filter_map(|item| user_id_from_rolname(&item.grantee, &base.user_role_prefix).ok())
.collect();
let all_users = User::fetch_by_ids_any(user_ids, &mut *app_db).await?;
let all_users = User::with_id_in(user_ids).fetch_all(&mut app_db).await?;
let interim_users: HashMap<String, User> = all_users
.into_iter()
.map(|user| {
@ -144,7 +144,9 @@ pub async fn rel_rbac_page(
})
.collect();
let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
let all_invites = RelInvitation::belonging_to_rel(Oid(class_oid))
.fetch_all(&mut app_db)
.await?;
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
for invite in all_invites {
let entry = invites_by_email.entry(invite.email.clone()).or_default();
@ -215,7 +217,7 @@ pub async fn rel_rbac_invite_page_post(
.privilege(privilege)
.created_by(current_user.id)
.build()?
.upsert(&mut *app_db)
.upsert(&mut app_db)
.await?;
}
Ok(Redirect::to(&format!(

View file

@ -1,23 +0,0 @@
// @generated automatically by Diesel CLI.
diesel::table! {
browser_sessions (id) {
id -> Text,
serialized -> Text,
created_at -> Timestamptz,
expiry -> Nullable<Timestamptz>,
}
}
diesel::table! {
users (id) {
id -> Uuid,
uid -> Text,
email -> Text,
}
}
diesel::allow_tables_to_appear_in_same_query!(
browser_sessions,
users,
);

View file

@ -1,14 +1,14 @@
use anyhow::Result;
use async_session::{async_trait, Session, SessionStore};
use async_session::{Session, SessionStore, async_trait};
use axum::{
RequestPartsExt as _,
extract::{FromRef, FromRequestParts},
http::request::Parts,
RequestPartsExt as _,
};
use axum_extra::extract::CookieJar;
use chrono::{DateTime, TimeDelta, Utc};
use sqlx::{query, query_as, Executor, PgPool};
use tracing::{trace_span, Instrument};
use sqlx::{PgPool, query, query_as};
use tracing::{Instrument, trace_span};
use crate::{app_error::AppError, app_state::AppState};

View file

@ -1,15 +1,16 @@
use async_session::{Session, SessionStore as _};
use axum::{
extract::{FromRequestParts, OriginalUri},
http::{request::Parts, Method},
response::{IntoResponse, Redirect, Response},
RequestPartsExt,
extract::{FromRequestParts, OriginalUri},
http::{Method, request::Parts},
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::{
cookie::{Cookie, SameSite},
CookieJar,
cookie::{Cookie, SameSite},
};
use sqlx::{query_as, PgExecutor};
use interim_models::user::User;
use sqlx::query_as;
use uuid::Uuid;
use crate::{
@ -19,33 +20,6 @@ use crate::{
sessions::AppSession,
};
#[derive(Clone, Debug)]
pub struct User {
pub id: Uuid,
pub uid: String,
pub email: String,
}
impl User {
pub async fn fetch_by_ids_any<'a, I: IntoIterator<Item = Uuid>, E: PgExecutor<'a>>(
ids: I,
app_db: E,
) -> Result<Vec<Self>, sqlx::Error> {
let ids: Vec<Uuid> = ids.into_iter().collect();
query_as!(
Self,
"
select * from users
where id = any($1)
",
ids.as_slice()
)
.fetch_all(app_db)
.await
.map_err(Into::into)
}
}
#[derive(Clone, Debug)]
pub struct CurrentUser(pub User);

View file

@ -3,8 +3,6 @@
<head>
<title>{% block title %}Interim{% endblock %}</title>
{% include "meta_tags.html" %}
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
<link rel="stylesheet" href="{{ settings.root_path }}/main.css">
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/main.css">
</head>
<body>

View file

@ -2,16 +2,22 @@
{% block main %}
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
<div class="page-grid">
<div class="page-grid__toolbar"></div>
<div class="page-grid__sidebar">
{{ navbar | safe }}
</div>
<main class="page-grid__main">
<viewer-controller root-path="{{ settings.root_path }}" pkeys="{{ pkeys | json }}" fields="{{ fields | json }}">
<table class="viewer">
<table class="viewer-table">
<thead>
<tr>
{% for field in fields %}
<th width="{{ field.width_px }}">
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
<th class="viewer-table__column-header" width="{{ field.width_px }}">
{{ field.label.clone().unwrap_or(field.name.clone()) }}
</th>
{% endfor %}
<th class="column-adder">
<th class="viewer-table__actions-header">
<field-adder root-path="{{ settings.root_path }}" columns="{{ all_columns | json }}"></field-adder>
</th>
</tr>
@ -22,7 +28,10 @@
<tr>
{% for (j, field) in fields.iter().enumerate() %}
{# Setting max-width is required for overflow to work properly. #}
<td style="width: {{ field.width_px }}px; max-width: {{ field.width_px }}px;">
<td
class="viewer-table__td"
style="width: {{ field.width_px }}px; max-width: {{ field.width_px }}px;"
>
{% match field.get_value_encodable(row) %}
{% when Ok with (encodable) %}
<{{ field.webc_tag() | safe }}
@ -47,6 +56,8 @@
</table>
<viewer-hoverbar root-path="{{ settings.root_path }}"></viewer-hoverbar>
</viewer-controller>
</main>
</div>
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>

View file

@ -0,0 +1,87 @@
<link rel="stylesheet" href="{{ root_path }}/css_dist/navbar.css">
<nav class="navbar">
<button type="button" class="base-switcher">
<div>{{ base.name }}</div>
{#- TODO: icon #}
</button>
<section>
<h2 class="navbar__heading">Tables</h2>
<menu class="navbar__menu">
{%- for schema in namespaces %}
<li class="navbar__menu-item">
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
<h3 slot="summary" class="navbar__heading navbar__heading--entity">
{{ schema.name }}
</h3>
<menu slot="content" class="navbar__menu">
{%- for rel in schema.rels %}
<li class="navbar__menu-item
{%- if current == Some(NavLocation::Rel(rel.class_oid.to_owned(), None)) -%}
{# preserve space #} navbar__menu-item--active
{%- endif -%}
">
<collapsible-menu
class="navbar__collapsible-menu"
root-path="{{ root_path }}"
expanded="
{%- if let Some(NavLocation::Rel(rel_oid, _)) = current -%}
{%- if rel_oid.to_owned() == rel.class_oid -%}
true
{%- endif -%}
{%- endif -%}
"
>
<h4 slot="summary" class="navbar__heading navbar__heading--entity">
{{ rel.name }}
</h4>
<menu slot="content" class="navbar__menu">
<li class="navbar__menu-item">
<a
href="{{ root_path }}/d/{{ base.id.simple() }}/r/{{ rel.class_oid.0 }}/rbac"
class="navbar__menu-link"
>
Sharing
</a>
</li>
<li class="navbar__menu-item">
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
<h5 slot="summary" class="navbar__heading">Interfaces</h5>
<menu slot="content" class="navbar__menu">
{% for lens in rel.lenses %}
<li class="navbar__menu-item
">
<a
href="
{{- root_path -}}
/d/
{{- base.id.simple() -}}
/r/
{{- rel.class_oid.0 -}}
/l/
{{- lens.id.simple() -}}
"
class="navbar__menu-link navbar__menu-link--entity
{%- if current == Some(NavLocation::Rel(rel.class_oid.to_owned(), Some(RelLocation::Lens(lens.id.to_owned())))) -%}
{# preserve space #} navbar__menu-link--current
{%- endif -%}
"
>
{{ lens.name }}
</a>
</li>
{% endfor %}
</menu>
</collapsible-menu>
</li>
</menu>
</collapsible-menu>
</li>
{% endfor -%}
</menu>
</collapsible-menu>
</li>
{% endfor -%}
</menu>
</section>
<script type="module" src="{{ root_path }}/js_dist/collapsible_menu_component.mjs"></script>
</nav>

View file

@ -10,11 +10,13 @@ $popover-border: $default-border;
$popover-shadow: 0 0.5rem 0.5rem #3333;
$border-radius-rounded-sm: 0.25rem;
$border-radius-rounded: 0.5rem;
$link-color: #069;
@mixin reset-button {
appearance: none;
background: none;
border: none;
padding: 0;
box-sizing: border-box;
cursor: pointer;
font-family: inherit;

View file

@ -0,0 +1,20 @@
@use 'globals';
.collapsible-menu {
&__summary {
@include globals.reset-button;
}
&__content {
overflow: hidden;
max-height: 0;
transition: max-height 0.3s ease-in;
padding-left: 0.5rem;
&--expanded {
// todo: adjust max-height dynamically based on content
max-height: 40rem;
transition: max-height 0.3s ease-out;
}
}
}

View file

@ -1 +1,58 @@
@use 'globals';
@use 'modern-normalize';
html {
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
}
button, input[type="submit"] {
font-family: inherit;
}
@font-face {
font-family: "Averia Serif Libre";
src: url("../averia_serif_libre/averia_serif_libre_regular.ttf");
}
@font-face {
font-family: "Averia Serif Libre";
src: url("../averia_serif_libre/averia_serif_libre_bold.ttf");
font-weight: 700;
}
@font-face {
font-family: "Averia Serif Libre";
src: url("../averia_serif_libre/averia_serif_libre_light.ttf");
font-weight: 300;
}
@font-face {
font-family: "Funnel Sans";
src: url("../funnel_sans/funnel_sans_variable.ttf");
}
.page-grid {
height: 100vh;
width: 100vw;
display: grid;
grid-template:
'sidebar toolbar' 4rem
'sidebar main' 1fr / max-content 1fr;
&__toolbar {
grid-area: toolbar;
border-bottom: globals.$default-border;
}
&__sidebar {
grid-area: sidebar;
width: 15rem;
max-height: 100vh;
overflow: auto;
border-right: globals.$default-border;
}
&__main {
grid-area: main;
}
}

45
sass/navbar.scss Normal file
View file

@ -0,0 +1,45 @@
@use 'globals';
$background-current-item: #0001;
.navbar {
padding: 2rem;
&__menu {
list-style-type: none;
padding: 0;
margin-bottom: 0;
}
&__heading {
font-size: inherit;
margin: 0;
padding: 0.5rem;
&--entity {
font-family: globals.$font-family-data;
}
}
&__menu-link {
@include globals.rounded-sm;
display: block;
padding: 0.5rem;
color: globals.$link-color;
text-decoration: none;
&--entity {
font-family: globals.$font-family-data;
}
&--current {
background: $background-current-item;
}
}
}
.base-switcher {
@include globals.reset-button;
font-family: globals.$font-family-data;
padding: 1rem;
}

View file

@ -1,38 +1,34 @@
table.viewer {
.viewer-table {
border-collapse: collapse;
height: 1px; /* css hack to make percentage based cell heights work */
}
height: 1px; // css hack to make percentage based cell heights work
table.viewer > thead > tr > th {
&__column-header {
border: solid 1px #ccc;
border-top: none;
font-family: "Funnel Sans";
background: #0001;
height: 100%; /* css hack to make percentage based cell heights work */
padding: 0 0.5rem;
height: 100%; // css hack to make percentage based cell heights work
padding: 0.5rem;
text-align: left;
&:first-child {
border-left: none;
}
}
&.column-adder {
&__actions-header {
border: none;
background: none;
padding: 0;
}
}
table.viewer .padded-cell {
padding: 0.5rem;
}
table.viewer > tbody > tr > td {
&__td {
border: solid 1px #ccc;
height: 100%; /* css hack to make percentage based cell heights work */
height: 100%; // css hack to make percentage based cell heights work
padding: 0;
&:first-child {
border-left: none;
}
}
}

View file

@ -1,18 +0,0 @@
field-adder {
--popover-border: solid 1px #ccc;
--popover-shadow: 0 0.5rem 0.5rem #3333;
height: 100%;
& button.expander {
appearance: none;
border: none;
width: 100%;
height: 100%;
font-weight: inherit;
font-size: inherit;
font-family: inherit;
cursor: pointer;
background: none;
}
}

View file

@ -1,29 +0,0 @@
html {
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
}
button, input[type="submit"] {
font-family: inherit;
}
@font-face {
font-family: "Averia Serif Libre";
src: url("./averia_serif_libre/averia_serif_libre_regular.ttf");
}
@font-face {
font-family: "Averia Serif Libre";
src: url("./averia_serif_libre/averia_serif_libre_bold.ttf");
font-weight: 700;
}
@font-face {
font-family: "Averia Serif Libre";
src: url("./averia_serif_libre/averia_serif_libre_light.ttf");
font-weight: 300;
}
@font-face {
font-family: "Funnel Sans";
src: url("./funnel_sans/funnel_sans_variable.ttf");
}

View file

@ -1,46 +0,0 @@
table.viewer {
border-collapse: collapse;
height: 1px; /* css hack to make percentage based cell heights work */
}
table.viewer > thead > tr > th {
border: solid 1px #ccc;
border-top: none;
font-family: "Funnel Sans";
background: #0001;
height: 100%; /* css hack to make percentage based cell heights work */
padding: 0 0.5rem;
text-align: left;
&:first-child {
border-left: none;
}
&.column-adder {
border: none;
background: none;
padding: 0;
}
}
table.viewer .padded-cell {
padding: 0.5rem;
}
table.viewer .clickable-header-cell {
appearance: none;
border: none;
width: 100%;
height: 100%;
font-weight: inherit;
font-size: inherit;
font-family: inherit;
}
table.viewer > tbody > tr > td {
border: solid 1px #ccc;
&:first-child {
border-left: none;
}
}

View file

@ -0,0 +1,85 @@
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/json
import gleam/regexp
import gleam/result
import gleam/string
import lustre.{type App}
import lustre/attribute as attr
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event
pub const name: String = "collapsible-menu"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
}),
component.on_attribute_change("expanded", fn(value) {
ParentChangedExpanded(value == "true") |> Ok
}),
])
}
pub type Model {
Model(root_path: String, expanded: Bool)
}
fn init(_) -> #(Model, Effect(Msg)) {
#(Model(root_path: "", expanded: True), effect.none())
}
pub type Msg {
ParentChangedRootPath(String)
ParentChangedExpanded(Bool)
UserClickedSummary
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
ParentChangedRootPath(root_path) -> #(
Model(..model, root_path:),
effect.none(),
)
ParentChangedExpanded(expanded) -> #(
Model(..model, expanded:),
effect.none(),
)
UserClickedSummary -> #(
Model(..model, expanded: !model.expanded),
effect.none(),
)
}
}
fn view(model: Model) -> Element(Msg) {
element.fragment([
html.link([
attr.rel("stylesheet"),
attr.href(model.root_path <> "/css_dist/collapsible_menu.css"),
]),
html.div([attr.class("collapsible-menu")], [
html.button(
[
attr.class("collapsible-menu__summary"),
event.on_click(UserClickedSummary),
],
[component.named_slot("summary", [], [])],
),
html.div(
[
attr.class("collapsible-menu__content"),
case model.expanded {
True -> attr.class("collapsible-menu__content--expanded")
False -> attr.none()
},
],
[component.named_slot("content", [], [])],
),
]),
])
}

View file

@ -15,7 +15,7 @@ export function overwriteInHoverbar(value) {
export function clearSelectedAttrs() {
document.querySelectorAll(
"table.viewer > tbody > tr > td > [selected='true']",
".viewer-table__td > [selected='true']",
)
.forEach((element) => element.setAttribute("selected", ""));
}
@ -40,7 +40,7 @@ export function syncCellValueToHoverbar(row, column, fieldType) {
}
function queryCell(row, column) {
const tr = document.querySelectorAll("table.viewer > tbody > tr")[row];
const tr = document.querySelectorAll(".viewer-table > tbody > tr")[row];
if (tr) {
return [...tr.querySelectorAll(":scope > td > *")][column];
}