refactor db clients
This commit is contained in:
parent
aa8bf34642
commit
53b4dfa130
49 changed files with 1241 additions and 1256 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
113
interim-models/src/base.rs
Normal 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
|
||||
}
|
||||
}
|
||||
15
interim-models/src/client.rs
Normal file
15
interim-models/src/client.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
38
interim-models/src/user.rs
Normal file
38
interim-models/src/user.rs
Normal 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
|
||||
}
|
||||
}
|
||||
55
interim-pgtypes/src/client.rs
Normal file
55
interim-pgtypes/src/client.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod client;
|
||||
pub mod pg_acl;
|
||||
pub mod pg_attribute;
|
||||
pub mod pg_class;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
108
interim-server/src/navbar.rs
Normal file
108
interim-server/src/navbar.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¤t_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, ¤t_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())
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
87
interim-server/templates/navbar.html
Normal file
87
interim-server/templates/navbar.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
20
sass/collapsible_menu.scss
Normal file
20
sass/collapsible_menu.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
45
sass/navbar.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
85
webc/src/collapsible_menu_component.gleam
Normal file
85
webc/src/collapsible_menu_component.gleam
Normal 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", [], [])],
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue