Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
pub mod user_uid;
|
||||
|
||||
pub use user_uid::{TEST_USER_EMAIL, TEST_USER_UID, UserUid};
|
||||
@@ -0,0 +1,87 @@
|
||||
use std::{fmt, sync::LazyLock};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub const TEST_USER_EMAIL: &str = "test_user@warp.dev";
|
||||
pub const TEST_USER_UID: &str = "test_user_uid";
|
||||
|
||||
/// UserUid represents the unique identifier for a user. Currently, this is a Firebase UID.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct UserUid(lasso::Spur);
|
||||
|
||||
static USER_UID_INTERNER: LazyLock<lasso::ThreadedRodeo<lasso::Spur>> =
|
||||
LazyLock::new(lasso::ThreadedRodeo::new);
|
||||
|
||||
impl Default for UserUid {
|
||||
fn default() -> Self {
|
||||
// Intern an empty string so that `as_str()` on a default UserUid
|
||||
// returns "" instead of panicking with "Key out of bounds".
|
||||
Self::new("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UserUid {
|
||||
pub fn new(uid: &str) -> Self {
|
||||
Self(USER_UID_INTERNER.get_or_intern(uid))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
USER_UID_INTERNER.resolve(&self.0)
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
self.as_str().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserUid> for cynic::Id {
|
||||
fn from(user_uid: UserUid) -> Self {
|
||||
cynic::Id::new(user_uid.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserUid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for UserUid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("UserUid(")?;
|
||||
f.write_str(self.as_str())?;
|
||||
f.write_str(")")
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for UserUid {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UserUid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct UidVisitor;
|
||||
impl serde::de::Visitor<'_> for UidVisitor {
|
||||
type Value = UserUid;
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a user UID")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(UserUid::new(v))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_str(UidVisitor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
use std::{borrow::Cow, fmt, str::FromStr};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use derivative::Derivative;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::{
|
||||
features::FeatureFlag,
|
||||
ui::{Icon, appearance::Appearance, theme::Fill},
|
||||
};
|
||||
use warp_graphql::{object_permissions::AccessLevel, scalars::time::ServerTimestamp};
|
||||
use warpui_core::{
|
||||
Element,
|
||||
elements::{
|
||||
Align, ChildAnchor, ConstrainedBox, Hoverable, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
|
||||
},
|
||||
ui_components::components::UiComponent,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
|
||||
ids::{FolderId, ServerId, SyncId},
|
||||
};
|
||||
|
||||
/// The type of object id each ObjectType corresponds to.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ObjectIdType {
|
||||
Notebook,
|
||||
Workflow,
|
||||
Folder,
|
||||
GenericStringObject,
|
||||
}
|
||||
|
||||
impl ObjectIdType {
|
||||
/// Returns the prefix for server IDs as we store them in sqlite. The prefix for these
|
||||
/// objects is in title case unlike how we store the object types, which is why two different
|
||||
/// APIs are needed.
|
||||
pub fn sqlite_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
ObjectIdType::Notebook => "Notebook",
|
||||
ObjectIdType::Workflow => "Workflow",
|
||||
ObjectIdType::Folder => "Folder",
|
||||
ObjectIdType::GenericStringObject => "GenericStringObject",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A type for communicating the type of cloud object to/from the server, absent of the object itself.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub enum ObjectType {
|
||||
Notebook,
|
||||
Workflow,
|
||||
Folder,
|
||||
GenericStringObject(GenericStringObjectFormat),
|
||||
}
|
||||
|
||||
impl ObjectType {
|
||||
/// Returns the serialized string for the object type, to be used for storing object_type in sqlite.
|
||||
pub fn sqlite_object_type_as_str(&self) -> Cow<'_, str> {
|
||||
match self {
|
||||
ObjectType::Notebook => "NOTEBOOK".into(),
|
||||
ObjectType::Workflow => "WORKFLOW".into(),
|
||||
ObjectType::Folder => "FOLDER".into(),
|
||||
ObjectType::GenericStringObject(format) => format.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const NOTEBOOK_OBJECT_STRING: &str = "notebook";
|
||||
const WORKFLOW_OBJECT_STRING: &str = "workflow";
|
||||
const PROMPT_OBJECT_STRING: &str = "prompt";
|
||||
const FOLDER_OBJECT_STRING: &str = "folder";
|
||||
const ENV_VAR_COLLECTION_STRING: &str = "env-vars";
|
||||
|
||||
impl FromStr for ObjectType {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
match s {
|
||||
NOTEBOOK_OBJECT_STRING => Ok(Self::Notebook),
|
||||
WORKFLOW_OBJECT_STRING => Ok(Self::Workflow),
|
||||
PROMPT_OBJECT_STRING => Ok(Self::Workflow),
|
||||
FOLDER_OBJECT_STRING => Ok(Self::Folder),
|
||||
ENV_VAR_COLLECTION_STRING => Ok(Self::GenericStringObject(
|
||||
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
|
||||
)),
|
||||
_ => Err(anyhow!("Unexpected object type")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ObjectType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ObjectType::Notebook => write!(f, "{NOTEBOOK_OBJECT_STRING}"),
|
||||
ObjectType::Workflow => write!(f, "{WORKFLOW_OBJECT_STRING}"),
|
||||
ObjectType::Folder => write!(f, "{FOLDER_OBJECT_STRING}"),
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::EnvVarCollection,
|
||||
)) => write!(f, "{ENV_VAR_COLLECTION_STRING}"),
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::AIFact,
|
||||
)) => write!(f, "rule"),
|
||||
ObjectType::GenericStringObject(_) => write!(f, "string_object_placeholder"), // placeholder value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ObjectType> for ObjectIdType {
|
||||
fn from(value: ObjectType) -> Self {
|
||||
match value {
|
||||
ObjectType::Notebook => ObjectIdType::Notebook,
|
||||
ObjectType::Workflow => ObjectIdType::Workflow,
|
||||
ObjectType::Folder => ObjectIdType::Folder,
|
||||
ObjectType::GenericStringObject(_) => ObjectIdType::GenericStringObject,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The object type prefix for generic string objects.
|
||||
pub const GENERIC_STRING_OBJECT_PREFIX: &str = "GENERIC_STRING_";
|
||||
|
||||
/// The object type prefix for json objects.
|
||||
pub const JSON_OBJECT_PREFIX: &str = "JSON_";
|
||||
|
||||
/// The data format for the generic string object type.
|
||||
/// Right now we only support json, but this is left
|
||||
/// open to support markdown, yaml and other text based types.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
|
||||
pub enum GenericStringObjectFormat {
|
||||
Json(JsonObjectType),
|
||||
}
|
||||
|
||||
// Temporarily suppress clippy warnings about the `ToString` impl until we
|
||||
// move `ObjectType` away from using `std::fmt::Display` for serialization.
|
||||
#[allow(clippy::to_string_trait_impl)]
|
||||
impl ToString for GenericStringObjectFormat {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
GenericStringObjectFormat::Json(json_object_type) => format!(
|
||||
"{}{}{}",
|
||||
GENERIC_STRING_OBJECT_PREFIX,
|
||||
JSON_OBJECT_PREFIX,
|
||||
json_object_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An object sub-type for objects that implement the JsonModel trait.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
|
||||
pub enum JsonObjectType {
|
||||
Preference,
|
||||
EnvVarCollection,
|
||||
WorkflowEnum,
|
||||
AIFact,
|
||||
MCPServer,
|
||||
AIExecutionProfile,
|
||||
TemplatableMCPServer,
|
||||
CloudEnvironment,
|
||||
ScheduledAmbientAgent,
|
||||
CloudAgentConfig,
|
||||
}
|
||||
|
||||
impl JsonObjectType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
JsonObjectType::Preference => "PREFERENCE",
|
||||
JsonObjectType::EnvVarCollection => "ENVVARCOLLECTION",
|
||||
JsonObjectType::WorkflowEnum => "WORKFLOWENUM",
|
||||
JsonObjectType::AIFact => "AIFACT",
|
||||
JsonObjectType::MCPServer => "MCPSERVER",
|
||||
JsonObjectType::AIExecutionProfile => "AIEXECUTIONPROFILE",
|
||||
JsonObjectType::TemplatableMCPServer => "TEMPLATABLEMCPSERVER",
|
||||
JsonObjectType::CloudEnvironment => "CLOUDENVIRONMENT",
|
||||
JsonObjectType::ScheduledAmbientAgent => "SCHEDULEDAMBIENTAGENT",
|
||||
JsonObjectType::CloudAgentConfig => "CLOUDAGENTCONFIG",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for JsonObjectType {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
|
||||
match value {
|
||||
"PREFERENCE" => Ok(JsonObjectType::Preference),
|
||||
"ENVVARCOLLECTION" => Ok(JsonObjectType::EnvVarCollection),
|
||||
"WORKFLOWENUM" => Ok(JsonObjectType::WorkflowEnum),
|
||||
"AIFACT" => Ok(JsonObjectType::AIFact),
|
||||
"MCPSERVER" => Ok(JsonObjectType::MCPServer),
|
||||
"AIEXECUTIONPROFILE" => Ok(JsonObjectType::AIExecutionProfile),
|
||||
"TEMPLATABLEMCPSERVER" => Ok(JsonObjectType::TemplatableMCPServer),
|
||||
"CLOUDENVIRONMENT" => Ok(JsonObjectType::CloudEnvironment),
|
||||
"SCHEDULEDAMBIENTAGENT" => Ok(JsonObjectType::ScheduledAmbientAgent),
|
||||
"CLOUDAGENTCONFIG" => Ok(JsonObjectType::CloudAgentConfig),
|
||||
_ => Err(anyhow!("could not convert unknown json object type")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object::ObjectType> for ObjectIdType {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(object_type: warp_graphql::object::ObjectType) -> Result<Self, Self::Error> {
|
||||
match object_type {
|
||||
warp_graphql::object::ObjectType::AIConversation => Err(anyhow!(
|
||||
"AIConversation is not a supported object type for this operation"
|
||||
)),
|
||||
warp_graphql::object::ObjectType::Notebook => Ok(ObjectIdType::Notebook),
|
||||
warp_graphql::object::ObjectType::Workflow => Ok(ObjectIdType::Workflow),
|
||||
warp_graphql::object::ObjectType::Folder => Ok(ObjectIdType::Folder),
|
||||
warp_graphql::object::ObjectType::GenericStringObject => {
|
||||
Ok(ObjectIdType::GenericStringObject)
|
||||
}
|
||||
warp_graphql::object::ObjectType::Unknown => {
|
||||
Err(anyhow!("could not convert unknown cloud object type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ObjectType> for warp_graphql::object::ObjectType {
|
||||
fn from(value: ObjectType) -> Self {
|
||||
match value {
|
||||
ObjectType::Notebook => warp_graphql::object::ObjectType::Notebook,
|
||||
ObjectType::Workflow => warp_graphql::object::ObjectType::Workflow,
|
||||
ObjectType::Folder => warp_graphql::object::ObjectType::Folder,
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::EnvVarCollection,
|
||||
)) => warp_graphql::object::ObjectType::GenericStringObject,
|
||||
ObjectType::GenericStringObject(gso) => {
|
||||
todo!("Moving is not implemented for {:?}", gso);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The revision timestamp at which an object was edited. This is used by the server
|
||||
/// to determine if an edit to an object was at the latest revision. Edits at older
|
||||
/// revisions are rejected by the server.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)]
|
||||
pub struct Revision(ServerTimestamp);
|
||||
|
||||
impl Revision {
|
||||
pub fn from_unix_timestamp_micros(ms_since_epoch: i64) -> Result<Self> {
|
||||
let ts = ServerTimestamp::from_unix_timestamp_micros(ms_since_epoch)?;
|
||||
Ok(Self(ts))
|
||||
}
|
||||
|
||||
pub fn timestamp_micros(&self) -> i64 {
|
||||
self.0.timestamp_micros()
|
||||
}
|
||||
|
||||
pub fn utc(&self) -> DateTime<Utc> {
|
||||
self.0.utc()
|
||||
}
|
||||
|
||||
/// Returns the inner `ServerTimestamp`.
|
||||
pub fn timestamp(&self) -> ServerTimestamp {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn now() -> Self {
|
||||
Self(ServerTimestamp::new(Utc::now()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Revision> for ServerTimestamp {
|
||||
fn from(revision: Revision) -> Self {
|
||||
revision.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerTimestamp> for Revision {
|
||||
fn from(time: ServerTimestamp) -> Self {
|
||||
Revision(time)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<DateTime<Utc>> for Revision {
|
||||
fn from(time: DateTime<Utc>) -> Self {
|
||||
Self(ServerTimestamp::new(time))
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner for a given object.
|
||||
#[derive(Copy, Clone, Debug, Eq, Serialize, Deserialize, Derivative)]
|
||||
#[derivative(PartialEq)]
|
||||
pub enum Owner {
|
||||
/// The owner of the object is a user (the object is in their personal drive).
|
||||
User { user_uid: UserUid },
|
||||
/// The owner of the object is a team (the object is in a team drive).
|
||||
Team { team_uid: ServerId },
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
/// A mock [`Owner`] ID for testing.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock_current_user() -> Owner {
|
||||
use crate::auth::TEST_USER_UID;
|
||||
|
||||
Owner::User {
|
||||
user_uid: UserUid::new(TEST_USER_UID),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Owner> for Option<ServerId> {
|
||||
fn from(owner: Owner) -> Option<ServerId> {
|
||||
match owner {
|
||||
Owner::User { .. } => None,
|
||||
Owner::Team { team_uid, .. } => Some(team_uid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server representation of an object's container. This corresponds to the `Container` GraphQL
|
||||
/// type.
|
||||
///
|
||||
/// Containers are similar to, but not quite the same as, the [`CloudObjectLocation`] type.
|
||||
/// Locations depend on object and user state - an object might currently be in the trash, or
|
||||
/// it could be in one user's [shared space](Space::Shared) but another's
|
||||
/// [team space](Space::Team). Containers, on the other hand, represent an object's canonical
|
||||
/// parent - its one parent folder or drive that permissions are inherited from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ServerObjectContainer {
|
||||
Folder { folder_uid: ServerId },
|
||||
Drive { owner: Owner },
|
||||
}
|
||||
|
||||
/// Server representation of a user object guest, as part of [`ServerObjectGuest`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ServerGuestSubject {
|
||||
User { firebase_uid: String },
|
||||
PendingUser { email: Option<String> },
|
||||
Team { team_uid: ServerId },
|
||||
}
|
||||
|
||||
/// Server representation of a link-sharing setting.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerLinkSharing {
|
||||
pub access_level: AccessLevel,
|
||||
pub source: Option<ServerObjectContainer>,
|
||||
}
|
||||
|
||||
/// Server representation of an object guest. This corresponds to the `ObjectGuest` GraphQL type.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerObjectGuest {
|
||||
pub subject: ServerGuestSubject,
|
||||
pub access_level: AccessLevel,
|
||||
/// If this guest is inherited, this is the ancestor that it's inherited from.
|
||||
pub source: Option<ServerObjectContainer>,
|
||||
}
|
||||
|
||||
/// Metadata for a cloud object that was fetched from the server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerMetadata {
|
||||
pub uid: ServerId,
|
||||
pub revision: Revision,
|
||||
pub metadata_last_updated_ts: ServerTimestamp,
|
||||
pub trashed_ts: Option<ServerTimestamp>,
|
||||
pub folder_id: Option<FolderId>,
|
||||
pub is_welcome_object: bool,
|
||||
pub creator_uid: Option<String>,
|
||||
pub last_editor_uid: Option<String>,
|
||||
pub current_editor_uid: Option<String>,
|
||||
}
|
||||
|
||||
/// Permissions for a cloud object that was fetched from the server.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerPermissions {
|
||||
/// The GraphQL definition of a `Space` is closer to the client's definition of an `Owner` (due
|
||||
/// to sharing). This is also going to migrate back to [ServerMetadata] as part of the
|
||||
/// `Container` migration.
|
||||
pub space: Owner,
|
||||
pub guests: Vec<ServerObjectGuest>,
|
||||
pub anyone_link_sharing: Option<ServerLinkSharing>,
|
||||
pub permissions_last_updated_ts: ServerTimestamp,
|
||||
}
|
||||
|
||||
impl ServerPermissions {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock_personal() -> Self {
|
||||
Self {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: DateTime::<Utc>::default().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NumInFlightRequests(pub usize);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// An enum representing what state a local cloud object's content changes can be in,
|
||||
/// in relation to the server.
|
||||
pub enum CloudObjectSyncStatus {
|
||||
/// The object's content hasn't changed from what we believe the server's representation
|
||||
/// to be.
|
||||
NoLocalChanges,
|
||||
/// The object's content has been modified locally, and is currently in the sync queue
|
||||
/// attempting to sync up with the server.
|
||||
InFlight(NumInFlightRequests),
|
||||
/// The object's content has been modified locally but has unresolved conflict with the server
|
||||
/// revision.
|
||||
InConflict,
|
||||
/// The object's content has been modified locally, but persisting the change on the server
|
||||
/// could not complete for some reason.
|
||||
Errored,
|
||||
}
|
||||
|
||||
const SYNC_ICON_DIMENSIONS: f32 = 16.;
|
||||
|
||||
const SYNC_STATUS_TOOLTIP_LOCAL_ONLY: &str = "Saved locally";
|
||||
const SYNC_STATUS_TOOLTIP_INFLIGHT: &str = "Saving";
|
||||
const SYNC_STATUS_TOOLTIP_ERROR: &str = "Failed to save";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CloudObjectPermissions {
|
||||
pub owner: Owner,
|
||||
pub permissions_last_updated_ts: Option<ServerTimestamp>,
|
||||
pub anyone_with_link: Option<CloudLinkSharing>,
|
||||
pub guests: Vec<CloudObjectGuest>,
|
||||
}
|
||||
|
||||
impl CloudObjectPermissions {
|
||||
pub fn new_from_server(server_permissions: ServerPermissions) -> Self {
|
||||
let guests = if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
server_permissions
|
||||
.guests
|
||||
.into_iter()
|
||||
.map(CloudObjectGuest::from_server)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let anyone_with_link = if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
server_permissions
|
||||
.anyone_link_sharing
|
||||
.map(CloudLinkSharing::from_server)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
owner: server_permissions.space,
|
||||
permissions_last_updated_ts: Some(server_permissions.permissions_last_updated_ts),
|
||||
guests,
|
||||
anyone_with_link,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock permissions for a personal object.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock_personal() -> Self {
|
||||
Self {
|
||||
owner: Owner::mock_current_user(),
|
||||
permissions_last_updated_ts: Some(Utc::now().into()),
|
||||
guests: Vec::new(),
|
||||
anyone_with_link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given user has direct personal access to this object —
|
||||
/// either via an explicit user guest ACL entry or via link sharing.
|
||||
/// Returns `false` if the only access is through a team guest ACL.
|
||||
pub fn has_direct_user_access(&self, user_uid: UserUid) -> bool {
|
||||
self.anyone_with_link.is_some() || self.guests.iter().any(|g| g.subject.is_user(user_uid))
|
||||
}
|
||||
|
||||
/// Updates self from new permissions information received from the server
|
||||
pub fn update_from_new_permissions_ts(&mut self, server_permissions: ServerPermissions) {
|
||||
self.owner = server_permissions.space;
|
||||
self.permissions_last_updated_ts = Some(server_permissions.permissions_last_updated_ts);
|
||||
if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
self.guests = server_permissions
|
||||
.guests
|
||||
.into_iter()
|
||||
.map(CloudObjectGuest::from_server)
|
||||
.collect();
|
||||
self.anyone_with_link = server_permissions
|
||||
.anyone_link_sharing
|
||||
.map(CloudLinkSharing::from_server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloudLinkSharing {
|
||||
pub access_level: SharingAccessLevel,
|
||||
// If this sharing setting was inherited, the `source` identifies the container it's inherited
|
||||
// from.
|
||||
pub source: Option<ServerObjectContainer>,
|
||||
}
|
||||
|
||||
impl CloudLinkSharing {
|
||||
pub fn from_server(server_link_sharing: ServerLinkSharing) -> Self {
|
||||
Self {
|
||||
access_level: server_link_sharing.access_level.into(),
|
||||
source: server_link_sharing.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloudObjectGuest {
|
||||
pub subject: Subject,
|
||||
pub access_level: SharingAccessLevel,
|
||||
/// If this guest was added to a container object, the `source` identifies that object.
|
||||
pub source: Option<ServerObjectContainer>,
|
||||
}
|
||||
|
||||
impl CloudObjectGuest {
|
||||
pub fn from_server(server_guest: ServerObjectGuest) -> Self {
|
||||
let subject = match server_guest.subject {
|
||||
ServerGuestSubject::User { firebase_uid } => {
|
||||
Subject::User(UserKind::Account(UserUid::new(&firebase_uid)))
|
||||
}
|
||||
ServerGuestSubject::PendingUser { email } => Subject::PendingUser { email },
|
||||
ServerGuestSubject::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
|
||||
};
|
||||
|
||||
Self {
|
||||
subject,
|
||||
access_level: server_guest.access_level.into(),
|
||||
source: server_guest.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CloudObjectMetadata {
|
||||
pub revision: Option<Revision>,
|
||||
pub metadata_last_updated_ts: Option<ServerTimestamp>,
|
||||
pub current_editor_uid: Option<String>,
|
||||
pub pending_changes_statuses: CloudObjectStatuses,
|
||||
pub trashed_ts: Option<ServerTimestamp>,
|
||||
pub folder_id: Option<SyncId>,
|
||||
/// Welcome objects are created on the server when a user first recieves
|
||||
/// access to Warp Drive as part of onboarding.
|
||||
pub is_welcome_object: bool,
|
||||
pub last_editor_uid: Option<String>,
|
||||
pub creator_uid: Option<String>,
|
||||
/// The "last used" timestamp for this environment.
|
||||
///
|
||||
/// This is populated via `GetCloudEnvironments` from
|
||||
/// `CloudEnvironment.lastTaskCreated.createdAt`.
|
||||
/// Only applicable for CloudEnvironment objects.
|
||||
pub last_task_run_ts: Option<ServerTimestamp>,
|
||||
}
|
||||
|
||||
impl CloudObjectMetadata {
|
||||
pub fn new_from_server(server_metadata: ServerMetadata) -> Self {
|
||||
Self {
|
||||
revision: Some(server_metadata.revision),
|
||||
current_editor_uid: server_metadata.current_editor_uid,
|
||||
metadata_last_updated_ts: Some(server_metadata.metadata_last_updated_ts),
|
||||
pending_changes_statuses: CloudObjectStatuses {
|
||||
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
|
||||
has_pending_metadata_change: false,
|
||||
has_pending_permissions_change: false,
|
||||
pending_untrash: false,
|
||||
pending_delete: false,
|
||||
},
|
||||
trashed_ts: server_metadata.trashed_ts,
|
||||
folder_id: server_metadata.folder_id.map(|id| id.into()),
|
||||
is_welcome_object: server_metadata.is_welcome_object,
|
||||
creator_uid: server_metadata.creator_uid,
|
||||
last_editor_uid: server_metadata.last_editor_uid,
|
||||
// last_task_run_ts is populated separately via GetCloudEnvironments query
|
||||
last_task_run_ts: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new set of metadata with reasonable defaults for a test:
|
||||
/// * Content and metadata timestamps set to now
|
||||
/// * No editor information
|
||||
/// * No parent folder
|
||||
/// * Not trashed
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock() -> Self {
|
||||
Self {
|
||||
revision: Some(Revision::now()),
|
||||
current_editor_uid: None,
|
||||
metadata_last_updated_ts: Some(Utc::now().into()),
|
||||
pending_changes_statuses: CloudObjectStatuses::mock(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
last_editor_uid: None,
|
||||
creator_uid: None,
|
||||
last_task_run_ts: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_pending_content_changes(&self) -> bool {
|
||||
!matches!(
|
||||
self.pending_changes_statuses.content_sync_status,
|
||||
CloudObjectSyncStatus::NoLocalChanges | CloudObjectSyncStatus::InConflict
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_errored(&self) -> bool {
|
||||
matches!(
|
||||
self.pending_changes_statuses.content_sync_status,
|
||||
CloudObjectSyncStatus::Errored
|
||||
)
|
||||
}
|
||||
|
||||
/// True iff there are unsynced online-only changes for the object.
|
||||
pub fn has_pending_online_only_change(&self) -> bool {
|
||||
self.pending_changes_statuses.has_pending_permissions_change
|
||||
|| self.pending_changes_statuses.has_pending_metadata_change
|
||||
|| self.pending_changes_statuses.pending_untrash
|
||||
|| self.pending_changes_statuses.pending_delete
|
||||
}
|
||||
|
||||
pub fn set_current_editor(&mut self, editor_uid: Option<String>) {
|
||||
self.current_editor_uid = editor_uid;
|
||||
}
|
||||
|
||||
/// Updates revision and last_editor_uid from server metadata.
|
||||
///
|
||||
/// This unconditionally updates the revision and last_editor_uid, even if
|
||||
/// there are conflicts, so callers should check for conflicts before calling
|
||||
/// this.
|
||||
pub fn update_revision_from_server(&mut self, server_metadata: &ServerMetadata) {
|
||||
self.revision = Some(server_metadata.revision.clone());
|
||||
self.last_editor_uid = server_metadata.last_editor_uid.clone();
|
||||
}
|
||||
|
||||
/// Updates self from a new metadata received from the server
|
||||
pub fn update_from_new_metadata_ts(&mut self, server_metadata: ServerMetadata) {
|
||||
// Overwriting the metadata from an MetadataUpdated RTC message shouldn't overwrite
|
||||
// the versioning of the object's data: the revision timestamp, has_pending_changes, conflict_status
|
||||
// (if the object data is not being updated, the data versioning should stay the same.
|
||||
self.current_editor_uid = server_metadata.current_editor_uid;
|
||||
self.trashed_ts = server_metadata.trashed_ts;
|
||||
self.folder_id = server_metadata.folder_id.map(|folder_id| folder_id.into());
|
||||
self.creator_uid = server_metadata.creator_uid;
|
||||
self.metadata_last_updated_ts = Some(server_metadata.metadata_last_updated_ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct holding the different statuses of pending changes that a cloud object might have.
|
||||
/// Note that content is handled differently than permissions/metadata:
|
||||
/// * Content changes go through the sync queue, and thus can exist in more states
|
||||
/// * Metadata/permissions changes are synchronous operations, and thus are only either
|
||||
/// in flight or synced
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CloudObjectStatuses {
|
||||
pub content_sync_status: CloudObjectSyncStatus,
|
||||
/// True iff there are unsynced permission changes for the object.
|
||||
/// We intentionally don't persist this value in sqlite. And if true,
|
||||
/// we don't upsert any in-memory permission changes to sqlite.
|
||||
pub has_pending_permissions_change: bool,
|
||||
/// True iff there are unsynced metadata changes for the object.
|
||||
/// We intentionally don't persist this value in sqlite. And if true,
|
||||
/// we don't upsert trashed and folder changes to sqlite.
|
||||
pub has_pending_metadata_change: bool,
|
||||
|
||||
/// True iff there is an unsynced untrash operation on the object.
|
||||
pub pending_untrash: bool,
|
||||
|
||||
/// True iff there is an unsynced delete operation on the object.
|
||||
pub pending_delete: bool,
|
||||
}
|
||||
|
||||
impl CloudObjectStatuses {
|
||||
/// Empty statuses with no in-flight changes, for use in tests.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn mock() -> Self {
|
||||
Self {
|
||||
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
|
||||
has_pending_permissions_change: false,
|
||||
has_pending_metadata_change: false,
|
||||
pending_untrash: false,
|
||||
pending_delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_icon(
|
||||
&self,
|
||||
sync_queue_is_dequeueing: bool,
|
||||
hover_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let theme = appearance.theme();
|
||||
let has_in_flight_requests = match &self.content_sync_status {
|
||||
CloudObjectSyncStatus::InFlight(reqs) => reqs.0 > 0,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let should_show_local_only_indicator = has_in_flight_requests && !sync_queue_is_dequeueing;
|
||||
let should_show_syncing_indicator = has_in_flight_requests
|
||||
|| self.has_pending_metadata_change
|
||||
|| self.has_pending_permissions_change
|
||||
|| self.pending_untrash;
|
||||
let should_show_error_indicator = matches!(
|
||||
self.content_sync_status,
|
||||
CloudObjectSyncStatus::Errored | CloudObjectSyncStatus::InConflict
|
||||
);
|
||||
|
||||
let icon_and_tooltip_text = if should_show_local_only_indicator {
|
||||
Some((
|
||||
Icon::Laptop.to_warpui_icon(theme.main_text_color(theme.surface_1())),
|
||||
SYNC_STATUS_TOOLTIP_LOCAL_ONLY,
|
||||
))
|
||||
} else if should_show_syncing_indicator {
|
||||
Some((
|
||||
Icon::Refresh.to_warpui_icon(theme.sub_text_color(theme.surface_2())),
|
||||
SYNC_STATUS_TOOLTIP_INFLIGHT,
|
||||
))
|
||||
} else if should_show_error_indicator {
|
||||
Some((
|
||||
Icon::AlertTriangle.to_warpui_icon(Fill::Solid(theme.ui_error_color())),
|
||||
SYNC_STATUS_TOOLTIP_ERROR,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((icon, tooltip_text)) = icon_and_tooltip_text {
|
||||
return Some(
|
||||
Align::new(
|
||||
Hoverable::new(hover_state, move |hover_state| {
|
||||
let mut stack = Stack::new().with_child(
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_height(SYNC_ICON_DIMENSIONS)
|
||||
.with_width(SYNC_ICON_DIMENSIONS)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if hover_state.is_hovered() {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(tooltip_text.to_string())
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -24.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Used for event tracking purposes, matches
|
||||
// up with GraphQL enum of the same name.
|
||||
#[derive(Copy, Default, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CloudObjectEventEntrypoint {
|
||||
TeamSettings,
|
||||
ResourceCenter,
|
||||
UniversalSearch,
|
||||
ManagementUI,
|
||||
Blocklist,
|
||||
ImportModal,
|
||||
Onboarding,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// GraphQL conversion impls.
|
||||
|
||||
impl From<GenericStringObjectFormat>
|
||||
for warp_graphql::generic_string_object::GenericStringObjectFormat
|
||||
{
|
||||
fn from(format: GenericStringObjectFormat) -> Self {
|
||||
use warp_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
|
||||
match format {
|
||||
GenericStringObjectFormat::Json(JsonObjectType::Preference) => {
|
||||
GraphQLFormat::JsonPreference
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) => {
|
||||
GraphQLFormat::JsonEnvVarCollection
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::WorkflowEnum) => {
|
||||
GraphQLFormat::JsonWorkflowEnum
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIFact) => GraphQLFormat::JsonAIFact,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::MCPServer) => {
|
||||
GraphQLFormat::JsonMCPServer
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile) => {
|
||||
GraphQLFormat::JsonAIExecutionProfile
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer) => {
|
||||
GraphQLFormat::JsonTemplatableMCPServer
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::CloudEnvironment) => {
|
||||
GraphQLFormat::JsonCloudEnvironment
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent) => {
|
||||
GraphQLFormat::JsonScheduledAmbientAgent
|
||||
}
|
||||
GenericStringObjectFormat::Json(JsonObjectType::CloudAgentConfig) => {
|
||||
unreachable!("JsonCloudAgentConfig is no longer present in GraphQL schema")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CloudObjectEventEntrypoint> for warp_graphql::object::CloudObjectEventEntrypoint {
|
||||
fn from(entrypoint: CloudObjectEventEntrypoint) -> Self {
|
||||
use warp_graphql::object::CloudObjectEventEntrypoint as GraphQLEntrypoint;
|
||||
match entrypoint {
|
||||
CloudObjectEventEntrypoint::TeamSettings => GraphQLEntrypoint::TeamSettings,
|
||||
CloudObjectEventEntrypoint::ResourceCenter => GraphQLEntrypoint::ResourceCenter,
|
||||
CloudObjectEventEntrypoint::UniversalSearch => GraphQLEntrypoint::UniversalSearch,
|
||||
CloudObjectEventEntrypoint::ManagementUI => GraphQLEntrypoint::DriveIndex,
|
||||
CloudObjectEventEntrypoint::Blocklist => GraphQLEntrypoint::Blocklist,
|
||||
CloudObjectEventEntrypoint::ImportModal => GraphQLEntrypoint::ImportModal,
|
||||
CloudObjectEventEntrypoint::Onboarding => GraphQLEntrypoint::Onboarding,
|
||||
CloudObjectEventEntrypoint::Unknown => GraphQLEntrypoint::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object::ObjectMetadata> for ServerMetadata {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object::ObjectMetadata) -> Result<Self, Self::Error> {
|
||||
let folder_id: Option<FolderId> = match value.parent {
|
||||
warp_graphql::object::Container::FolderContainer(folder_container) => {
|
||||
Some(folder_container.folder_uid.into_inner().into())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let metadata = ServerMetadata {
|
||||
uid: ServerId::from_string_lossy(value.uid.inner()),
|
||||
revision: value.revision_ts.into(),
|
||||
metadata_last_updated_ts: value.metadata_last_updated_ts,
|
||||
trashed_ts: value.trashed_ts,
|
||||
folder_id,
|
||||
is_welcome_object: value.is_welcome_object,
|
||||
creator_uid: value.creator_uid.map(|uid| uid.into_inner()),
|
||||
last_editor_uid: value.last_editor_uid.map(|uid| uid.into_inner()),
|
||||
current_editor_uid: value.current_editor_uid.map(|uid| uid.into_inner()),
|
||||
};
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object_permissions::ObjectPermissions> for ServerPermissions {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(
|
||||
value: warp_graphql::object_permissions::ObjectPermissions,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let server_object_guests: Result<Vec<ServerObjectGuest>, _> = value
|
||||
.guests
|
||||
.into_iter()
|
||||
.map(|guest| guest.try_into())
|
||||
.collect();
|
||||
let object_permissions = ServerPermissions {
|
||||
space: value.space.try_into()?,
|
||||
guests: server_object_guests?,
|
||||
anyone_link_sharing: match value.anyone_link_sharing {
|
||||
Some(sharing) => Some(sharing.try_into()?),
|
||||
None => None,
|
||||
},
|
||||
permissions_last_updated_ts: value.last_updated_ts,
|
||||
};
|
||||
Ok(object_permissions)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object_permissions::ObjectGuest> for ServerObjectGuest {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object_permissions::ObjectGuest) -> Result<Self, Self::Error> {
|
||||
let object_guest = ServerObjectGuest {
|
||||
subject: value.subject.try_into()?,
|
||||
access_level: value.access_level,
|
||||
source: match value.source {
|
||||
Some(container) => Some(container.try_into()?),
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
Ok(object_guest)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object_permissions::GuestSubject> for ServerGuestSubject {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(
|
||||
value: warp_graphql::object_permissions::GuestSubject,
|
||||
) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
warp_graphql::object_permissions::GuestSubject::UserGuest(user_guest) => {
|
||||
let guest_subject = ServerGuestSubject::User {
|
||||
firebase_uid: user_guest.firebase_uid.into_inner(),
|
||||
};
|
||||
Ok(guest_subject)
|
||||
}
|
||||
warp_graphql::object_permissions::GuestSubject::PendingUserGuest(guest) => {
|
||||
Ok(ServerGuestSubject::PendingUser { email: guest.email })
|
||||
}
|
||||
warp_graphql::object_permissions::GuestSubject::TeamGuest(team_guest) => {
|
||||
Ok(ServerGuestSubject::Team {
|
||||
team_uid: ServerId::from_string_lossy(team_guest.uid.inner()),
|
||||
})
|
||||
}
|
||||
warp_graphql::object_permissions::GuestSubject::Unknown => {
|
||||
anyhow::bail!("Unknown GuestSubject type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object_permissions::LinkSharing> for ServerLinkSharing {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object_permissions::LinkSharing) -> Result<Self, Self::Error> {
|
||||
Ok(ServerLinkSharing {
|
||||
access_level: value.access_level,
|
||||
source: value.source.map(TryInto::try_into).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object::Container> for ServerObjectContainer {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object::Container) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
warp_graphql::object::Container::FolderContainer(folder) => {
|
||||
Ok(ServerObjectContainer::Folder {
|
||||
folder_uid: ServerId::from_string_lossy(folder.folder_uid.inner()),
|
||||
})
|
||||
}
|
||||
warp_graphql::object::Container::Space(space) => Ok(ServerObjectContainer::Drive {
|
||||
owner: space.try_into()?,
|
||||
}),
|
||||
warp_graphql::object::Container::Unknown => {
|
||||
anyhow::bail!("Unknown Container type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object::Space> for Owner {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object::Space) -> Result<Self, Self::Error> {
|
||||
let owner = match value.type_ {
|
||||
warp_graphql::object::SpaceType::Team => Owner::Team {
|
||||
team_uid: ServerId::from_string_lossy(value.uid.inner()),
|
||||
},
|
||||
warp_graphql::object::SpaceType::User => Owner::User {
|
||||
user_uid: UserUid::new(value.uid.inner()),
|
||||
},
|
||||
};
|
||||
Ok(owner)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Owner> for warp_graphql::object_permissions::Owner {
|
||||
fn from(owner: Owner) -> Self {
|
||||
use warp_graphql::object_permissions::Owner as GraphQLOwner;
|
||||
use warp_graphql::object_permissions::OwnerType;
|
||||
match owner {
|
||||
Owner::User { user_uid } => GraphQLOwner {
|
||||
type_: OwnerType::User,
|
||||
uid: Some(cynic::Id::new(user_uid.to_string())),
|
||||
},
|
||||
Owner::Team { team_uid, .. } => GraphQLOwner {
|
||||
type_: OwnerType::Team,
|
||||
uid: Some(cynic::Id::new(team_uid)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod sharing;
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_sharing_protocol::common::{ProfileData as SessionSharingProfileData, Role};
|
||||
use warp_graphql::object_permissions::AccessLevel;
|
||||
|
||||
use crate::{auth::UserUid, cloud_object::Owner, ids::ServerId};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum SharingAccessLevel {
|
||||
View,
|
||||
Edit,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl SharingAccessLevel {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
SharingAccessLevel::View => "Can view",
|
||||
SharingAccessLevel::Edit => "Can edit",
|
||||
SharingAccessLevel::Full => "Full access",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
SharingAccessLevel::View => "view",
|
||||
SharingAccessLevel::Edit => "edit",
|
||||
SharingAccessLevel::Full => "access",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not this access level implies the `Trash` action.
|
||||
pub fn can_trash(self) -> bool {
|
||||
self >= SharingAccessLevel::Edit
|
||||
}
|
||||
|
||||
/// Whether or not this access level implies the `DeletePermanently` action.
|
||||
pub fn can_delete(self) -> bool {
|
||||
self >= SharingAccessLevel::Full
|
||||
}
|
||||
|
||||
/// Whether or not this access level implies the `ChangeOwner` action.
|
||||
pub fn can_move_drive(self) -> bool {
|
||||
self >= SharingAccessLevel::Full
|
||||
}
|
||||
|
||||
/// Whether or not this access level implies the `EditAccess` action.
|
||||
pub fn can_edit_access(self) -> bool {
|
||||
self >= SharingAccessLevel::Full
|
||||
}
|
||||
|
||||
/// Convert this access level to a serializable value, which can be parsed by [`FromStr`].
|
||||
pub fn to_serializable_value(self) -> &'static str {
|
||||
match self {
|
||||
SharingAccessLevel::View => "VIEW",
|
||||
SharingAccessLevel::Edit => "EDIT",
|
||||
SharingAccessLevel::Full => "FULL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SharingAccessLevel {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"VIEW" => Ok(Self::View),
|
||||
"EDIT" => Ok(Self::Edit),
|
||||
"FULL" => Ok(Self::Full),
|
||||
_ => Err(anyhow::anyhow!("unknown access level {value}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccessLevel> for SharingAccessLevel {
|
||||
fn from(server_access: AccessLevel) -> Self {
|
||||
match server_access {
|
||||
AccessLevel::Viewer => Self::View,
|
||||
AccessLevel::Editor => Self::Edit,
|
||||
AccessLevel::Full => Self::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharingAccessLevel> for AccessLevel {
|
||||
fn from(val: SharingAccessLevel) -> Self {
|
||||
match val {
|
||||
SharingAccessLevel::View => AccessLevel::Viewer,
|
||||
SharingAccessLevel::Edit => AccessLevel::Editor,
|
||||
SharingAccessLevel::Full => AccessLevel::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Role> for SharingAccessLevel {
|
||||
fn from(role: Role) -> Self {
|
||||
match role {
|
||||
Role::Reader => Self::View,
|
||||
Role::Executor => Self::Edit,
|
||||
Role::Full => Self::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharingAccessLevel> for Role {
|
||||
fn from(access_level: SharingAccessLevel) -> Self {
|
||||
match access_level {
|
||||
SharingAccessLevel::View => Self::Reader,
|
||||
SharingAccessLevel::Edit | SharingAccessLevel::Full => Self::Executor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum LinkSharingSubjectType {
|
||||
None,
|
||||
Anyone,
|
||||
}
|
||||
/// A `Subject` is someone with access to a shared object, like its owner or a directly-added
|
||||
/// guest.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Subject {
|
||||
User(UserKind),
|
||||
#[allow(dead_code)]
|
||||
PendingUser {
|
||||
email: Option<String>,
|
||||
},
|
||||
Team(TeamKind),
|
||||
AnyoneWithLink(LinkSharingSubjectType),
|
||||
}
|
||||
|
||||
/// A kind of user. In all cases, there is an underlying Warp account, but it's represented
|
||||
/// differently in certain cases.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UserKind {
|
||||
/// A Warp user account, tracked in the [`UserProfiles`] model.
|
||||
Account(UserUid),
|
||||
/// A session-sharing participant.
|
||||
// TODO(CLD-2283): Remove this once we have Firebase UIDs for shared session participants.
|
||||
SharedSessionParticipant(SessionSharingProfileData),
|
||||
}
|
||||
|
||||
/// A kind of team. Team permission updates are propagated differently for
|
||||
/// shared sessions, so we need to store different info in certain cases.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TeamKind {
|
||||
Team {
|
||||
team_uid: ServerId,
|
||||
},
|
||||
/// The team of the shared session sharer.
|
||||
SharedSessionTeam {
|
||||
team_uid: ServerId,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TeamKind {
|
||||
/// Gets the team UID.
|
||||
pub fn team_uid(&self) -> ServerId {
|
||||
match self {
|
||||
TeamKind::Team { team_uid } => *team_uid,
|
||||
TeamKind::SharedSessionTeam { team_uid, .. } => *team_uid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// Convert an [`Owner`] into the closest [`Subject`] type.
|
||||
pub fn from_owner(owner: Owner) -> Self {
|
||||
match owner {
|
||||
Owner::User { user_uid } => Subject::User(UserKind::Account(user_uid)),
|
||||
Owner::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the user UID for this subject, if it has one.
|
||||
pub fn user_uid(&self) -> Option<UserUid> {
|
||||
match self {
|
||||
Subject::User(user_kind) => match user_kind {
|
||||
UserKind::Account(user_uid) => Some(*user_uid),
|
||||
UserKind::SharedSessionParticipant(profile_data) => {
|
||||
Some(UserUid::new(profile_data.firebase_uid.as_str()))
|
||||
}
|
||||
},
|
||||
Subject::PendingUser { .. } => None,
|
||||
Subject::Team(_) => None,
|
||||
Subject::AnyoneWithLink(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if this subject refers to a given Firebase user directly.
|
||||
pub fn is_user(&self, other_uid: UserUid) -> bool {
|
||||
match self {
|
||||
Subject::User(UserKind::Account(user_uid)) => *user_uid == other_uid,
|
||||
Subject::User(UserKind::SharedSessionParticipant(profile_data)) => {
|
||||
profile_data.firebase_uid.as_str() == other_uid.as_str()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the team UID for this subject, if it has one.
|
||||
pub fn team_uid(&self) -> Option<ServerId> {
|
||||
match self {
|
||||
Subject::Team(team_kind) => Some(team_kind.team_uid()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for UserKind {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Account(self_uid), Self::Account(other_uid)) => self_uid == other_uid,
|
||||
// Shared session participant data does not implement `PartialEq`. We only compare
|
||||
// `UserKind`s in tests, so support isn't yet needed.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
use std::fmt;
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cloud_object::ObjectIdType;
|
||||
|
||||
/// Convert ID enums into and from a hashed UUID.
|
||||
pub trait HashableId: Sized + Send + Sync {
|
||||
fn to_hash(&self) -> String;
|
||||
fn from_hash(hash: &str) -> Option<Self>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(description = "A client-generated unique identifier.")]
|
||||
pub struct ClientId(Uuid);
|
||||
|
||||
impl HashableId for ClientId {
|
||||
fn to_hash(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
fn from_hash(hash: &str) -> Option<ClientId> {
|
||||
hash.strip_prefix("Client-")
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.map(ClientId)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientId {
|
||||
pub fn new() -> ClientId {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn sqlite_hash(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientId {
|
||||
fn default() -> Self {
|
||||
ClientId::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Client-{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ClientId {
|
||||
fn from(s: String) -> Self {
|
||||
ClientId::from_hash(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// ID of an object in the sync queue.
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, schemars::JsonSchema)]
|
||||
#[schemars(description = "Identifier for a synced object, either local or server-assigned.")]
|
||||
pub enum SyncId {
|
||||
/// Item has not been sync-ed yet. Using a client-created UUID.
|
||||
#[schemars(
|
||||
description = "A locally-generated identifier for an object not yet synced to the server."
|
||||
)]
|
||||
ClientId(ClientId),
|
||||
/// Item has been sync-ed to the cloud. Using the server ID.
|
||||
#[schemars(description = "A server-assigned identifier for a synced object.")]
|
||||
ServerId(ServerId),
|
||||
}
|
||||
|
||||
impl SyncId {
|
||||
pub fn from_object_id<K>(id: K) -> Self
|
||||
where
|
||||
K: ToServerId,
|
||||
{
|
||||
Self::ServerId(id.to_server_id())
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
match self {
|
||||
Self::ClientId(id) => id.to_string(),
|
||||
Self::ServerId(id) => id.uid(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sqlite_uid_hash(&self, object_id_type: ObjectIdType) -> String {
|
||||
match self {
|
||||
SyncId::ClientId(id) => id.sqlite_hash(),
|
||||
SyncId::ServerId(id) => id.sqlite_type_and_uid_hash(object_id_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// If this item has been synced to the cloud, extract its server ID.
|
||||
pub fn into_server(self) -> Option<ServerId> {
|
||||
match self {
|
||||
Self::ServerId(id) => Some(id),
|
||||
Self::ClientId(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_client(self) -> Option<ClientId> {
|
||||
match self {
|
||||
Self::ServerId(_) => None,
|
||||
Self::ClientId(id) => Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl settings_value::SettingsValue for SyncId {}
|
||||
|
||||
impl fmt::Display for SyncId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::ServerId(id) => id.fmt(f),
|
||||
Self::ClientId(id) => id.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerId> for SyncId {
|
||||
fn from(id: ServerId) -> SyncId {
|
||||
SyncId::ServerId(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom serialize function for SyncIds.
|
||||
impl Serialize for SyncId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
SyncId::ServerId(server_id) => server_id.serialize(serializer),
|
||||
SyncId::ClientId(client_id) => client_id.to_hash().serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom deserialize function for SyncIds.
|
||||
impl<'de> Deserialize<'de> for SyncId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
// We try to deserialize as a ClientID, which only succeeds if the ID is prefixed with `Client-`.
|
||||
// If that fails, we assume this is a server id and create a server ID.
|
||||
if let Some(hashed) = ClientId::from_hash(s.as_str()) {
|
||||
Ok(SyncId::ClientId(hashed))
|
||||
} else {
|
||||
Ok(SyncId::ServerId(ServerId::from_string_lossy(s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the ServerId, should be in sync with the length picked for the server.
|
||||
const SERVER_ID_LENGTH: usize = 22;
|
||||
|
||||
/// ServerId is a representation of a string-based unique ID we generate on the server,
|
||||
/// of length SERVER_ID_LENGTH.
|
||||
/// Because it's of fixed length, it can implement the Copy trait
|
||||
/// (in contrast to simply using a String type).
|
||||
#[derive(Clone, Copy, Default, Hash, PartialEq, Eq, schemars::JsonSchema)]
|
||||
#[schemars(description = "A server-assigned unique identifier.")]
|
||||
pub struct ServerId([char; SERVER_ID_LENGTH]);
|
||||
|
||||
/// For server IDs, this is the value that is stored
|
||||
/// in the database. For client IDs, it is of the form "Client-{id}".
|
||||
/// Used to index into cloud model and in most object read, write, and metadata
|
||||
/// mutation server APIs.
|
||||
pub type ObjectUid = String;
|
||||
|
||||
/// Corresponds to what is stored for a given object id within the local sqlite
|
||||
/// database. Needed for backwards compatibility of the sqlite db following a refactor
|
||||
/// that stripped the object type away from SyncID.
|
||||
///
|
||||
/// Of the format {sqlite_prefix}-{uid}.
|
||||
///
|
||||
/// Other than sqlite model events, this id is used for embedded objects within notebooks.
|
||||
pub type HashedSqliteId = String;
|
||||
|
||||
/// UID for API keys.
|
||||
pub type ApiKeyUid = String;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseServerIdError {
|
||||
#[error("ServerId must be exactly {SERVER_ID_LENGTH} characters, got {len}")]
|
||||
InvalidLength { len: usize },
|
||||
}
|
||||
|
||||
/// Removes the prefix from sqlite IDs to extract the UIDs. Should not be used unless there
|
||||
/// is not other way to cleanly do the conversion, i.e., when we don't know the ID type.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse_sqlite_id_to_uid(hashed_sqlite_id: HashedSqliteId) -> Result<ObjectUid, ()> {
|
||||
let Some(uid) = hashed_sqlite_id.split("-").last() else {
|
||||
return Err(());
|
||||
};
|
||||
|
||||
Ok(uid.to_owned())
|
||||
}
|
||||
|
||||
impl ServerId {
|
||||
/// Convert a string input to a server ID. If the string is not exactly
|
||||
/// [`SERVER_ID_LENGTH`] characters long, it will be truncated or padded as
|
||||
/// necessary.
|
||||
pub fn from_string_lossy(id: impl AsRef<str>) -> Self {
|
||||
let id = id.as_ref();
|
||||
Self::try_from(id).unwrap_or_else(|err| {
|
||||
if cfg!(debug_assertions) {
|
||||
panic!("{err}");
|
||||
}
|
||||
// ServerIds need to be exactly 22 characters, so to prevent a crash, we'll normalize
|
||||
// the string. Nothing that uses it will work, but it's better than crashing.
|
||||
let normalized = Self::normalize_id_str(id, 0);
|
||||
Self::try_from(normalized).expect("id should convert")
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalizes a string to be exactly 22 characters long.
|
||||
fn normalize_id_str(input: &str, prefix_length: usize) -> String {
|
||||
let available_len = SERVER_ID_LENGTH - prefix_length;
|
||||
let truncated = if input.len() > available_len {
|
||||
&input[input.len() - available_len..]
|
||||
} else {
|
||||
input
|
||||
};
|
||||
format!("{truncated:0>available_len$}")
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
(*self).into()
|
||||
}
|
||||
|
||||
/// We need this API for backwards compatibility with local sqlite data.
|
||||
/// In sqlite, objects are stored in object typy, uid pairs of the format
|
||||
/// {sqlite-prefix}-{uid}. For example, for a workflow this would be
|
||||
/// "Workflow-{uid}".
|
||||
pub fn sqlite_type_and_uid_hash(&self, object_id_type: ObjectIdType) -> HashedSqliteId {
|
||||
format!("{}-{}", object_id_type.sqlite_prefix(), self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ServerId {
|
||||
type Error = ParseServerIdError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.chars().collect_array() {
|
||||
Some(chars) => Ok(Self(chars)),
|
||||
None => Err(ParseServerIdError::InvalidLength {
|
||||
len: s.chars().count(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ServerId {
|
||||
type Error = ParseServerIdError;
|
||||
|
||||
fn try_from(id: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a conversion between an i64 and a corresponding deterministic ServerId for use in tests.
|
||||
/// An i64 like 123 will be converted to "test_uid00000000000123".
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<i64> for ServerId {
|
||||
fn from(id: i64) -> Self {
|
||||
let prefix = "test_uid";
|
||||
let id_str = id.abs().to_string();
|
||||
let normalized = format!(
|
||||
"{}{}",
|
||||
prefix,
|
||||
Self::normalize_id_str(&id_str, prefix.len())
|
||||
);
|
||||
Self::try_from(normalized).expect("normalized string should always be valid")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerId> for String {
|
||||
fn from(id: ServerId) -> String {
|
||||
String::from_iter(id.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// We need our own implementation of serialize, due to ServerId being essentially a char array.
|
||||
/// The default serializer in this case would spit a string that looks like an array, instead of a
|
||||
/// nicely formatted string that we want. This implementation would serialize ServerId('a', 'b') to
|
||||
/// "ab" instead.
|
||||
impl Serialize for ServerId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s: String = (*self).into();
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ServerId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
ServerId::try_from(s.as_str()).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
use std::fmt::Write;
|
||||
for ch in self.0.iter() {
|
||||
f.write_char(*ch)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "ServerId({self})")
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToServerId {
|
||||
fn to_server_id(&self) -> ServerId;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerIdAndType {
|
||||
pub id: ServerId,
|
||||
pub id_type: ObjectIdType,
|
||||
}
|
||||
|
||||
impl ServerIdAndType {
|
||||
pub fn sqlite_type_and_uid_hash(&self) -> HashedSqliteId {
|
||||
self.id.sqlite_type_and_uid_hash(self.id_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// string_id_traits is a macro used for generating implementations for the type aliases on
|
||||
/// ServerId, implements different To/From and Display, and HashableId traits.
|
||||
/// Takes type and desired prefix for HashableId.
|
||||
#[macro_export]
|
||||
macro_rules! server_id_traits {
|
||||
($t:ty, $prefix:literal) => {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<i64> for $t {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $t {
|
||||
fn from(id: String) -> Self {
|
||||
Self($crate::ids::ServerId::from_string_lossy(id))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$t> for String {
|
||||
fn from(id: $t) -> String {
|
||||
id.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $t {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$t> for $crate::ids::ServerId {
|
||||
fn from(id: $t) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::ids::HashableId for $t {
|
||||
fn to_hash(&self) -> String {
|
||||
format!("{}-{}", $prefix, self)
|
||||
}
|
||||
|
||||
fn from_hash(hash: &str) -> Option<$t> {
|
||||
hash.strip_prefix(&format!("{}-", $prefix))
|
||||
.map(|s| s.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$crate::ids::ServerId> for $t {
|
||||
fn from(id: $crate::ids::ServerId) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::ids::ToServerId for $t {
|
||||
fn to_server_id(&self) -> $crate::ids::ServerId {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct FolderId(ServerId);
|
||||
server_id_traits! { FolderId, "Folder" }
|
||||
|
||||
impl From<FolderId> for SyncId {
|
||||
fn from(id: FolderId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod cloud_object;
|
||||
pub mod drive;
|
||||
pub mod ids;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
|
||||
pub use auth::UserUid;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Supporting types for persisting cloud objects to SQLite.
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
cloud_object::{CloudLinkSharing, CloudObjectGuest, ServerObjectContainer},
|
||||
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
|
||||
ids::ServerId,
|
||||
};
|
||||
|
||||
/// Decode a link-sharing setting.
|
||||
pub fn decode_link_sharing(
|
||||
encoded_access_level: &str,
|
||||
encoded_source: Option<&[u8]>,
|
||||
) -> anyhow::Result<CloudLinkSharing> {
|
||||
let access_level = encoded_access_level.parse()?;
|
||||
let source = encoded_source.map(bincode::deserialize).transpose()?;
|
||||
Ok(CloudLinkSharing {
|
||||
access_level,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a link-sharing setting.
|
||||
pub fn encode_link_sharing(
|
||||
link_sharing: &CloudLinkSharing,
|
||||
) -> anyhow::Result<(&'static str, Option<Vec<u8>>)> {
|
||||
let source = link_sharing
|
||||
.source
|
||||
.as_ref()
|
||||
.map(bincode::serialize)
|
||||
.transpose()?;
|
||||
Ok((link_sharing.access_level.to_serializable_value(), source))
|
||||
}
|
||||
|
||||
/// Deserialize encoded object guests.
|
||||
pub fn decode_guests(encoded_guests: &[u8]) -> anyhow::Result<Vec<CloudObjectGuest>> {
|
||||
let persisted_guests = bincode::deserialize::<Vec<PersistedGuest>>(encoded_guests)?;
|
||||
Ok(persisted_guests
|
||||
.into_iter()
|
||||
.map(PersistedGuest::into_cloud_object_guest)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Encode object guests for persistence.
|
||||
pub fn encode_guests(guests: &[CloudObjectGuest]) -> anyhow::Result<Vec<u8>> {
|
||||
let persisted_guests = guests
|
||||
.iter()
|
||||
.map(PersistedGuest::try_from_cloud_object_guest)
|
||||
.collect::<anyhow::Result<Vec<PersistedGuest>>>()?;
|
||||
Ok(bincode::serialize(&persisted_guests)?)
|
||||
}
|
||||
|
||||
/// Database representation of an object guest. These are [`bincode`]-serialized to support storing
|
||||
/// an arbitrarily-long guest list.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedGuest {
|
||||
subject: PersistedSubject,
|
||||
access_level: SharingAccessLevel,
|
||||
source: Option<ServerObjectContainer>,
|
||||
}
|
||||
|
||||
/// Database representation of a guest subject. This is restricted compared to the [`Subject`] type
|
||||
/// since not all subjects are persisted.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum PersistedSubject {
|
||||
User { firebase_uid: String },
|
||||
PendingUser { email: Option<String> },
|
||||
Team { team_uid: ServerId },
|
||||
}
|
||||
|
||||
impl PersistedGuest {
|
||||
pub fn into_cloud_object_guest(self) -> CloudObjectGuest {
|
||||
CloudObjectGuest {
|
||||
subject: self.subject.into_subject(),
|
||||
access_level: self.access_level,
|
||||
source: self.source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_from_cloud_object_guest(guest: &CloudObjectGuest) -> anyhow::Result<Self> {
|
||||
Ok(PersistedGuest {
|
||||
subject: PersistedSubject::try_from_subject(&guest.subject)?,
|
||||
access_level: guest.access_level,
|
||||
source: guest.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PersistedSubject {
|
||||
pub fn into_subject(self) -> Subject {
|
||||
match self {
|
||||
PersistedSubject::User { firebase_uid } => {
|
||||
Subject::User(UserKind::Account(UserUid::new(&firebase_uid)))
|
||||
}
|
||||
PersistedSubject::PendingUser { email } => Subject::PendingUser { email },
|
||||
PersistedSubject::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`Subject`] into a guest subject type. This is only supported for subjects that
|
||||
/// may be direct object guests.
|
||||
pub fn try_from_subject(subject: &Subject) -> anyhow::Result<Self> {
|
||||
match subject {
|
||||
Subject::User(user_kind) => match user_kind {
|
||||
UserKind::Account(user_uid) => Ok(PersistedSubject::User {
|
||||
firebase_uid: user_uid.to_string(),
|
||||
}),
|
||||
UserKind::SharedSessionParticipant(_) => {
|
||||
// Shared sessions are transient, so we don't persist their ACLs to SQLite.
|
||||
Err(anyhow!("Session-sharing participants not supported"))
|
||||
}
|
||||
},
|
||||
Subject::PendingUser { email } => Ok(PersistedSubject::PendingUser {
|
||||
email: email.clone(),
|
||||
}),
|
||||
Subject::Team(team_kind) => match team_kind {
|
||||
TeamKind::Team { team_uid } => Ok(PersistedSubject::Team {
|
||||
team_uid: *team_uid,
|
||||
}),
|
||||
TeamKind::SharedSessionTeam { .. } => {
|
||||
// Shared sessions are transient, so we don't persist their ACLs to SQLite.
|
||||
Err(anyhow!("Session-sharing teams not supported"))
|
||||
}
|
||||
},
|
||||
// Link sharing is persisted separately in the schema.
|
||||
Subject::AnyoneWithLink(_) => Err(anyhow!("Anyone with the link not supported")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Persistence utilities for cloud objects.
|
||||
|
||||
mod cloud_objects;
|
||||
|
||||
use diesel::SqliteConnection;
|
||||
use diesel::result::Error;
|
||||
|
||||
pub use cloud_objects::{decode_guests, decode_link_sharing, encode_guests, encode_link_sharing};
|
||||
|
||||
use crate::cloud_object::{
|
||||
CloudObjectMetadata, CloudObjectPermissions, ObjectIdType, ObjectType, Owner,
|
||||
};
|
||||
use crate::ids::SyncId;
|
||||
use persistence::model::{NewObjectMetadata, NewObjectPermissions, ObjectMetadata};
|
||||
use persistence::schema;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
/// The sqlite id of a cloud object.
|
||||
pub type CloudObjectId = i32;
|
||||
|
||||
/// When upserting a cloud object, this callback is used to create the cloud
|
||||
/// object itself. It returns the id of the created cloud object.
|
||||
/// Note: the supplied conn has already started a transaction.
|
||||
pub type CreateCloudObjectFn =
|
||||
Box<dyn FnOnce(&mut SqliteConnection) -> Result<CloudObjectId, Error>>;
|
||||
|
||||
/// When upserting a cloud object, this callback is used to update the cloud
|
||||
/// object. It takes the id of the cloud object to update as a parameter.
|
||||
/// The supplied conn has already started a transaction.
|
||||
pub type UpdateCloudObjectFn =
|
||||
Box<dyn FnOnce(&mut SqliteConnection, CloudObjectId) -> Result<(), Error>>;
|
||||
|
||||
pub fn upsert_cloud_object(
|
||||
conn: &mut SqliteConnection,
|
||||
cloud_object_type: ObjectType,
|
||||
sync_id: SyncId,
|
||||
cloud_object_metadata: CloudObjectMetadata,
|
||||
cloud_object_permissions: CloudObjectPermissions,
|
||||
create_object_fn: CreateCloudObjectFn,
|
||||
update_object_fn: UpdateCloudObjectFn,
|
||||
) -> Result<(), Error> {
|
||||
use schema::object_metadata::dsl::{
|
||||
client_id, current_editor, folder_id, is_pending, last_editor_uid,
|
||||
metadata_last_updated_ts, object_metadata, revision_ts, server_id, trashed_ts,
|
||||
};
|
||||
use schema::object_permissions::dsl::{
|
||||
anyone_with_link_access_level, anyone_with_link_source, object_guests, object_metadata_id,
|
||||
object_permissions, permissions_last_updated_at, subject_id, subject_type, subject_uid,
|
||||
};
|
||||
|
||||
use diesel::prelude::*;
|
||||
|
||||
let (subject_type_value, subject_id_value, subject_uid_value) =
|
||||
match cloud_object_permissions.owner {
|
||||
Owner::User { user_uid } => ("USER", Some(user_uid.to_string()), user_uid.to_string()),
|
||||
Owner::Team { team_uid } => ("TEAM", None, team_uid.to_string()),
|
||||
};
|
||||
let permissions_ts = cloud_object_permissions
|
||||
.permissions_last_updated_ts
|
||||
.map(|ts| ts.timestamp_micros());
|
||||
let guests = if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
match encode_guests(&cloud_object_permissions.guests) {
|
||||
Ok(guests) => Some(guests),
|
||||
Err(err) => {
|
||||
log::warn!("Unable to encode guests: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (anyone_with_link_access_level_value, anyone_with_link_source_value) =
|
||||
if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
match cloud_object_permissions
|
||||
.anyone_with_link
|
||||
.as_ref()
|
||||
.map(encode_link_sharing)
|
||||
{
|
||||
Some(Ok((access_level, source))) => (Some(access_level), source),
|
||||
Some(Err(err)) => {
|
||||
log::warn!("Unable to encode link-sharing setting: {err:#}");
|
||||
(None, None)
|
||||
}
|
||||
None => (None, None),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let revision = cloud_object_metadata
|
||||
.revision
|
||||
.as_ref()
|
||||
.map(|r| r.timestamp_micros());
|
||||
let has_pending_content_changes = cloud_object_metadata.has_pending_content_changes();
|
||||
|
||||
// Filter to find metadata row.
|
||||
// The diesel types for `filter`s are dependent on the columns being filtered
|
||||
// so while the `hashed_sync_id` will only match one of `client_id` and `server_id`,
|
||||
// we filter on both here for ergonomics.
|
||||
let hashed_sync_id = sync_id.sqlite_uid_hash(cloud_object_type.into());
|
||||
let metadata_filter = object_metadata
|
||||
.filter(client_id.eq(Some(hashed_sync_id.as_str())))
|
||||
.or_filter(server_id.eq(Some(hashed_sync_id.as_str())));
|
||||
let metadata: Option<ObjectMetadata> = metadata_filter.first(conn).ok();
|
||||
|
||||
match metadata {
|
||||
Some(metadata) => {
|
||||
// The object already exists in sqlite so update the object.
|
||||
update_object_fn(conn, metadata.shareable_object_id)?;
|
||||
|
||||
let metadata_last_updated_at = cloud_object_metadata
|
||||
.metadata_last_updated_ts
|
||||
.map(|ts| ts.timestamp_micros());
|
||||
|
||||
let trashed_timestamp = cloud_object_metadata
|
||||
.trashed_ts
|
||||
.map(|ts| ts.timestamp_micros());
|
||||
|
||||
let folder_id_str = cloud_object_metadata
|
||||
.folder_id
|
||||
.map(|folder_sync_id| folder_sync_id.sqlite_uid_hash(ObjectIdType::Folder));
|
||||
|
||||
// Update the metadata. Note: this is holistic write of all the metadata based on the current state of the in-memory object.
|
||||
// TODO: we need to update author_id as well.
|
||||
diesel::update(metadata_filter)
|
||||
.set((
|
||||
revision_ts.eq(revision),
|
||||
is_pending.eq(has_pending_content_changes),
|
||||
last_editor_uid.eq(cloud_object_metadata.last_editor_uid),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
if !cloud_object_metadata
|
||||
.pending_changes_statuses
|
||||
.has_pending_metadata_change
|
||||
{
|
||||
diesel::update(metadata_filter)
|
||||
.set((
|
||||
metadata_last_updated_ts.eq(metadata_last_updated_at),
|
||||
trashed_ts.eq(trashed_timestamp),
|
||||
folder_id.eq(folder_id_str),
|
||||
current_editor.eq(cloud_object_metadata.current_editor_uid),
|
||||
))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
// Update the permissions.
|
||||
if !cloud_object_metadata
|
||||
.pending_changes_statuses
|
||||
.has_pending_permissions_change
|
||||
{
|
||||
let permissions_filter =
|
||||
object_permissions.filter(object_metadata_id.eq(metadata.id));
|
||||
diesel::update(permissions_filter)
|
||||
.set((
|
||||
subject_type.eq(subject_type_value),
|
||||
subject_id.eq(subject_id_value),
|
||||
subject_uid.eq(subject_uid_value),
|
||||
permissions_last_updated_at.eq(permissions_ts),
|
||||
object_guests.eq(guests),
|
||||
anyone_with_link_access_level.eq(anyone_with_link_access_level_value),
|
||||
anyone_with_link_source.eq(anyone_with_link_source_value),
|
||||
))
|
||||
.execute(conn)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// The object doesn't exist in sqlite so create the object.
|
||||
let object_id = create_object_fn(conn)?;
|
||||
|
||||
// Create the metadata.
|
||||
let mut new_object_metadata = NewObjectMetadata {
|
||||
object_type: cloud_object_type.sqlite_object_type_as_str().to_string(),
|
||||
revision_ts: revision,
|
||||
shareable_object_id: object_id,
|
||||
is_pending: has_pending_content_changes,
|
||||
retry_count: 0,
|
||||
|
||||
// TODO: we need to deserialize this from graphql.
|
||||
author_id: None,
|
||||
|
||||
// One of these is set below.
|
||||
client_id: None,
|
||||
server_id: None,
|
||||
|
||||
metadata_last_updated_ts: cloud_object_metadata
|
||||
.metadata_last_updated_ts
|
||||
.map(|ts| ts.timestamp_micros()),
|
||||
|
||||
trashed_ts: cloud_object_metadata
|
||||
.trashed_ts
|
||||
.map(|ts| ts.timestamp_micros()),
|
||||
|
||||
folder_id: cloud_object_metadata
|
||||
.folder_id
|
||||
.map(|sync_id| sync_id.sqlite_uid_hash(ObjectIdType::Folder)),
|
||||
|
||||
// When we insert an object, mark whether it's a welcome object. This
|
||||
// field won't ever be updated and this is the only pathway for it to be set.
|
||||
is_welcome_object: cloud_object_metadata.is_welcome_object,
|
||||
|
||||
creator_uid: cloud_object_metadata.creator_uid,
|
||||
last_editor_uid: cloud_object_metadata.last_editor_uid,
|
||||
current_editor: cloud_object_metadata.current_editor_uid,
|
||||
};
|
||||
|
||||
// There are two distinct cases:
|
||||
// - If the client created this object, the clientId will be set. There is another model event to set the server id.
|
||||
// - Otherwise, the server notified the client about this object so only the serverId will be set.
|
||||
match sync_id {
|
||||
SyncId::ClientId(_) => {
|
||||
new_object_metadata.client_id = Some(hashed_sync_id);
|
||||
}
|
||||
SyncId::ServerId(_) => {
|
||||
new_object_metadata.server_id = Some(hashed_sync_id);
|
||||
}
|
||||
}
|
||||
diesel::insert_into(schema::object_metadata::dsl::object_metadata)
|
||||
.values(new_object_metadata)
|
||||
.execute(conn)?;
|
||||
|
||||
// Retrieve the ID of the row that was just inserted. We need to
|
||||
// do it this way because sqlite doesn't support RETURNING.
|
||||
let metadata_id: i32 = schema::object_metadata::dsl::object_metadata
|
||||
.select(schema::object_metadata::dsl::id)
|
||||
.order(schema::object_metadata::dsl::id.desc())
|
||||
.first(conn)?;
|
||||
|
||||
// Create the permissions.
|
||||
let new_object_permissions = NewObjectPermissions {
|
||||
object_metadata_id: metadata_id,
|
||||
subject_type: subject_type_value.to_owned(),
|
||||
subject_id: subject_id_value,
|
||||
subject_uid: subject_uid_value,
|
||||
permissions_last_updated_at: permissions_ts,
|
||||
object_guests: guests,
|
||||
anyone_with_link_access_level: anyone_with_link_access_level_value,
|
||||
anyone_with_link_source: anyone_with_link_source_value,
|
||||
};
|
||||
diesel::insert_into(schema::object_permissions::dsl::object_permissions)
|
||||
.values(new_object_permissions)
|
||||
.execute(conn)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user