From 53b4dfa13010ab7ace3110669d856852bc135364 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Mon, 4 Aug 2025 13:59:42 -0700 Subject: [PATCH] refactor db clients --- Dockerfile | 6 +- interim-models/sqlizable.rs | 12 - interim-models/src/base.rs | 113 +++++++++ interim-models/src/client.rs | 15 ++ interim-models/src/field.rs | 61 +++-- interim-models/src/lens.rs | 114 +++++---- interim-models/src/lib.rs | 5 +- .../src/rel_invitation.rs | 50 ++-- interim-models/src/user.rs | 38 +++ interim-pgtypes/src/client.rs | 55 ++++ interim-pgtypes/src/lib.rs | 1 + interim-pgtypes/src/pg_attribute.rs | 66 +++-- interim-pgtypes/src/pg_class.rs | 110 +++++--- interim-pgtypes/src/pg_database.rs | 19 +- interim-pgtypes/src/pg_role.rs | 94 ++++--- interim-server/src/abstract_.rs | 1 - interim-server/src/app_state.rs | 7 +- interim-server/src/base_pooler.rs | 44 +++- interim-server/src/base_user_perms.rs | 33 +-- interim-server/src/bases.rs | 75 ------ interim-server/src/data_layer.rs | 142 ----------- interim-server/src/db_conns.rs | 31 --- interim-server/src/flexi_row.rs | 33 --- interim-server/src/iclient.rs | 50 ---- interim-server/src/lenses.rs | 72 ------ interim-server/src/main.rs | 7 +- interim-server/src/nav.rs | 236 ------------------ interim-server/src/navbar.rs | 108 ++++++++ interim-server/src/router.rs | 4 - interim-server/src/routes/bases.rs | 46 ++-- interim-server/src/routes/lenses.rs | 186 ++++++-------- interim-server/src/routes/relations.rs | 54 ++-- interim-server/src/schema.rs | 23 -- interim-server/src/sessions.rs | 8 +- interim-server/src/{users.rs => user.rs} | 38 +-- interim-server/templates/base.html | 2 - interim-server/templates/lens.html | 99 ++++---- interim-server/templates/navbar.html | 87 +++++++ sass/_globals.scss | 2 + .../_modern-normalize.css | 0 sass/collapsible_menu.scss | 20 ++ sass/main.scss | 57 +++++ sass/navbar.scss | 45 ++++ sass/viewer.scss | 46 ++-- static/css/field_adder.css | 18 -- static/main.css | 29 --- static/viewer.css | 46 ---- webc/src/collapsible_menu_component.gleam | 85 +++++++ webc/src/viewer_controller_component.ffi.mjs | 4 +- 49 files changed, 1241 insertions(+), 1256 deletions(-) delete mode 100644 interim-models/sqlizable.rs create mode 100644 interim-models/src/base.rs create mode 100644 interim-models/src/client.rs rename interim-server/src/rel_invitations.rs => interim-models/src/rel_invitation.rs (70%) create mode 100644 interim-models/src/user.rs create mode 100644 interim-pgtypes/src/client.rs delete mode 100644 interim-server/src/abstract_.rs delete mode 100644 interim-server/src/bases.rs delete mode 100644 interim-server/src/data_layer.rs delete mode 100644 interim-server/src/db_conns.rs delete mode 100644 interim-server/src/flexi_row.rs delete mode 100644 interim-server/src/iclient.rs delete mode 100644 interim-server/src/lenses.rs delete mode 100644 interim-server/src/nav.rs create mode 100644 interim-server/src/navbar.rs delete mode 100644 interim-server/src/schema.rs rename interim-server/src/{users.rs => user.rs} (87%) create mode 100644 interim-server/templates/navbar.html rename static/modern-normalize.min.css => sass/_modern-normalize.css (100%) create mode 100644 sass/collapsible_menu.scss create mode 100644 sass/navbar.scss delete mode 100644 static/css/field_adder.css delete mode 100644 static/main.css delete mode 100644 static/viewer.css create mode 100644 webc/src/collapsible_menu_component.gleam diff --git a/Dockerfile b/Dockerfile index ceabbf8..762a0f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/interim-models/sqlizable.rs b/interim-models/sqlizable.rs deleted file mode 100644 index d24dc13..0000000 --- a/interim-models/sqlizable.rs +++ /dev/null @@ -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), - Uuid(Uuid), -} - -impl Encode<'a, Postgres> for Sqlizable {} diff --git a/interim-models/src/base.rs b/interim-models/src/base.rs new file mode 100644 index 0000000..ac0a26a --- /dev/null +++ b/interim-models/src/base.rs @@ -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>( + perms: I, + ) -> WithPermissionInQueryPartial { + let perms: Vec = perms.into_iter().map(ToOwned::to_owned).collect(); + WithPermissionInQueryPartial { perms } + } +} + +pub struct WithPermissionInQueryPartial { + perms: Vec, +} + +impl WithPermissionInQueryPartial { + pub fn for_user(self, user_id: Uuid) -> WithPermissionInQuery { + WithPermissionInQuery { + perms: self.perms, + user_id, + } + } +} + +pub struct WithPermissionInQuery { + perms: Vec, + user_id: Uuid, +} + +impl WithPermissionInQuery { + pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result, 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, 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 { + 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 { + 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 + } +} diff --git a/interim-models/src/client.rs b/interim-models/src/client.rs new file mode 100644 index 0000000..6830a24 --- /dev/null +++ b/interim-models/src/client.rs @@ -0,0 +1,15 @@ +use sqlx::{PgConnection, Postgres, pool::PoolConnection}; + +pub struct AppDbClient { + pub(crate) conn: PoolConnection, +} + +impl AppDbClient { + pub fn from_pool_conn(conn: PoolConnection) -> Self { + Self { conn } + } + + pub fn get_conn(&mut self) -> &mut PgConnection { + &mut self.conn + } +} diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index 305ab4c..3bb0ef8 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -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 { 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, sqlx::Error> { + query_as!( + Field, + r#" +select + id, + name, + label, + field_type as "field_type: sqlx::types::Json", + 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 { + pub async fn insert(self, app_db: &mut AppDbClient) -> Result { query_as!( Field, r#" @@ -169,7 +178,7 @@ returning sqlx::types::Json::<_>(self.field_type) as sqlx::types::Json, self.width_px, ) - .fetch_one(app_db) + .fetch_one(&mut *app_db.conn) .await } } diff --git a/interim-models/src/lens.rs b/interim-models/src/lens.rs index 6d6aac9..ef2d3cc 100644 --- a/interim-models/src/lens.rs +++ b/interim-models/src/lens.rs @@ -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>>( - id: Uuid, - app_db: E, - ) -> Result, sqlx::Error> { + 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, +} + +impl WithIdQuery { + pub async fn fetch_optional( + self, + app_db: &mut AppDbClient, + ) -> Result, 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>>( - base_id: Uuid, - rel_oid: Oid, - app_db: E, - ) -> Result, sqlx::Error> { + pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result { query_as!( - Self, + 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, +} + +impl BelongingToRelQuery { + pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result, sqlx::Error> { + query_as!( + 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, sqlx::Error> { - query_as!( - Field, - r#" -select - id, - name, - label, - field_type as "field_type: sqlx::types::Json", - 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 { + pub async fn insert(self, app_db: &mut AppDbClient) -> Result { 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 } } diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs index 634a4e4..4656507 100644 --- a/interim-models/src/lib.rs +++ b/interim-models/src/lib.rs @@ -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!(); diff --git a/interim-server/src/rel_invitations.rs b/interim-models/src/rel_invitation.rs similarity index 70% rename from interim-server/src/rel_invitations.rs rename to interim-models/src/rel_invitation.rs index 9e21ae7..d18d089 100644 --- a/interim-server/src/rel_invitations.rs +++ b/interim-models/src/rel_invitation.rs @@ -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, 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, 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 { + pub async fn upsert(self, app_db: &mut AppDbClient) -> Result { 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 } } diff --git a/interim-models/src/user.rs b/interim-models/src/user.rs new file mode 100644 index 0000000..3de5feb --- /dev/null +++ b/interim-models/src/user.rs @@ -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>(ids: I) -> WithIdInQuery { + let ids: Vec = ids.into_iter().collect(); + WithIdInQuery { ids } + } +} + +#[derive(Clone, Debug)] +pub struct WithIdInQuery { + ids: Vec, +} + +impl WithIdInQuery { + pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result, sqlx::Error> { + query_as!( + User, + " +select * from users +where id = any($1) +", + self.ids.as_slice() + ) + .fetch_all(&mut *app_db.conn) + .await + } +} diff --git a/interim-pgtypes/src/client.rs b/interim-pgtypes/src/client.rs new file mode 100644 index 0000000..e38d1f3 --- /dev/null +++ b/interim-pgtypes/src/client.rs @@ -0,0 +1,55 @@ +use sqlx::{PgConnection, Postgres, Row as _, pool::PoolConnection, query}; + +use crate::escape_identifier; + +pub struct BaseClient { + pub(crate) conn: PoolConnection, +} + +impl BaseClient { + pub fn from_pool_conn(conn: PoolConnection) -> 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(()) + } +} diff --git a/interim-pgtypes/src/lib.rs b/interim-pgtypes/src/lib.rs index 8a742e3..f37d9e8 100644 --- a/interim-pgtypes/src/lib.rs +++ b/interim-pgtypes/src/lib.rs @@ -1,3 +1,4 @@ +pub mod client; pub mod pg_acl; pub mod pg_attribute; pub mod pg_class; diff --git a/interim-pgtypes/src/pg_attribute.rs b/interim-pgtypes/src/pg_attribute.rs index fbdba05..867395f 100644 --- a/interim-pgtypes/src/pg_attribute.rs +++ b/interim-pgtypes/src/pg_attribute.rs @@ -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,13 +39,26 @@ pub struct PgAttribute { pub attfdwoptions: Option>, } -pub async fn fetch_attributes_for_rel<'a, E: PgExecutor<'a>>( - oid: Oid, - client: E, -) -> Result, sqlx::Error> { - query_as!( - PgAttribute, - r#" +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, sqlx::Error> { + query_as!( + PgAttribute, + r#" select attrelid, attname, @@ -63,19 +78,23 @@ select from pg_attribute where attrelid = $1 and attnum > 0 and not attisdropped "#, - &oid - ) - .fetch_all(client) - .await + &self.rel_oid + ) + .fetch_all(&mut *client.conn) + .await + } } -pub async fn fetch_primary_keys_for_rel<'a, E: PgExecutor<'a>>( - oid: Oid, - client: E, -) -> Result, sqlx::Error> { - query_as!( - PgAttribute, - r#" +#[derive(Clone, Debug)] +pub struct PkeysForRelQuery { + rel_oid: Oid, +} + +impl PkeysForRelQuery { + pub async fn fetch_all(self, client: &mut BaseClient) -> Result, sqlx::Error> { + query_as!( + PgAttribute, + r#" select a.attrelid as attrelid, a.attname as attname, @@ -98,8 +117,9 @@ from pg_attribute a and a.attnum = any(i.indkey) where i.indrelid = $1 and i.indisprimary; "#, - &oid - ) - .fetch_all(client) - .await + &self.rel_oid + ) + .fetch_all(&mut *client.conn) + .await + } } diff --git a/interim-pgtypes/src/pg_class.rs b/interim-pgtypes/src/pg_class.rs index 621868c..64796e6 100644 --- a/interim-pgtypes/src/pg_class.rs +++ b/interim-pgtypes/src/pg_class.rs @@ -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>>( - oid: Oid, - client: E, - ) -> Result, sqlx::Error> { + pub async fn fetch_namespace( + &self, + client: &mut BaseClient, + ) -> Result { + 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>(kinds: I) -> WithKindInQuery { + WithKindInQuery { + kinds: kinds.into_iter().collect(), + } + } +} + +pub struct WithOidQuery { + oid: Oid, +} + +// 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) - .await + }; +} + +impl WithOidQuery { + pub async fn fetch_one(self, client: &mut BaseClient) -> Result { + with_oid_sqlx_query!(self.oid) + .fetch_one(&mut *client.conn) + .await } - pub async fn fetch_all_by_kind_any<'a, I: IntoIterator, E: PgExecutor<'a>>( - kinds: I, - client: E, - ) -> Result, sqlx::Error> { - let kinds_i8 = kinds + pub async fn fetch_optional( + self, + client: &mut BaseClient, + ) -> Result, sqlx::Error> { + with_oid_sqlx_query!(self.oid) + .fetch_optional(&mut *client.conn) + .await + } +} + +pub struct WithKindInQuery { + kinds: Vec, +} + +impl WithKindInQuery { + pub async fn fetch_all(self, client: &mut BaseClient) -> Result, sqlx::Error> { + let kinds_i8: Vec<_> = self + .kinds .into_iter() .map(|kind| kind.to_u8() as i8) - .collect::>(); + .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::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 { diff --git a/interim-pgtypes/src/pg_database.rs b/interim-pgtypes/src/pg_database.rs index befce3d..aa8f9fc 100644 --- a/interim-pgtypes/src/pg_database.rs +++ b/interim-pgtypes/src/pg_database.rs @@ -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 { + pub fn current() -> CurrentQuery { + CurrentQuery {} + } +} + +#[derive(Clone, Debug)] +pub struct CurrentQuery {} + +impl CurrentQuery { + pub async fn fetch_one(self, client: &mut BaseClient) -> Result { query_as!( PgDatabase, r#" @@ -66,7 +73,7 @@ from pg_database where datname = current_database() "#, ) - .fetch_one(client) + .fetch_one(&mut *client.conn) .await } } diff --git a/interim-pgtypes/src/pg_role.rs b/interim-pgtypes/src/pg_role.rs index 24c6c7c..04cacd2 100644 --- a/interim-pgtypes/src/pg_role.rs +++ b/interim-pgtypes/src/pg_role.rs @@ -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>>( - names: Vec, - client: E, - ) -> Result, sqlx::Error> { + pub fn with_name_in(names: Vec) -> WithNameInQuery { + WithNameInQuery { names } + } +} + +#[derive(Clone, Debug)] +pub struct WithNameInQuery { + names: Vec, +} + +impl WithNameInQuery { + pub async fn fetch_all(&self, client: &mut BaseClient) -> Result, 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>>( - role_oid: Oid, - client: E, + 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::>() + .concat(), + ] + .concat() + } +} + +#[derive(Clone, Debug)] +pub struct MembersOfQuery { + role_oid: Oid, +} + +impl MembersOfQuery { + pub async fn fetch_tree( + self, + client: &mut BaseClient, ) -> Result, sqlx::Error> { let rows: Vec = 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>>( - role_oid: Oid, - client: E, +#[derive(Clone, Debug)] +pub struct GrantedToQuery { + role_oid: Oid, +} + +impl GrantedToQuery { + pub async fn fetch_tree( + self, + client: &mut BaseClient, ) -> Result, sqlx::Error> { let rows: Vec = 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::>() - .concat(), - ] - .concat() - } } fn compute_members(rows: &Vec, root: Oid) -> Vec { diff --git a/interim-server/src/abstract_.rs b/interim-server/src/abstract_.rs deleted file mode 100644 index 8b13789..0000000 --- a/interim-server/src/abstract_.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/interim-server/src/app_state.rs b/interim-server/src/app_state.rs index 1336b83..aac6243 100644 --- a/interim-server/src/app_state.rs +++ b/interim-server/src/app_state.rs @@ -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); +pub struct AppDbConn(pub AppDbClient); impl FromRequestParts for AppDbConn where @@ -77,6 +78,6 @@ where .app_db .acquire() .await?; - Ok(Self(conn)) + Ok(Self(AppDbClient::from_pool_conn(conn))) } } diff --git a/interim-server/src/base_pooler.rs b/interim-server/src/base_pooler.rs index e50dea6..7160cdf 100644 --- a/interim-server/src/base_pooler.rs +++ b/interim-server/src/base_pooler.rs @@ -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 this uses will probably need to be cleaned up for +// TODO: The Arc 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 { 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> { + /// 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 { + 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::::into(state.clone()).base_pooler.clone() } } + +pub enum RoleAssignment { + Root, + User(Uuid), +} diff --git a/interim-server/src/base_user_perms.rs b/interim-server/src/base_user_perms.rs index b765013..4af9440 100644 --- a/interim-server/src/base_user_perms.rs +++ b/interim-server/src/base_user_perms.rs @@ -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 = 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 = 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(()) diff --git a/interim-server/src/bases.rs b/interim-server/src/bases.rs deleted file mode 100644 index 27b6db1..0000000 --- a/interim-server/src/bases.rs +++ /dev/null @@ -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, 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, sqlx::Error> { - let perms = perms - .into_iter() - .map(ToOwned::to_owned) - .collect::>(); - 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 { - 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 - } -} diff --git a/interim-server/src/data_layer.rs b/interim-server/src/data_layer.rs deleted file mode 100644 index d76de2f..0000000 --- a/interim-server/src/data_layer.rs +++ /dev/null @@ -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), - Integer(Option), - Timestamptz(Option>), - Uuid(Option), -} - -pub trait ToHtmlString { - fn to_html_string(&self, display_type: &Option) -> 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 + Display>( - row: &PgRow, - idx: I, - ) -> Result { - 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 { - 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) -> 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 for Value { - fn type_info() -> ::TypeInfo { - PgTypeInfo::with_name("XXX"); - todo!() - } -} - -impl<'a> Decode<'a, Postgres> for Value { - fn decode(value: PgValueRef<'a>) -> Result { - 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(>::decode(value)?) - })), - "TEXT" | "VARCHAR" => Ok(Self::Text(if value.is_null() { - None - } else { - Some(>::decode(value)?) - })), - "TIMESTAMPTZ" => Ok(Self::Timestamptz(if value.is_null() { - None - } else { - Some( as Decode>::decode(value)?) - })), - "UUID" => Ok(Self::Uuid(if value.is_null() { - None - } else { - Some(>::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 ::ArgumentBuffer<'a>, - ) -> std::result::Result { - 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), - } - } -} diff --git a/interim-server/src/db_conns.rs b/interim-server/src/db_conns.rs deleted file mode 100644 index 5ef50ec..0000000 --- a/interim-server/src/db_conns.rs +++ /dev/null @@ -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(()) -} diff --git a/interim-server/src/flexi_row.rs b/interim-server/src/flexi_row.rs deleted file mode 100644 index 9b69963..0000000 --- a/interim-server/src/flexi_row.rs +++ /dev/null @@ -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, -} - -/// Sum type representing a range of SQL data types. -pub enum FlexiField { - Text(String), - Int(i32), - Unknown, -} - -impl QueryableByName for FlexiRow { - fn build<'a>(row: &impl NamedRow<'a, Pg>) -> diesel::deserialize::Result { - let mut hm: HashMap = 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 }) - } -} diff --git a/interim-server/src/iclient.rs b/interim-server/src/iclient.rs deleted file mode 100644 index 4b5b28d..0000000 --- a/interim-server/src/iclient.rs +++ /dev/null @@ -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, -} - -#[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> { - 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?) - } -} diff --git a/interim-server/src/lenses.rs b/interim-server/src/lenses.rs deleted file mode 100644 index 2f97ede..0000000 --- a/interim-server/src/lenses.rs +++ /dev/null @@ -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>, - pub label: Option, - pub display_type: Option, - pub visible: bool, -} - -impl Selection { - pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec { - 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, - pub display_type: Option, -} diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index cab09ef..8015ed2 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -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 diff --git a/interim-server/src/nav.rs b/interim-server/src/nav.rs deleted file mode 100644 index 136e33d..0000000 --- a/interim-server/src/nav.rs +++ /dev/null @@ -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, -} - -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 "/". 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; - - 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, - active_item: Option, - params: HashMap, -} - -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 = Vec::with_capacity(self.items.len()); - for item in self.items { - let path_segments = item.href.split('/'); - let substituted_segments: Vec> = 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::>() - .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 FromRef for NavbarBuilder -where - S: Into + Clone, -{ - fn from_ref(state: &S) -> Self { - Into::::into(state.clone()) - .navbar_template - .clone() - } -} - -#[derive(Clone, Debug)] -pub struct Navbar { - pub items: Vec, - pub active_item: Option, -} - -#[derive(Clone, Debug)] -pub struct NavbarItem { - pub href: String, - pub id: String, - pub label: String, -} diff --git a/interim-server/src/navbar.rs b/interim-server/src/navbar.rs new file mode 100644 index 0000000..e2fdefa --- /dev/null +++ b/interim-server/src/navbar.rs @@ -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, + #[builder(setter(strip_option))] + pub current: Option, + pub root_path: String, +} + +impl Navbar { + pub fn builder() -> NavbarBuilder { + NavbarBuilder::default() + } +} + +#[derive(Clone, Debug)] +pub struct NamespaceItem { + pub name: String, + pub rels: Vec, +} + +#[derive(Clone, Debug)] +pub struct RelItem { + pub name: String, + pub class_oid: Oid, + pub lenses: Vec, +} + +#[derive(Clone, Debug)] +pub struct LensItem { + pub name: String, + pub id: Uuid, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum NavLocation { + Rel(Oid, Option), +} + +#[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> = 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(), + )) + } +} diff --git a/interim-server/src/router.rs b/interim-server/src/router.rs index bd1d49a..9d3f45c 100644 --- a/interim-server/src/router.rs +++ b/interim-server/src/router.rs @@ -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), diff --git a/interim-server/src/routes/bases.rs b/interim-server/src/routes/bases.rs index 4809498..bee5f20 100644 --- a/interim-server/src/routes/bases.rs +++ b/interim-server/src/routes/bases.rs @@ -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,9 +25,10 @@ pub async fn list_bases_page( AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, ) -> Result { - let bases = - Base::fetch_by_perm_any(current_user.id, vec!["configure", "connect"], &mut *app_db) - .await?; + 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")] struct ResponseTemplate { @@ -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, AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, + CurrentUser(_current_user): CurrentUser, Path(params): Path, ) -> Result { // 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 { // 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()) } diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs index e695b2f..294dd87 100644 --- a/interim-server/src/routes/lenses.rs +++ b/interim-server/src/routes/lenses.rs @@ -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, ) -> Result { // 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, State(mut base_pooler): State, navigator: Navigator, AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, Path(LensesPagePath { base_id, class_oid }): Path, Form(AddLensPagePostForm { name }): Form, ) -> Result { // 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, AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, - Path(LensPagePath { lens_id, .. }): Path, + Path(LensPagePath { + lens_id, + base_id, + class_oid, + }): Path, ) -> Result { // 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, ¤t_user.id.simple()), - &mut client, - ) - .await?; + 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 = query(&format!( @@ -186,11 +186,11 @@ pub async fn lens_page( .map(|attr| escape_identifier(&attr.attname)) .collect::>() .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> = rows .iter() @@ -212,6 +212,7 @@ pub async fn lens_page( rows: Vec, pkeys: Vec>, 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, State(mut base_pooler): State, 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, ¤t_user.id.simple()), - &mut client, - ) - .await?; + 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, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - base_id, - class_oid, - lens_id, - }): Path, - Form(form): Form>, -) -> Result { - 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, State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, CurrentUser(current_user): CurrentUser, Path(LensPagePath { - base_id, - class_oid, - lens_id: _, + base_id, class_oid, .. }): Path, Json(body): Json, ) -> Result { // 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()) diff --git a/interim-server/src/routes/relations.rs b/interim-server/src/routes/relations.rs index 9fc0e7a..1a30b78 100644 --- a/interim-server/src/routes/relations.rs +++ b/interim-server/src/routes/relations.rs @@ -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, ) -> Result { // 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 = 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 = all_rels .into_iter() .filter(|rel| { @@ -117,15 +119,13 @@ pub async fn rel_rbac_page( Path(RelPagePath { base_id, class_oid }): Path, ) -> Result { // 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 = 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 = 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> = 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!( diff --git a/interim-server/src/schema.rs b/interim-server/src/schema.rs deleted file mode 100644 index fedb061..0000000 --- a/interim-server/src/schema.rs +++ /dev/null @@ -1,23 +0,0 @@ -// @generated automatically by Diesel CLI. - -diesel::table! { - browser_sessions (id) { - id -> Text, - serialized -> Text, - created_at -> Timestamptz, - expiry -> Nullable, - } -} - -diesel::table! { - users (id) { - id -> Uuid, - uid -> Text, - email -> Text, - } -} - -diesel::allow_tables_to_appear_in_same_query!( - browser_sessions, - users, -); diff --git a/interim-server/src/sessions.rs b/interim-server/src/sessions.rs index 25fcd4f..70bd4a4 100644 --- a/interim-server/src/sessions.rs +++ b/interim-server/src/sessions.rs @@ -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}; diff --git a/interim-server/src/users.rs b/interim-server/src/user.rs similarity index 87% rename from interim-server/src/users.rs rename to interim-server/src/user.rs index 2350945..060a5ff 100644 --- a/interim-server/src/users.rs +++ b/interim-server/src/user.rs @@ -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, E: PgExecutor<'a>>( - ids: I, - app_db: E, - ) -> Result, sqlx::Error> { - let ids: Vec = 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); diff --git a/interim-server/templates/base.html b/interim-server/templates/base.html index 3a15223..c200e69 100644 --- a/interim-server/templates/base.html +++ b/interim-server/templates/base.html @@ -3,8 +3,6 @@ {% block title %}Interim{% endblock %} {% include "meta_tags.html" %} - - diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html index 80e1b0f..10ff3e0 100644 --- a/interim-server/templates/lens.html +++ b/interim-server/templates/lens.html @@ -2,51 +2,62 @@ {% block main %} - - - - - {% for field in fields %} - - {% endfor %} - - - - - {% for (i, row) in rows.iter().enumerate() %} - {# TODO: store primary keys in a Vec separate from rows #} - - {% for (j, field) in fields.iter().enumerate() %} - {# Setting max-width is required for overflow to work properly. #} - + + + + {% for (i, row) in rows.iter().enumerate() %} + {# TODO: store primary keys in a Vec separate from rows #} + + {% for (j, field) in fields.iter().enumerate() %} + {# Setting max-width is required for overflow to work properly. #} + + {% endfor %} + + {% endfor %} + +
-
{{ field.label.clone().unwrap_or(field.name.clone()) }}
-
- -
- {% match field.get_value_encodable(row) %} - {% when Ok with (encodable) %} - <{{ field.webc_tag() | safe }} - {% for (k, v) in field.webc_custom_attrs() %} - {{ k }}="{{ v }}" +
+
+
+ {{ navbar | safe }} +
+
+ + + + + {% for field in fields %} + {% endfor %} - row="{{ i }}" - column="{{ j }}" - value="{{ encodable | json }}" - class="cell" - > - {{ encodable.inner_as_value() | json }} - {{ err }} - {% endmatch %} - - {% endfor %} - - {% endfor %} - -
+ {{ field.label.clone().unwrap_or(field.name.clone()) }} +
- -
+
+ +
+ {% match field.get_value_encodable(row) %} + {% when Ok with (encodable) %} + <{{ field.webc_tag() | safe }} + {% for (k, v) in field.webc_custom_attrs() %} + {{ k }}="{{ v }}" + {% endfor %} + row="{{ i }}" + column="{{ j }}" + value="{{ encodable | json }}" + class="cell" + > + {{ encodable.inner_as_value() | json }} + {{ err }} + {% endmatch %} +
+ +
+ + diff --git a/interim-server/templates/navbar.html b/interim-server/templates/navbar.html new file mode 100644 index 0000000..d9ec390 --- /dev/null +++ b/interim-server/templates/navbar.html @@ -0,0 +1,87 @@ + + diff --git a/sass/_globals.scss b/sass/_globals.scss index 10b8a1a..b2898eb 100644 --- a/sass/_globals.scss +++ b/sass/_globals.scss @@ -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; diff --git a/static/modern-normalize.min.css b/sass/_modern-normalize.css similarity index 100% rename from static/modern-normalize.min.css rename to sass/_modern-normalize.css diff --git a/sass/collapsible_menu.scss b/sass/collapsible_menu.scss new file mode 100644 index 0000000..64fdf90 --- /dev/null +++ b/sass/collapsible_menu.scss @@ -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; + } + } +} diff --git a/sass/main.scss b/sass/main.scss index 8b13789..e804d14 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -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; + } +} diff --git a/sass/navbar.scss b/sass/navbar.scss new file mode 100644 index 0000000..ba8ccbe --- /dev/null +++ b/sass/navbar.scss @@ -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; +} diff --git a/sass/viewer.scss b/sass/viewer.scss index a9aaa80..29f4011 100644 --- a/sass/viewer.scss +++ b/sass/viewer.scss @@ -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 { - 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; + &__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.5rem; + text-align: left; - &:first-child { - border-left: none; + &:first-child { + border-left: none; + } } - &.column-adder { + &__actions-header { border: none; background: none; padding: 0; } -} -table.viewer .padded-cell { - padding: 0.5rem; -} + &__td { + border: solid 1px #ccc; + height: 100%; // css hack to make percentage based cell heights work + padding: 0; -table.viewer > tbody > tr > td { - border: solid 1px #ccc; - height: 100%; /* css hack to make percentage based cell heights work */ - padding: 0; - - &:first-child { - border-left: none; + &:first-child { + border-left: none; + } } } diff --git a/static/css/field_adder.css b/static/css/field_adder.css deleted file mode 100644 index b6020c7..0000000 --- a/static/css/field_adder.css +++ /dev/null @@ -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; - } -} diff --git a/static/main.css b/static/main.css deleted file mode 100644 index 9069802..0000000 --- a/static/main.css +++ /dev/null @@ -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"); -} diff --git a/static/viewer.css b/static/viewer.css deleted file mode 100644 index 28e37d3..0000000 --- a/static/viewer.css +++ /dev/null @@ -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; - } -} diff --git a/webc/src/collapsible_menu_component.gleam b/webc/src/collapsible_menu_component.gleam new file mode 100644 index 0000000..a16240d --- /dev/null +++ b/webc/src/collapsible_menu_component.gleam @@ -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", [], [])], + ), + ]), + ]) +} diff --git a/webc/src/viewer_controller_component.ffi.mjs b/webc/src/viewer_controller_component.ffi.mjs index 9d222dc..21ca17d 100644 --- a/webc/src/viewer_controller_component.ffi.mjs +++ b/webc/src/viewer_controller_component.ffi.mjs @@ -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]; }