diff --git a/Cargo.lock b/Cargo.lock index 0bffd6d8b..da214333e 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/core/Cargo.toml b/core/Cargo.toml index cbd21d2bb..79f3bb795 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -107,6 +107,7 @@ bytes = "1.5.0" reqwest = { version = "0.11.22", features = ["json", "native-tls-vendored"] } directories = "5.0.1" async-recursion = "1.0.5" +base64 = "0.21.5" # Override features of transitive dependencies [dependencies.openssl] diff --git a/core/src/api/cloud.rs b/core/src/api/cloud.rs new file mode 100644 index 000000000..71744182f --- /dev/null +++ b/core/src/api/cloud.rs @@ -0,0 +1,205 @@ +use base64::prelude::*; +use reqwest::Response; +use rspc::alpha::AlphaRouter; +use sd_prisma::prisma::instance; +use sd_utils::uuid_to_bytes; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; +use specta::Type; +use uuid::Uuid; + +use crate::{invalidate_query, library::LibraryName}; + +use crate::util::http::ensure_response; + +use super::{utils::library, Ctx, R}; + +async fn parse_json_body(response: Response) -> Result { + response.json().await.map_err(|_| { + rspc::Error::new( + rspc::ErrorCode::InternalServerError, + "JSON conversion failed".to_string(), + ) + }) +} + +pub(crate) fn mount() -> AlphaRouter { + R.router().merge("library.", library::mount()) +} + +mod library { + use chrono::{DateTime, Utc}; + + use crate::api::libraries::LibraryConfigWrapped; + + use super::*; + + #[derive(Serialize, Deserialize, Type)] + #[specta(inline)] + #[serde(rename_all = "camelCase")] + struct Response { + // id: String, + uuid: Uuid, + name: String, + owner_id: String, + instances: Vec, + } + + #[derive(Serialize, Deserialize, Type)] + #[specta(inline)] + #[serde(rename_all = "camelCase")] + struct Instance { + id: String, + uuid: Uuid, + identity: String, + } + + pub fn mount() -> AlphaRouter { + R.router() + .procedure("get", { + R.with2(library()) + .query(|(node, library), _: ()| async move { + let library_id = library.id; + let api_url = &node.env.api_url; + + node.authed_api_request( + node.http + .get(&format!("{api_url}/api/v1/libraries/{library_id}")), + ) + .await + .and_then(ensure_response) + .map(parse_json_body::>)? + .await + }) + }) + .procedure("list", { + #[derive(Serialize, Deserialize, Type)] + #[specta(inline)] + #[serde(rename_all = "camelCase")] + struct Response { + // id: String, + uuid: Uuid, + name: String, + owner_id: String, + instances: Vec, + } + + #[derive(Serialize, Deserialize, Type)] + #[specta(inline)] + #[serde(rename_all = "camelCase")] + struct Instance { + id: String, + uuid: Uuid, + } + + R.query(|node, _: ()| async move { + let api_url = &node.env.api_url; + + node.authed_api_request(node.http.get(&format!("{api_url}/api/v1/libraries"))) + .await + .and_then(ensure_response) + .map(parse_json_body::>)? + .await + }) + }) + .procedure("create", { + R.with2(library()) + .mutation(|(node, library), _: ()| async move { + let api_url = &node.env.api_url; + let library_id = library.id; + let instance_uuid = library.instance_uuid; + + node.authed_api_request( + node.http + .post(&format!("{api_url}/api/v1/libraries/{library_id}")) + .json(&json!({ + "name": library.config().await.name, + "instanceUuid": library.instance_uuid, + "instanceIdentity": library.identity.to_remote_identity() + })), + ) + .await + .and_then(ensure_response)?; + + invalidate_query!(library, "cloud.library.get"); + + Ok(()) + }) + }) + .procedure("join", { + R.mutation(|node, library_id: Uuid| async move { + let api_url = &node.env.api_url; + + let Some(cloud_library) = node + .authed_api_request( + node.http + .get(&format!("{api_url}/api/v1/libraries/{library_id}")), + ) + .await + .and_then(ensure_response) + .map(parse_json_body::>)? + .await? + else { + return Err(rspc::Error::new( + rspc::ErrorCode::NotFound, + "Library not found".to_string(), + )); + }; + + let library = node + .libraries + .create_with_uuid( + library_id, + LibraryName::new(cloud_library.name).unwrap(), + None, + false, + None, + &node, + ) + .await?; + + let instance_uuid = library.instance_uuid; + + node.authed_api_request( + node.http + .post(&format!( + "{api_url}/api/v1/libraries/{library_id}/instances/{instance_uuid}" + )) + .json(&json!({ + "instanceIdentity": library.identity.to_remote_identity() + })), + ) + .await + .and_then(ensure_response)?; + + library + .db + .instance() + .create_many( + cloud_library + .instances + .into_iter() + .map(|instance| { + instance::create_unchecked( + uuid_to_bytes(instance.uuid), + BASE64_STANDARD.decode(instance.identity).unwrap(), + vec![], + "".to_string(), + 0, + Utc::now().into(), + Utc::now().into(), + vec![], + ) + }) + .collect(), + ) + .exec() + .await?; + + invalidate_query!(library, "cloud.library.get"); + + Ok(LibraryConfigWrapped::from_library(&library).await) + }) + }) + } +} diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index 7d1eb8ac4..c774dbe95 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -35,6 +35,17 @@ pub struct LibraryConfigWrapped { pub config: LibraryConfig, } +impl LibraryConfigWrapped { + pub async fn from_library(library: &Library) -> Self { + Self { + uuid: library.id, + instance_id: library.instance_uuid, + instance_public_key: library.identity.to_remote_identity(), + config: library.config().await, + } + } +} + pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("list", { @@ -268,12 +279,7 @@ pub(crate) fn mount() -> AlphaRouter { .await?; } - Ok(LibraryConfigWrapped { - uuid: library.id, - instance_id: library.instance_uuid, - instance_public_key: library.identity.to_remote_identity(), - config: library.config().await, - }) + Ok(LibraryConfigWrapped::from_library(&library).await) }, ) }) diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 889b19463..922200b61 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -17,6 +17,7 @@ use uuid::Uuid; mod auth; mod backups; +mod cloud; // mod categories; mod ephemeral_files; mod files; @@ -179,6 +180,7 @@ pub(crate) fn mount() -> Arc { }) .merge("api.", web_api::mount()) .merge("auth.", auth::mount()) + .merge("cloud.", cloud::mount()) .merge("search.", search::mount()) .merge("library.", libraries::mount()) .merge("volumes.", volumes::mount()) diff --git a/core/src/lib.rs b/core/src/lib.rs index 9ce83f66b..a7252613d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -275,6 +275,15 @@ impl Node { ) }) } + + pub async fn api_request(&self, req: RequestBuilder) -> Result { + req.send().await.map_err(|_| { + rspc::Error::new( + rspc::ErrorCode::InternalServerError, + "Request failed".to_string(), + ) + }) + } } /// Error type for Node related errors. diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx index cd47dd3e7..344d6e115 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx @@ -1,4 +1,4 @@ -import { ArrowsClockwise, Planet } from '@phosphor-icons/react'; +import { ArrowsClockwise, Cloud, Planet } from '@phosphor-icons/react'; import { useNavigate } from 'react-router'; import { LibraryContextProvider, useClientContext, useFeatureFlag } from '@sd/client'; import { Tooltip } from '@sd/ui'; @@ -21,7 +21,6 @@ export default () => { return (
- {/*
*/} {/* Spacedrop @@ -31,13 +30,20 @@ export default () => { Imports */} - {useFeatureFlag('syncRoute') && ( - - - Sync - - )} - {/*
*/} +
+ {useFeatureFlag('syncRoute') && ( + + + Sync + + )} + {useFeatureFlag('cloud') && ( + + + Cloud + + )} +
{library && ( diff --git a/interface/app/$libraryId/cloud.tsx b/interface/app/$libraryId/cloud.tsx new file mode 100644 index 000000000..9bd719925 --- /dev/null +++ b/interface/app/$libraryId/cloud.tsx @@ -0,0 +1,61 @@ +import { auth, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { Button } from '@sd/ui'; +import { AuthRequiredOverlay } from '~/components/AuthRequiredOverlay'; +import { LoginButton } from '~/components/LoginButton'; +import { useRouteTitle } from '~/hooks'; + +export const Component = () => { + useRouteTitle('Cloud'); + + const authState = auth.useStateSnapshot(); + + if (authState.status === 'loggedIn') return ; + if (authState.status === 'notLoggedIn') + return ( +
+ +
+ ); + + return null; +}; + +function Authenticated() { + const cloudLibrary = useLibraryQuery(['cloud.library.get'], { suspense: true, retry: false }); + + const createLibrary = useLibraryMutation(['cloud.library.create']); + + return ( +
+ {cloudLibrary.data ? ( +
+

Library: {cloudLibrary.data.name}

+

Instances

+
    + {cloudLibrary.data.instances.map((instance) => ( +
  • +

    Id: {instance.id}

    +

    UUID: {instance.uuid}

    +

    Public Key: {instance.identity}

    +
  • + ))} +
+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index 989607927..9c22e5ebd 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -13,7 +13,8 @@ const pageRoutes: RouteObject = { { path: 'media', lazy: () => import('./media') }, { path: 'spaces', lazy: () => import('./spaces') }, { path: 'debug', lazy: () => import('./debug') }, - { path: 'sync', lazy: () => import('./sync') } + { path: 'sync', lazy: () => import('./sync') }, + { path: 'cloud', lazy: () => import('./cloud') } ] }; diff --git a/interface/app/onboarding/Component.tsx b/interface/app/onboarding/Component.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/interface/app/onboarding/index.tsx b/interface/app/onboarding/index.tsx index 407560c0b..214da3694 100644 --- a/interface/app/onboarding/index.tsx +++ b/interface/app/onboarding/index.tsx @@ -5,6 +5,7 @@ import Alpha from './alpha'; import { useOnboardingContext } from './context'; import CreatingLibrary from './creating-library'; import { FullDisk } from './full-disk'; +import { JoinLibrary } from './join-library'; import Locations from './locations'; import NewLibrary from './new-library'; import Privacy from './privacy'; @@ -36,6 +37,7 @@ export default [ // path: 'login' // }, { Component: NewLibrary, path: 'new-library' }, + { Component: JoinLibrary, path: 'join-library' }, { Component: FullDisk, path: 'full-disk' }, { Component: Locations, path: 'locations' }, { Component: Privacy, path: 'privacy' }, diff --git a/interface/app/onboarding/join-library.tsx b/interface/app/onboarding/join-library.tsx new file mode 100644 index 000000000..f980cb568 --- /dev/null +++ b/interface/app/onboarding/join-library.tsx @@ -0,0 +1,76 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; +import { resetOnboardingStore, useBridgeMutation, useBridgeQuery } from '@sd/client'; +import { Button } from '@sd/ui'; +import { Icon } from '~/components'; +import { AuthRequiredOverlay } from '~/components/AuthRequiredOverlay'; +import { useRouteTitle } from '~/hooks'; +import { usePlatform } from '~/util/Platform'; + +import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './components'; + +export function JoinLibrary() { + useRouteTitle('Join Library'); + + return ( + + + Join a Library + + Libraries are a secure, on-device database. Your files remain where they are, the + Library catalogs them and stores all Spacedrive related data. + + +
+ Cloud Libraries +
    + + +
+
+
+ ); +} + +function CloudLibraries() { + const cloudLibraries = useBridgeQuery(['cloud.library.list']); + const joinLibrary = useBridgeMutation(['cloud.library.join']); + + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const platform = usePlatform(); + + if (cloudLibraries.isLoading) return Loading...; + + return ( + <> + {cloudLibraries.data?.map((cloudLibrary) => ( +
  • + {cloudLibrary.name} + +
  • + ))} + + ); +} diff --git a/interface/app/onboarding/joining-library.tsx b/interface/app/onboarding/joining-library.tsx new file mode 100644 index 000000000..63df399df --- /dev/null +++ b/interface/app/onboarding/joining-library.tsx @@ -0,0 +1,14 @@ +import { Loader } from '@sd/ui'; + +import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './components'; + +export default function OnboardingCreatingLibrary() { + return ( + + 🛠 + Joining library + Joing library... + + + ); +} diff --git a/interface/app/onboarding/new-library.tsx b/interface/app/onboarding/new-library.tsx index a612b16bf..0df00c975 100644 --- a/interface/app/onboarding/new-library.tsx +++ b/interface/app/onboarding/new-library.tsx @@ -67,6 +67,14 @@ export default function OnboardingNewLibrary() { Import library */}
    + OR + )} diff --git a/package.json b/package.json index 76053f236..dea29efc6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "client": "pnpm --filter @sd/client -- ", "storybook": "pnpm --filter @sd/storybook -- ", "prisma": "cd core && cargo prisma", + "dev:desktop": "pnpm run --filter @sd/desktop tauri dev", "dev:web": "turbo run dev --filter @sd/web --filter @sd/server", "bootstrap:desktop": "cargo clean && ./scripts/setup.sh && pnpm i && pnpm prep && pnpm tauri dev", "codegen": "cargo test -p sd-core api::tests::test_and_export_rspc_bindings -- --exact", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 4c06ee6d9..75b7280e7 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -6,6 +6,8 @@ export type Procedures = { { key: "auth.me", input: never, result: { id: string; email: string } } | { key: "backups.getAll", input: never, result: GetAll } | { key: "buildInfo", input: never, result: BuildInfo } | + { key: "cloud.library.get", input: LibraryArgs, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string; identity: string }[] } | null } | + { key: "cloud.library.list", input: never, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string }[] }[] } | { key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } | { key: "files.get", input: LibraryArgs, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } | { key: "files.getConvertableImageExtensions", input: never, result: string[] } | @@ -49,6 +51,8 @@ export type Procedures = { { key: "backups.backup", input: LibraryArgs, result: string } | { key: "backups.delete", input: string, result: null } | { key: "backups.restore", input: string, result: null } | + { key: "cloud.library.create", input: LibraryArgs, result: null } | + { key: "cloud.library.join", input: string, result: LibraryConfigWrapped } | { key: "ephemeralFiles.copyFiles", input: LibraryArgs, result: null } | { key: "ephemeralFiles.createFolder", input: LibraryArgs, result: string } | { key: "ephemeralFiles.cutFiles", input: LibraryArgs, result: null } | diff --git a/packages/client/src/hooks/useFeatureFlag.tsx b/packages/client/src/hooks/useFeatureFlag.tsx index 9b3d2aef3..b0e321cec 100644 --- a/packages/client/src/hooks/useFeatureFlag.tsx +++ b/packages/client/src/hooks/useFeatureFlag.tsx @@ -5,7 +5,7 @@ import type { BackendFeature } from '../core'; import { valtioPersist } from '../lib/valito'; import { nonLibraryClient, useBridgeQuery } from '../rspc'; -export const features = ['spacedrop', 'p2pPairing', 'syncRoute', 'backups'] as const; +export const features = ['spacedrop', 'p2pPairing', 'syncRoute', 'backups', 'cloud'] as const; // This defines which backend feature flags show up in the UI. // This is kinda a hack to not having the runtime array of possible features as Specta only exports the types.