From 9bb7dcca7cc0c6a34aeaa7f24b94e2b2d3e65cb8 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Tue, 23 Sep 2025 13:15:53 -0700 Subject: [PATCH] clean up old files --- interim-server/src/navigator.rs | 54 ++- interim-server/src/router.rs | 269 ----------- interim-server/src/routes/bases.rs | 139 ------ interim-server/src/routes/lens_index.rs | 72 --- interim-server/src/routes/lens_insert.rs | 95 ---- interim-server/src/routes/lens_set_filter.rs | 36 -- interim-server/src/routes/lenses.rs | 440 ------------------ interim-server/src/routes/relations.rs | 251 ---------- .../src/routes/relations_single/mod.rs | 9 + .../relations_single/set_filter_handler.rs | 72 +++ .../settings_invite_handler.rs | 82 ++++ .../relations_single/update_value_handler.rs | 104 +++++ interim-server/templates/add_lens.html | 11 - interim-server/templates/base_config.html | 15 - interim-server/templates/class-viewer.html | 34 -- interim-server/templates/lenses.html | 19 - interim-server/templates/list_bases.html | 3 - interim-server/templates/list_rels.html | 17 - interim-server/templates/nav.html | 7 - interim-server/templates/navbar.html | 87 ---- interim-server/templates/tmp.html | 20 - 21 files changed, 320 insertions(+), 1516 deletions(-) delete mode 100644 interim-server/src/router.rs delete mode 100644 interim-server/src/routes/bases.rs delete mode 100644 interim-server/src/routes/lens_index.rs delete mode 100644 interim-server/src/routes/lens_insert.rs delete mode 100644 interim-server/src/routes/lens_set_filter.rs delete mode 100644 interim-server/src/routes/lenses.rs delete mode 100644 interim-server/src/routes/relations.rs create mode 100644 interim-server/src/routes/relations_single/set_filter_handler.rs create mode 100644 interim-server/src/routes/relations_single/settings_invite_handler.rs create mode 100644 interim-server/src/routes/relations_single/update_value_handler.rs delete mode 100644 interim-server/templates/add_lens.html delete mode 100644 interim-server/templates/base_config.html delete mode 100644 interim-server/templates/class-viewer.html delete mode 100644 interim-server/templates/lenses.html delete mode 100644 interim-server/templates/list_rels.html delete mode 100644 interim-server/templates/nav.html delete mode 100644 interim-server/templates/navbar.html delete mode 100644 interim-server/templates/tmp.html diff --git a/interim-server/src/navigator.rs b/interim-server/src/navigator.rs index bc0b3d1..4d12c05 100644 --- a/interim-server/src/navigator.rs +++ b/interim-server/src/navigator.rs @@ -3,14 +3,29 @@ use axum::{ http::request::Parts, response::{IntoResponse as _, Redirect, Response}, }; +use derive_builder::Builder; use interim_models::portal::Portal; +use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{app::App, errors::AppError}; +pub(crate) trait NavigatorPage { + /// Returns the path component of the URL to the corresonding web page. + /// Starts with a "/" character and the `root_path` if one is specified for + /// [`crate::settings::Settings`]. + fn get_path(&self) -> String; + + /// Wrap [`Self::get_path`] in an Axum HTTP 303 redirection response for + /// convenience. + fn redirect_to(&self) -> Response { + Redirect::to(&self.get_path()).into_response() + } +} + /// Helper type for semantically generating URI paths, e.g. for redirects. #[derive(Clone, Debug)] -pub struct Navigator { +pub(crate) struct Navigator { root_path: String, sub_path: String, } @@ -35,6 +50,15 @@ impl Navigator { } } + /// Returns a [`NavigatorPage`] builder for navigating to a relation's + /// "settings" page. + pub(crate) fn rel_settings_page(&self) -> RelSettingsPageBuilder { + RelSettingsPageBuilder { + root_path: Some(self.get_root_path()), + ..Default::default() + } + } + pub(crate) fn get_root_path(&self) -> String { self.root_path.to_owned() } @@ -58,3 +82,31 @@ impl FromRequestParts for Navigator { }) } } + +#[derive(Builder, Clone, Debug, PartialEq)] +pub(crate) struct RelSettingsPage { + rel_oid: Oid, + + #[builder(setter(custom))] + root_path: String, + + /// Any value provided for `suffix` will be appended (without %-encoding) to + /// the final path value. This may be used for sub-paths and/or search + /// parameters. + #[builder(default, setter(strip_option))] + suffix: Option, + + workspace_id: Uuid, +} + +impl NavigatorPage for RelSettingsPage { + fn get_path(&self) -> String { + format!( + "{root_path}/w/{workspace_id}/r/{rel_oid}/settings/{suffix}", + root_path = self.root_path, + workspace_id = self.workspace_id.simple(), + rel_oid = self.rel_oid.0, + suffix = self.suffix.clone().unwrap_or_default(), + ) + } +} diff --git a/interim-server/src/router.rs b/interim-server/src/router.rs deleted file mode 100644 index 7573039..0000000 --- a/interim-server/src/router.rs +++ /dev/null @@ -1,269 +0,0 @@ -use std::net::SocketAddr; - -use axum::{ - Router, - extract::{ConnectInfo, WebSocketUpgrade, ws::WebSocket}, - http::{HeaderValue, header::CACHE_CONTROL}, - response::Response, - routing::{any, get, post}, -}; -use axum_extra::routing::RouterExt as _; -use tower::ServiceBuilder; -use tower_http::{ - services::{ServeDir, ServeFile}, - set_header::SetResponseHeaderLayer, -}; - -use crate::{app_state::App, auth, routes}; - -pub fn new_router(state: App) -> Router<()> { - let root_path = state.settings.root_path.clone(); - let app = Router::new() - .route_with_tsr("/workspaces/", get(routes::bases::list_bases_page)) - .route_with_tsr("/workspaces/add/", post(routes::bases::add_base_page)) - .route_with_tsr( - "/d/{base_id}/config/", - get(routes::bases::base_config_page_get), - ) - .route( - "/d/{base_id}/config/", - post(routes::bases::base_config_page_post), - ) - .route_with_tsr( - "/d/{base_id}/relations/", - get(routes::relations::list_relations_page), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/", - get(routes::relations::rel_index_page), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/rbac/", - get(routes::relations::rel_rbac_page), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/rbac/invite/", - get(routes::relations::rel_rbac_invite_page_get), - ) - .route( - "/d/{base_id}/r/{class_oid}/rbac/invite", - post(routes::relations::rel_rbac_invite_page_post), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/lenses/", - get(routes::lenses::lenses_page), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/lenses/add/", - get(routes::lenses::add_lens_page_get), - ) - .route( - "/d/{base_id}/r/{class_oid}/lenses/add/", - post(routes::lenses::add_lens_page_post), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/", - get(routes::lens_index::lens_page_get), - ) - .route( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data", - get(routes::lenses::get_data_page_get), - ) - .route( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-column", - post(routes::lenses::add_column_page_post), - ) - .route( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value", - post(routes::lenses::update_value_page_post), - ) - .route( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/set-filter", - post(routes::lens_set_filter::lens_set_filter_page_post), - ) - .route( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/insert", - post(routes::lens_insert::insert_page_post), - ) - .route_with_tsr( - "/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/", - get(routes::lenses::viewer_page), - ) - .route("/__dev-healthz", any(dev_healthz_handler)) - .nest("/auth", auth::new_router()) - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .nest_service( - "/js_dist", - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - // FIXME: restore production value - // HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), - HeaderValue::from_static("no-cache"), - )) - .service( - ServeDir::new("js_dist").not_found_service( - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .service(ServeFile::new("static/_404.html")), - ), - ), - ) - .nest_service( - "/glm_dist", - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - // FIXME: restore production value - // HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), - HeaderValue::from_static("no-cache"), - )) - .service( - ServeDir::new("glm_dist").not_found_service( - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .service(ServeFile::new("static/_404.html")), - ), - ), - ) - .nest_service( - "/css_dist", - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - // FIXME: restore production value - // HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), - HeaderValue::from_static("no-cache"), - )) - .service( - ServeDir::new("css_dist").not_found_service( - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .service(ServeFile::new("static/_404.html")), - ), - ), - ) - .fallback_service( - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("max-age=21600, stale-while-revalidate=86400"), - )) - .service( - ServeDir::new("static").not_found_service( - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::if_not_present( - CACHE_CONTROL, - HeaderValue::from_static("no-cache"), - )) - .service(ServeFile::new("static/_404.html")), - ), - ), - ) - .with_state(state); - if root_path.is_empty() { - app - } else { - Router::new().nest(&root_path, app).fallback_service( - ServeDir::new("static").not_found_service(ServeFile::new("static/_404.html")), - ) - } -} - -async fn dev_healthz_handler( - ws: WebSocketUpgrade, - ConnectInfo(addr): ConnectInfo, -) -> Response { - tracing::info!("{addr} connected"); - ws.on_upgrade(move |socket| handle_dev_healthz_socket(socket, addr)) -} - -async fn handle_dev_healthz_socket(mut socket: WebSocket, _: SocketAddr) { - // Keep socket open indefinitely until the entire server exits - while let Some(Ok(_)) = socket.recv().await {} -} - -// #[derive(Deserialize)] -// struct RbacIndexPath { -// oid: u32, -// } -// -// async fn rbac_index( -// State(Settings { -// base_path, -// pg_user_role_prefix: role_prefix, -// .. -// }): State, -// DieselConn(diesel_conn): DieselConn, -// PgConn(pg_client): PgConn, -// CurrentUser(current_user): CurrentUser, -// Path(params): Path, -// ) -> Result { -// pg_set_role(&role_prefix, ¤t_user.id, &pg_client, &diesel_conn) -// .await -// .context("failed to set tokio_postgres role")?; -// -// struct UserDetails { -// user: User, -// roles: Vec, -// } -// let all_users = { -// let role_prefix = role_prefix.clone(); -// diesel_conn -// .interact(move |conn| -> Result<_> { -// let pg_users: Vec = -// .get_results(conn) -// .context("failed to query pg users with database access")?; -// let user_ids: Vec = pg_users -// .iter() -// .filter_map(|role| { -// let mut rolname = role.rolname.clone(); -// rolname.replace_range(0..role_prefix.len(), ""); -// Uuid::parse_str(&rolname).ok() -// }) -// .collect(); -// let all_users: Vec = users::table -// .filter(users::dsl::id.eq_any(user_ids)) -// .get_results(conn) -// .context("failed to query users with database access")?; -// Ok(all_users) -// }) -// .await -// .unwrap()? -// }; -// #[derive(Template)] -// #[template(path = "rbac.html")] -// struct ResponseTemplate { -// base_path: String, -// role_prefix: String, -// users: Vec, -// } -// -// Ok(Html( -// ResponseTemplate { -// base_path, -// role_prefix, -// users: all_users -// .into_iter() -// .map(|user| UserDetails { -// user, -// roles: vec![], -// }) -// .collect(), -// } -// .render()?, -// ) -// .into_response()) -// } diff --git a/interim-server/src/routes/bases.rs b/interim-server/src/routes/bases.rs deleted file mode 100644 index 13f4568..0000000 --- a/interim-server/src/routes/bases.rs +++ /dev/null @@ -1,139 +0,0 @@ -use anyhow::Context as _; -use askama::Template; -use axum::{ - extract::{Path, State}, - 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::{self, WorkspacePooler}, - base_user_perms::sync_perms_for_base, - settings::Settings, - user::CurrentUser, -}; - -pub async fn list_bases_page( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, -) -> Result { - let bases = Base::with_permission_in(["configure", "connect"]) - .for_user(current_user.id) - .fetch_all(&mut app_db) - .await?; - #[derive(Template)] - #[template(path = "list_bases.html")] - struct ResponseTemplate { - bases: Vec, - settings: Settings, - } - Ok(Html(ResponseTemplate { bases, settings }.render()?).into_response()) -} - -pub async fn add_base_page( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, -) -> Result { - // FIXME: CSRF - let base = Base::insertable_builder() - .url("".to_owned()) - .owner_id(current_user.id) - .build()? - .insert(&mut app_db) - .await?; - query!( - " -insert into base_user_perms -(id, base_id, user_id, perm) -values ($1, $2, $3, 'configure')", - Uuid::now_v7(), - base.id, - current_user.id - ) - .execute(app_db.get_conn()) - .await?; - Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base.id)).into_response()) -} - -#[derive(Deserialize)] -pub struct BaseConfigPagePath { - base_id: Uuid, -} - -pub async fn base_config_page_get( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(_current_user): CurrentUser, - Path(params): Path, -) -> Result { - // FIXME: auth - let base = Base::with_id(params.base_id).fetch_one(&mut app_db).await?; - #[derive(Template)] - #[template(path = "base_config.html")] - struct ResponseTemplate { - base: Base, - settings: Settings, - } - Ok(Html(ResponseTemplate { base, settings }.render()?).into_response()) -} - -#[derive(Deserialize)] -pub struct BaseConfigPageForm { - name: String, - url: String, -} - -pub async fn base_config_page_post( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(BaseConfigPagePath { base_id }): Path, - Form(form): Form, -) -> Result { - // FIXME: CSRF - // FIXME: auth - 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(app_db.get_conn()) - .await?; - if form.url != base.url { - base_pooler.close_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. - 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(root_client.get_conn()) - .await? - .context("unable to select current_database()")?; - - query(&format!( - "grant connect on database {} to {}", - escape_identifier(&db_name), - escape_identifier(&rolname) - )) - .execute(root_client.get_conn()) - .await?; - sync_perms_for_base(base.id, &mut app_db, &mut root_client).await?; - } - Ok(Redirect::to(&format!("{}/d/{}/config", settings.root_path, base_id)).into_response()) -} diff --git a/interim-server/src/routes/lens_index.rs b/interim-server/src/routes/lens_index.rs deleted file mode 100644 index ae9c1af..0000000 --- a/interim-server/src/routes/lens_index.rs +++ /dev/null @@ -1,72 +0,0 @@ -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{Html, IntoResponse as _, Response}, -}; -use interim_models::{base::Base, expression::PgExpressionAny, lens::Lens}; -use interim_pgtypes::pg_attribute::PgAttribute; -use sqlx::postgres::types::Oid; - -use crate::{ - app_error::AppError, - app_state::AppDbConn, - base_pooler::{RoleAssignment, WorkspacePooler}, - navbar::{NavLocation, Navbar, RelLocation}, - settings::Settings, - user::CurrentUser, -}; - -use super::LensPagePath; - -pub async fn lens_page_get( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - lens_id, - base_id, - class_oid, - }): Path, -) -> Result { - // FIXME auth - 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 base_client = base_pooler - .acquire_for(lens.base_id, RoleAssignment::User(current_user.id)) - .await?; - - let attrs = PgAttribute::all_for_rel(lens.class_oid) - .fetch_all(&mut base_client) - .await?; - let attr_names: Vec = attrs.iter().map(|attr| attr.attname.clone()).collect(); - - #[derive(Template)] - #[template(path = "lens.html")] - struct ResponseTemplate { - attr_names: Vec, - filter: Option, - settings: Settings, - navbar: Navbar, - } - Ok(Html( - ResponseTemplate { - attr_names, - filter: lens.filter.0, - 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()?, - ) - .into_response()) -} diff --git a/interim-server/src/routes/lens_insert.rs b/interim-server/src/routes/lens_insert.rs deleted file mode 100644 index f563337..0000000 --- a/interim-server/src/routes/lens_insert.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::collections::HashMap; - -use axum::{ - extract::{Path, State}, - response::Response, -}; -use axum_extra::extract::Form; -use interim_models::{encodable::Encodable, lens::Lens}; -use interim_pgtypes::{escape_identifier, pg_class::PgClass}; -use sqlx::{postgres::types::Oid, query}; - -use crate::{ - app_error::AppError, - app_state::AppDbConn, - base_pooler::{RoleAssignment, WorkspacePooler}, - navigator::Navigator, - user::CurrentUser, -}; - -use super::LensPagePath; - -pub async fn insert_page_post( - State(mut base_pooler): State, - navigator: Navigator, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - base_id, - class_oid, - lens_id, - }): Path, - Form(body): Form>>, -) -> Result { - // FIXME auth, csrf - - let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).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 col_names: Vec = body.keys().cloned().collect(); - let col_list_sql = col_names - .iter() - .map(|value| escape_identifier(value)) - .collect::>() - .join(", "); - - let n_rows = body.values().map(|value| value.len()).max().unwrap_or(0); - if n_rows > 0 { - let mut param_index = 1; - let mut params: Vec = vec![]; - let mut row_list: Vec = vec![]; - for i in 0..n_rows { - let mut param_slots: Vec = vec![]; - for col in col_names.iter() { - let maybe_value: Option = body - .get(col) - .and_then(|col_values| col_values.get(i)) - .map(|value_raw| serde_json::from_str(value_raw)) - .transpose()?; - if let Some(value) = maybe_value.filter(|value| !value.is_none()) { - params.push(value); - param_slots.push(format!("${param_index}")); - param_index += 1; - } else { - param_slots.push("default".to_owned()); - } - } - row_list.push(format!("({})", param_slots.join(", "))); - } - let row_list_sql = row_list.join(",\n"); - - let query_sql = &format!( - r#" -insert into {0}.{1} -({col_list_sql}) -values -{row_list_sql} -"#, - escape_identifier(&rel.regnamespace), - escape_identifier(&rel.relname) - ); - let mut q = query(query_sql); - for param in params { - q = param.bind_onto(q); - } - q.execute(base_client.get_conn()).await?; - } - - Ok(navigator.lens_page(&lens).redirect_to()) -} diff --git a/interim-server/src/routes/lens_set_filter.rs b/interim-server/src/routes/lens_set_filter.rs deleted file mode 100644 index 2d804a4..0000000 --- a/interim-server/src/routes/lens_set_filter.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::{extract::Path, response::Response}; -use axum_extra::extract::Form; -use interim_models::{expression::PgExpressionAny, lens::Lens}; -use serde::Deserialize; - -use crate::{app_error::AppError, app_state::AppDbConn, navigator::Navigator, user::CurrentUser}; - -use super::LensPagePath; - -#[derive(Deserialize)] -pub struct FormBody { - filter_expression: Option, -} - -pub async fn lens_set_filter_page_post( - navigator: Navigator, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(_): CurrentUser, - Path(LensPagePath { lens_id, .. }): Path, - Form(body): Form, -) -> Result { - // FIXME auth, csrf - - let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?; - - let filter: Option = - serde_json::from_str(&body.filter_expression.unwrap_or("null".to_owned()))?; - Lens::update() - .id(lens.id) - .filter(filter) - .build()? - .execute(&mut app_db) - .await?; - - Ok(navigator.lens_page(&lens).redirect_to()) -} diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs deleted file mode 100644 index 7a764d6..0000000 --- a/interim-server/src/routes/lenses.rs +++ /dev/null @@ -1,440 +0,0 @@ -use std::collections::HashMap; - -use askama::Template; -use axum::{ - Json, - extract::{Path, State}, - response::{Html, IntoResponse, Response}, -}; -use axum_extra::extract::Form; -use interim_models::{ - base::Base, - encodable::Encodable, - field::{Field, InsertableFieldBuilder}, - lens::{Lens, LensDisplayType}, - presentation::{Presentation, RFC_3339_S}, -}; -use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::{ - postgres::{PgRow, types::Oid}, - query, -}; -use uuid::Uuid; - -use crate::{ - app_error::{AppError, bad_request}, - app_state::AppDbConn, - base_pooler::{RoleAssignment, WorkspacePooler}, - field_info::FieldInfo, - navigator::Navigator, - settings::Settings, - user::CurrentUser, -}; - -use super::LensPagePath; - -#[derive(Deserialize)] -pub struct LensesPagePath { - base_id: Uuid, - class_oid: u32, -} - -pub async fn lenses_page( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - Path(LensesPagePath { base_id, class_oid }): Path, -) -> Result { - // FIXME auth - 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 { - base_id: Uuid, - class_oid: u32, - lenses: Vec, - settings: Settings, - } - - Ok(Html( - ResponseTemplate { - base_id, - class_oid, - lenses, - settings, - } - .render()?, - ) - .into_response()) -} - -pub async fn add_lens_page_get( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - Path(LensesPagePath { base_id, class_oid }): Path, -) -> Result { - // FIXME auth - #[derive(Template)] - #[template(path = "add_lens.html")] - struct ResponseTemplate { - base_id: Uuid, - class_oid: u32, - settings: Settings, - } - - Ok(Html( - ResponseTemplate { - base_id, - class_oid, - settings, - } - .render()?, - ) - .into_response()) -} - -#[derive(Deserialize)] -pub struct AddLensPagePostForm { - name: String, -} - -pub async fn add_lens_page_post( - State(settings): State, - State(mut base_pooler): State, - navigator: Navigator, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensesPagePath { base_id, class_oid }): Path, - Form(AddLensPagePostForm { name }): Form, -) -> Result { - // FIXME auth - // FIXME csrf - - let mut client = base_pooler - .acquire_for(base_id, RoleAssignment::User(current_user.id)) - .await?; - - let attrs = PgAttribute::all_for_rel(Oid(class_oid)) - .fetch_all(&mut client) - .await?; - - let lens = Lens::insertable_builder() - .base_id(base_id) - .class_oid(Oid(class_oid)) - .name(name) - .display_type(LensDisplayType::Table) - .build()? - .insert(&mut app_db) - .await?; - - for attr in attrs { - InsertableFieldBuilder::default_from_attr(&attr) - .lens_id(lens.id) - .build()? - .insert(&mut app_db) - .await?; - } - - Ok(navigator.lens_page(&lens).redirect_to()) -} - -pub async fn get_data_page_get( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - lens_id, - base_id, - class_oid, - }): Path, -) -> Result { - // FIXME auth - 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 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 attrs = PgAttribute::all_for_rel(lens.class_oid) - .fetch_all(&mut base_client) - .await?; - let pkey_attrs = PgAttribute::pkeys_for_rel(lens.class_oid) - .fetch_all(&mut base_client) - .await?; - - let fields: Vec = { - let fields: Vec = Field::belonging_to_portal(lens.id) - .fetch_all(&mut app_db) - .await?; - let mut field_info: Vec = Vec::with_capacity(fields.len()); - for field in fields { - if let Some(attr) = attrs.iter().find(|attr| attr.attname == field.name) { - field_info.push(FieldInfo { - field, - has_default: attr.atthasdef, - not_null: attr.attnotnull.unwrap_or_default(), - }); - } - } - field_info - }; - - const FRONTEND_ROW_LIMIT: i64 = 1000; - let mut sql_raw = format!( - "select {0} from {1}.{2}", - pkey_attrs - .iter() - .chain(attrs.iter()) - .map(|attr| escape_identifier(&attr.attname)) - .collect::>() - .join(", "), - escape_identifier(&rel.regnamespace), - escape_identifier(&rel.relname), - ); - let rows: Vec = if let Some(filter_expr) = lens.filter.0 { - let filter_fragment = filter_expr.into_query_fragment(); - let filter_params = filter_fragment.to_params(); - sql_raw = format!( - "{sql_raw} where {0} limit ${1}", - filter_fragment.to_sql(1), - filter_params.len() + 1 - ); - let mut q = query(&sql_raw); - for param in filter_params { - q = param.bind_onto(q); - } - q = q.bind(FRONTEND_ROW_LIMIT); - q.fetch_all(base_client.get_conn()).await? - } else { - sql_raw = format!("{sql_raw} limit $1"); - query(&sql_raw) - .bind(FRONTEND_ROW_LIMIT) - .fetch_all(base_client.get_conn()) - .await? - }; - - #[derive(Serialize)] - struct DataRow { - pkey: String, - data: Vec, - } - - let mut data_rows: Vec = vec![]; - let mut pkeys: Vec = vec![]; - for row in rows.iter() { - let mut pkey_values: HashMap = HashMap::new(); - for attr in pkey_attrs.clone() { - let field = Field::default_from_attr(&attr) - .ok_or(anyhow::anyhow!("unsupported primary key column type"))?; - pkey_values.insert(field.name.clone(), field.get_value_encodable(row)?); - } - let pkey = serde_json::to_string(&pkey_values)?; - pkeys.push(pkey.clone()); - let mut row_data: Vec = vec![]; - for field in fields.iter() { - row_data.push(field.field.get_value_encodable(row)?); - } - data_rows.push(DataRow { - pkey, - data: row_data, - }); - } - - #[derive(Serialize)] - struct ResponseBody { - rows: Vec, - fields: Vec, - pkeys: Vec, - } - Ok(Json(ResponseBody { - rows: data_rows, - fields, - pkeys, - }) - .into_response()) -} - -#[derive(Debug, Deserialize)] -pub struct AddColumnPageForm { - name: String, - label: String, - presentation_tag: String, - timestamp_format: Option, -} - -fn try_presentation_from_form(form: &AddColumnPageForm) -> Result { - let serialized = match form.presentation_tag.as_str() { - "Timestamp" => { - json!({ - "t": form.presentation_tag, - "c": { - "format": form.timestamp_format.clone().unwrap_or(RFC_3339_S.to_owned()), - }, - }) - } - _ => json!({"t": form.presentation_tag}), - }; - serde_json::from_value(serialized).or(Err(bad_request!("unable to parse field type"))) -} - -pub async fn add_column_page_post( - State(mut base_pooler): State, - navigator: Navigator, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { lens_id, .. }): Path, - Form(form): Form, -) -> Result { - // FIXME auth - // FIXME csrf - // FIXME validate column name length is less than 64 - - 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 base_client = base_pooler - .acquire_for(base.id, RoleAssignment::User(current_user.id)) - .await?; - - let class = PgClass::with_oid(lens.class_oid) - .fetch_one(&mut base_client) - .await?; - - let presentation = try_presentation_from_form(&form)?; - let data_type_fragment = presentation.attr_data_type_fragment(); - - query(&format!( - r#" -alter table {0} -add column if not exists {1} {2} -"#, - class.get_identifier(), - escape_identifier(&form.name), - data_type_fragment - )) - .execute(base_client.get_conn()) - .await?; - - Field::insert() - .lens_id(lens.id) - .name(form.name) - .label(if form.label.is_empty() { - None - } else { - Some(form.label) - }) - .presentation(presentation) - .build()? - .insert(&mut app_db) - .await?; - - Ok(navigator.lens_page(&lens).redirect_to()) -} - -// #[derive(Debug, Deserialize)] -// pub struct AddSelectionPageForm { -// column: String, -// } -// -// pub async fn add_selection_page_post( -// State(settings): State, -// AppDbConn(mut app_db): AppDbConn, -// CurrentUser(current_user): CurrentUser, -// Path(LensPagePath { -// base_id, -// class_oid, -// lens_id, -// }): Path, -// Form(form): Form, -// ) -> Result { -// dbg!(&form); -// // FIXME auth -// // FIXME csrf -// -// let lens = Lens::fetch_by_id(lens_id, &mut *app_db) -// .await? -// .ok_or(not_found!("lens not found"))?; -// Selection::insertable_builder() -// .lens_id(lens.id) -// .attr_filters(vec![AttrFilter::NameEq(form.column)]) -// .build()? -// .insert(&mut *app_db) -// .await?; -// -// 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, - pkeys: HashMap, - value: Encodable, -} - -pub async fn update_value_page_post( - State(mut base_pooler): State, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - base_id, class_oid, .. - }): Path, - Json(body): Json, -) -> Result { - // FIXME auth - // FIXME csrf - - 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() - .clone() - .bind_onto(body.value.bind_onto(query(&format!( - r#"update {0}.{1} set {2} = $1 where {3} = $2"#, - escape_identifier(&rel.regnamespace), - escape_identifier(&rel.relname), - escape_identifier(&body.column), - escape_identifier(&pkey_attrs.first().unwrap().attname), - )))) - .execute(base_client.get_conn()) - .await?; - - Ok(Json(json!({ "ok": true })).into_response()) -} - -#[derive(Deserialize)] -pub struct ViewerPagePath { - base_id: Uuid, - class_oid: u32, - lens_id: Uuid, -} - -pub async fn viewer_page( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(params): Path, -) -> Result { - todo!("not yet implemented"); -} diff --git a/interim-server/src/routes/relations.rs b/interim-server/src/routes/relations.rs deleted file mode 100644 index 94bf237..0000000 --- a/interim-server/src/routes/relations.rs +++ /dev/null @@ -1,251 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use anyhow::Context as _; -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{Html, IntoResponse as _, Redirect, Response}, -}; -use axum_extra::extract::Form; -use interim_models::{base::Base, rel_invitation::RelInvitation}; -use interim_pgtypes::{ - pg_acl::{PgAclItem, PgPrivilegeType}, - pg_class::{PgClass, PgRelKind}, - pg_role::{PgRole, RoleTree}, -}; -use serde::Deserialize; -use sqlx::postgres::types::Oid; -use uuid::Uuid; - -use crate::{ - app_error::{AppError, forbidden}, - app_state::AppDbConn, - base_pooler::{self, WorkspacePooler}, - navbar::{NavLocation, Navbar, RelLocation}, - renderable_role_tree::RenderableRoleTree, - settings::Settings, - user::CurrentUser, -}; - -#[derive(Deserialize)] -pub struct ListRelationsPagePath { - base_id: Uuid, -} - -pub async fn list_relations_page( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(ListRelationsPagePath { base_id }): Path, -) -> Result { - // FIXME auth - let base = Base::with_id(base_id).fetch_one(&mut app_db).await?; - let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple()); - let mut client = base_pooler - .acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id)) - .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::granted_to(role.oid) - .fetch_tree(&mut client) - .await? - .context("unable to construct role tree")?; - let granted_roles: HashSet = granted_role_tree - .flatten_inherited() - .into_iter() - .map(|role| role.rolname.clone()) - .collect(); - - let all_rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable]) - .fetch_all(&mut client) - .await?; - dbg!(&all_rels); - let accessible_rels: Vec = all_rels - .into_iter() - .filter(|rel| { - let privileges: HashSet = rel - .relacl - .clone() - .unwrap_or_default() - .into_iter() - .filter(|item| granted_roles.contains(&item.grantee)) - .flat_map(|item| item.privileges) - .map(|privilege| privilege.privilege) - .collect(); - privileges.contains(&PgPrivilegeType::Select) - }) - .collect(); - - #[derive(Template)] - #[template(path = "list_rels.html")] - struct ResponseTemplate { - base: Base, - rels: Vec, - settings: Settings, - } - - Ok(Html( - ResponseTemplate { - base, - rels: accessible_rels, - settings, - } - .render()?, - ) - .into_response()) -} - -#[derive(Deserialize)] -pub struct RelPagePath { - base_id: Uuid, - class_oid: u32, -} - -pub async fn rel_index_page( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(RelPagePath { base_id, class_oid }): Path, -) -> Result { - todo!(); -} - -pub async fn rel_rbac_page( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(RelPagePath { base_id, class_oid }): Path, -) -> Result { - // FIXME: auth - let base = Base::with_id(base_id).fetch_one(&mut app_db).await?; - let mut base_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 base_client) - .await?; - - let owners: RenderableRoleTree = RoleTree::members_of_oid(class.relowner) - .fetch_tree(&mut base_client) - .await? - .ok_or(forbidden!( - "user does not have permission to determine relation owner" - ))? - .into(); - - let all_invites = RelInvitation::belonging_to_rel(Oid(class_oid)) - .fetch_all(&mut app_db) - .await?; - let mut invites_by_email: HashMap> = HashMap::new(); - for invite in all_invites { - let entry = invites_by_email.entry(invite.email.clone()).or_default(); - entry.push(invite); - } - - struct AclTree { - acl_item: PgAclItem, - grantees: RenderableRoleTree, - } - let mut acl_trees: Vec = vec![]; - for item in class.relacl.clone().unwrap_or_default() { - acl_trees.push(AclTree { - acl_item: item.clone(), - grantees: RoleTree::members_of_rolname(&item.grantee) - .fetch_tree(&mut base_client) - .await? - .ok_or(forbidden!( - "unable to construct full acl tree for role {0}", - &item.grantee - ))? - .into(), - }); - } - - #[derive(Template)] - #[template(path = "rel_rbac.html")] - struct ResponseTemplate { - acl_trees: Vec, - base: Base, - invites_by_email: HashMap>, - navbar: Navbar, - owners: RenderableRoleTree, - pg_class: PgClass, - settings: Settings, - } - - Ok(Html( - ResponseTemplate { - acl_trees, - invites_by_email, - pg_class: class, - 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::Rbac))) - .build()?, - owners, - base, - settings, - } - .render()?, - ) - .into_response()) -} - -pub async fn rel_rbac_invite_page_get( - State(settings): State, -) -> Result { - #[derive(Template)] - #[template(path = "rbac_invite.html")] - struct ResponseTemplate { - settings: Settings, - } - Ok(Html(ResponseTemplate { settings }.render()?).into_response()) -} - -#[derive(Deserialize)] -pub struct RbacInvitePagePostForm { - email: String, -} - -pub async fn rel_rbac_invite_page_post( - State(settings): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(RelPagePath { base_id, class_oid }): Path, - Form(form): Form, -) -> Result { - // FIXME auth - // FIXME form validation - for privilege in [ - PgPrivilegeType::Select, - PgPrivilegeType::Insert, - PgPrivilegeType::Update, - PgPrivilegeType::Delete, - PgPrivilegeType::Truncate, - PgPrivilegeType::References, - PgPrivilegeType::Trigger, - ] { - RelInvitation::upsertable() - .email(form.email.clone()) - .base_id(base_id) - .class_oid(Oid(class_oid)) - .privilege(privilege) - .created_by(current_user.id) - .build()? - .upsert(&mut app_db) - .await?; - } - Ok(Redirect::to(&format!( - "{0}/d/{base_id}/r/{class_oid}/rbac", - settings.root_path - )) - .into_response()) -} diff --git a/interim-server/src/routes/relations_single/mod.rs b/interim-server/src/routes/relations_single/mod.rs index f174c9a..23a46a6 100644 --- a/interim-server/src/routes/relations_single/mod.rs +++ b/interim-server/src/routes/relations_single/mod.rs @@ -11,12 +11,21 @@ mod add_portal_handler; mod get_data_handler; mod insert_handler; mod portal_handler; +mod set_filter_handler; +mod settings_invite_handler; +mod update_value_handler; pub(super) fn new_router() -> Router { Router::::new() + .route("/settings/invite", post(settings_invite_handler::post)) .route("/add-portal", post(add_portal_handler::post)) .route_with_tsr("/p/{portal_id}/", get(portal_handler::get)) .route_with_tsr("/p/{portal_id}/get-data/", get(get_data_handler::get)) .route("/p/{portal_id}/add-field", post(add_field_handler::post)) .route("/p/{portal_id}/insert", post(insert_handler::post)) + .route( + "/p/{portal_id}/update-value", + post(update_value_handler::post), + ) + .route("/p/{portal_id}/set-filter", post(set_filter_handler::post)) } diff --git a/interim-server/src/routes/relations_single/set_filter_handler.rs b/interim-server/src/routes/relations_single/set_filter_handler.rs new file mode 100644 index 0000000..9ac7c10 --- /dev/null +++ b/interim-server/src/routes/relations_single/set_filter_handler.rs @@ -0,0 +1,72 @@ +use axum::{debug_handler, extract::Path, response::Response}; +// [`axum_extra`]'s form extractor is preferred: +// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform +use axum_extra::extract::Form; +use interim_models::{ + expression::PgExpressionAny, + portal::Portal, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + navigator::Navigator, + user::CurrentUser, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FormBody { + filter_expression: Option, +} + +/// HTTP POST handler for applying a [`PgExpressionAny`] filter to a portal's +/// table viewer. +/// +/// This handler expects 3 path parameters with the structure described by +/// [`PathParams`]. +#[debug_handler(state = App)] +pub(super) async fn post( + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { + portal_id, + workspace_id, + .. + }): Path, + Form(form): Form, +) -> Result { + // Check workspace authorization. + let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) + .fetch_all(&mut app_db) + .await?; + if workspace_perms.iter().all(|p| { + p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect + }) { + return Err(forbidden!("access denied to workspace")); + } + // FIXME ensure workspace corresponds to rel/portal, and that user has + // permission to access/alter both as needed. + + let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; + let filter: Option = + serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?; + Portal::update() + .id(portal.id) + .filter(filter) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator.portal_page(&portal).redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/settings_invite_handler.rs b/interim-server/src/routes/relations_single/settings_invite_handler.rs new file mode 100644 index 0000000..04fc48f --- /dev/null +++ b/interim-server/src/routes/relations_single/settings_invite_handler.rs @@ -0,0 +1,82 @@ +use axum::{debug_handler, extract::Path, response::Response}; +// [`axum_extra`]'s form extractor is preferred: +// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform +use axum_extra::extract::Form; +use interim_models::{ + rel_invitation::RelInvitation, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::pg_acl::PgPrivilegeType; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FormBody { + email: String, +} + +/// HTTP POST handler for inviting another Phonograph user to collaborate on a +/// relation, by email address. +#[debug_handler(state = App)] +pub(super) async fn post( + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { + rel_oid, + workspace_id, + }): Path, + Form(form): Form, +) -> Result { + // Check workspace authorization. + let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) + .fetch_all(&mut app_db) + .await?; + if workspace_perms.iter().all(|p| { + p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect + }) { + return Err(forbidden!("access denied to workspace")); + } + // FIXME ensure workspace corresponds to rel/portal, and that user has + // permission to access/alter both as needed. + // FIXME form validation + for privilege in [ + PgPrivilegeType::Select, + PgPrivilegeType::Insert, + PgPrivilegeType::Update, + PgPrivilegeType::Delete, + PgPrivilegeType::Truncate, + PgPrivilegeType::References, + PgPrivilegeType::Trigger, + ] { + RelInvitation::upsertable() + .email(form.email.clone()) + .workspace_id(workspace_id) + .class_oid(Oid(rel_oid)) + .privilege(privilege) + .created_by(user.id) + .build()? + .upsert(&mut app_db) + .await?; + } + Ok(navigator + .rel_settings_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/update_value_handler.rs b/interim-server/src/routes/relations_single/update_value_handler.rs new file mode 100644 index 0000000..f8be56f --- /dev/null +++ b/interim-server/src/routes/relations_single/update_value_handler.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use axum::{ + Json, debug_handler, + extract::{Path, State}, + response::{IntoResponse as _, Response}, +}; +// [`axum_extra`]'s form extractor is preferred: +// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform +use axum_extra::extract::Form; +use interim_models::{ + datum::Datum, + portal::Portal, + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; +use serde::Deserialize; +use serde_json::json; +use sqlx::{postgres::types::Oid, query}; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + user::CurrentUser, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FormBody { + column: String, + pkeys: HashMap, + value: Datum, +} + +/// HTTP POST handler for updating a single value in a backing Postgres table. +/// +/// This handler expects 3 path parameters with the structure described by +/// [`PathParams`]. +#[debug_handler(state = App)] +pub(super) async fn post( + State(mut workspace_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + Form(form): Form, +) -> Result { + // Check workspace authorization. + let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) + .fetch_all(&mut app_db) + .await?; + if workspace_perms.iter().all(|p| { + p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect + }) { + return Err(forbidden!("access denied to workspace")); + } + // FIXME ensure workspace corresponds to rel/portal, and that user has + // permission to access/alter both as needed. + + let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; + let workspace = Workspace::with_id(portal.workspace_id) + .fetch_one(&mut app_db) + .await?; + + let mut workspace_client = workspace_pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; + + let rel = PgClass::with_oid(portal.class_oid) + .fetch_one(&mut workspace_client) + .await?; + + let pkey_attrs = PgAttribute::pkeys_for_rel(Oid(rel_oid)) + .fetch_all(&mut workspace_client) + .await?; + + // TODO: simplify pkey management + form.pkeys + .get(&pkey_attrs.first().unwrap().attname) + .unwrap() + .clone() + .bind_onto(form.value.bind_onto(query(&format!( + "update {ident} set {value_col} = $1 where {pkey_col} = $2", + ident = rel.get_identifier(), + value_col = escape_identifier(&form.column), + pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname), + )))) + .execute(workspace_client.get_conn()) + .await?; + + Ok(Json(json!({ "ok": true })).into_response()) +} diff --git a/interim-server/templates/add_lens.html b/interim-server/templates/add_lens.html deleted file mode 100644 index ed0690c..0000000 --- a/interim-server/templates/add_lens.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-
- - -
- -
-{% endblock %} diff --git a/interim-server/templates/base_config.html b/interim-server/templates/base_config.html deleted file mode 100644 index 9f07bb9..0000000 --- a/interim-server/templates/base_config.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-
- - -
-
- - -
- -
-{% endblock %} diff --git a/interim-server/templates/class-viewer.html b/interim-server/templates/class-viewer.html deleted file mode 100644 index a6d101e..0000000 --- a/interim-server/templates/class-viewer.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "base.html" %} - -{% block main %} - - - - - - {% for field in fields %} - - {% endfor %} - - - - {% for row in rows %} - - {% for field in fields %} - - {% endfor %} - - {% endfor %} - -
-
{{ field.label.clone().unwrap_or(field.name.clone()) }}
-
- {% match Value::get_from_row(row, field.name.as_str()) %} - {% when Ok with (value) %} - {{ value.to_html_string(&field.display_type) | safe }} - {% when Err with (err) %} - {{ err }} - {% endmatch %} -
- -{% endblock %} diff --git a/interim-server/templates/lenses.html b/interim-server/templates/lenses.html deleted file mode 100644 index 93acb51..0000000 --- a/interim-server/templates/lenses.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} - -{% block main %} - - - {% for lens in lenses %} - - - - {% endfor %} - -
- - {{ lens.name }} - -
-{% endblock %} diff --git a/interim-server/templates/list_bases.html b/interim-server/templates/list_bases.html index 4fe520e..57ea2ba 100644 --- a/interim-server/templates/list_bases.html +++ b/interim-server/templates/list_bases.html @@ -1,9 +1,6 @@ {% extends "base.html" %} {% block main %} -
- -
{% for base in bases %} diff --git a/interim-server/templates/list_rels.html b/interim-server/templates/list_rels.html deleted file mode 100644 index 65a6bc5..0000000 --- a/interim-server/templates/list_rels.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
- - {% for rel in rels %} - - - - {% endfor %} - -
- - {{ rel.relname }} - -
-{% endblock %} diff --git a/interim-server/templates/nav.html b/interim-server/templates/nav.html deleted file mode 100644 index b66a46a..0000000 --- a/interim-server/templates/nav.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/interim-server/templates/navbar.html b/interim-server/templates/navbar.html deleted file mode 100644 index 2efcce0..0000000 --- a/interim-server/templates/navbar.html +++ /dev/null @@ -1,87 +0,0 @@ - - diff --git a/interim-server/templates/tmp.html b/interim-server/templates/tmp.html deleted file mode 100644 index ac7f0ce..0000000 --- a/interim-server/templates/tmp.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} - -{% block main %} - - - - - - - - - {% for relation in relations %} - - - - - {% endfor %} - -
NameOID
{{ relation.relname }}{{ relation.oid }}
-{% endblock %}