mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
Hosted Locations - Part 1 (#1861)
* Hosted locations CRD * Authorise + file upload demo * Configurable path for testing perms
This commit is contained in:
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -119,6 +119,9 @@ slotmap = "1.0.6"
|
||||
static_assertions = "1.1.0"
|
||||
sysinfo = "0.29.10"
|
||||
tar = "0.4.40"
|
||||
aws-sdk-s3 = { version = "1.5.0", features = ["behavior-version-latest"] }
|
||||
aws-config = "1.0.3"
|
||||
aws-credential-types = "1.0.3"
|
||||
|
||||
# Override features of transitive dependencies
|
||||
[dependencies.openssl]
|
||||
|
||||
@@ -19,7 +19,9 @@ async fn parse_json_body<T: DeserializeOwned>(response: Response) -> Result<T, r
|
||||
}
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router().merge("library.", library::mount())
|
||||
R.router()
|
||||
.merge("library.", library::mount())
|
||||
.merge("locations.", locations::mount())
|
||||
}
|
||||
|
||||
mod library {
|
||||
@@ -104,3 +106,172 @@ mod library {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod locations {
|
||||
use aws_config::{Region, SdkConfig};
|
||||
use aws_credential_types::provider::future;
|
||||
use aws_sdk_s3::{
|
||||
config::{Credentials, ProvideCredentials, SharedCredentialsProvider},
|
||||
primitives::ByteStream,
|
||||
};
|
||||
use http_body::Full;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use specta::Type;
|
||||
|
||||
use crate::util::http::ensure_response;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Type, Serialize, Deserialize)]
|
||||
pub struct CloudLocation {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Type, Deserialize)]
|
||||
pub struct AuthoriseResponse {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
pub fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("list", {
|
||||
R.query(|node, _: ()| async move {
|
||||
let api_url = &node.env.api_url;
|
||||
|
||||
node.authed_api_request(node.http.get(&format!("{api_url}/api/v1/locations")))
|
||||
.await
|
||||
.and_then(ensure_response)
|
||||
.map(parse_json_body::<Vec<CloudLocation>>)?
|
||||
.await
|
||||
})
|
||||
})
|
||||
.procedure("create", {
|
||||
R.mutation(|node, name: String| async move {
|
||||
let api_url = &node.env.api_url;
|
||||
|
||||
node.authed_api_request(
|
||||
node.http
|
||||
.post(&format!("{api_url}/api/v1/locations"))
|
||||
.json(&json!({
|
||||
"name": name
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.and_then(ensure_response)
|
||||
.map(parse_json_body::<CloudLocation>)?
|
||||
.await
|
||||
})
|
||||
})
|
||||
.procedure("remove", {
|
||||
R.mutation(|node, id: String| async move {
|
||||
let api_url = &node.env.api_url;
|
||||
|
||||
node.authed_api_request(
|
||||
node.http
|
||||
.post(&format!("{api_url}/api/v1/locations/delete"))
|
||||
.json(&json!({
|
||||
"id": id
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.and_then(ensure_response)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
// TODO: Remove this
|
||||
.procedure("testing", {
|
||||
// // TODO: Move this off a static. This is just for debugging.
|
||||
// static AUTH_TOKEN: Lazy<Mutex<Option<AuthoriseResponse>>> =
|
||||
// Lazy::new(|| Mutex::new(None));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CredentialsProvider(AuthoriseResponse);
|
||||
|
||||
impl ProvideCredentials for CredentialsProvider {
|
||||
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
{
|
||||
future::ProvideCredentials::ready(Ok(Credentials::new(
|
||||
self.0.access_key_id.clone(),
|
||||
self.0.secret_access_key.clone(),
|
||||
Some(self.0.session_token.clone()),
|
||||
None, // TODO: Get this from the SD Cloud backend
|
||||
"sd-cloud",
|
||||
)))
|
||||
}
|
||||
|
||||
fn fallback_on_interrupt(&self) -> Option<Credentials> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct TestingParams {
|
||||
id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
R.mutation(|node, params: TestingParams| async move {
|
||||
let token = {
|
||||
let token = &mut None; // AUTH_TOKEN.lock().await; // TODO: Caching of the token. For now it's annoying when debugging.
|
||||
if token.is_none() {
|
||||
let api_url = &node.env.api_url;
|
||||
|
||||
*token = Some(
|
||||
node.authed_api_request(
|
||||
node.http
|
||||
.post(&format!("{api_url}/api/v1/locations/authorise"))
|
||||
.json(&json!({
|
||||
"id": params.id
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.and_then(ensure_response)
|
||||
.map(parse_json_body::<AuthoriseResponse>)?
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
token.clone().expect("Checked above")
|
||||
};
|
||||
|
||||
println!("{token:?}"); // TODO
|
||||
|
||||
// TODO: Reuse the client between procedure calls
|
||||
let client = aws_sdk_s3::Client::new(
|
||||
&SdkConfig::builder()
|
||||
.region(Region::new("us-west-1")) // TODO: From cloud config
|
||||
.credentials_provider(SharedCredentialsProvider::new(
|
||||
CredentialsProvider(token),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket("spacedrive-cloud") // TODO: From cloud config
|
||||
.key(params.path) // TODO: Proper access control to only the current locations files
|
||||
.body(ByteStream::from_body_0_4(Full::from("Hello, world!")))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!("S3 error: {err:?}");
|
||||
rspc::Error::new(
|
||||
rspc::ErrorCode::InternalServerError,
|
||||
"Failed to upload to S3".to_string(),
|
||||
)
|
||||
})?; // TODO: Error handling
|
||||
|
||||
println!("Uploaded file!");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Envelope, User } from '@phosphor-icons/react';
|
||||
import { iconNames } from '@sd/assets/util';
|
||||
import { auth, useBridgeQuery } from '@sd/client';
|
||||
import { Button, Card } from '@sd/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { auth, useBridgeMutation, useBridgeQuery, useFeatureFlag } from '@sd/client';
|
||||
import { Button, Card, Input, toast } from '@sd/ui';
|
||||
import { Icon, TruncatedText } from '~/components';
|
||||
import { AuthRequiredOverlay } from '~/components/AuthRequiredOverlay';
|
||||
|
||||
@@ -31,6 +32,7 @@ export const Component = () => {
|
||||
<Profile authStore={authStore} email={me.data?.email} />
|
||||
<Cloud />
|
||||
</div>
|
||||
{useFeatureFlag('hostedLocations') && <HostedLocationsPlayground />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -97,3 +99,121 @@ const Cloud = () => {
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function HostedLocationsPlayground() {
|
||||
const locations = useBridgeQuery(['cloud.locations.list'], { retry: false });
|
||||
|
||||
const [locationName, setLocationName] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const createLocation = useBridgeMutation('cloud.locations.create', {
|
||||
onSuccess(data) {
|
||||
// console.log('DATA', data); // TODO: Optimistic UI
|
||||
|
||||
locations.refetch();
|
||||
setLocationName('');
|
||||
}
|
||||
});
|
||||
const removeLocation = useBridgeMutation('cloud.locations.remove', {
|
||||
onSuccess() {
|
||||
// TODO: Optimistic UI
|
||||
|
||||
locations.refetch();
|
||||
}
|
||||
});
|
||||
const doTheThing = useBridgeMutation('cloud.locations.testing', {
|
||||
onSuccess() {
|
||||
toast.success('Uploaded file!');
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (path === '' && locations.data?.[0]) {
|
||||
setPath(`location/${locations.data[0].id}/hello.txt`);
|
||||
}
|
||||
}, [path, locations.data]);
|
||||
|
||||
const isLoading = createLocation.isLoading || removeLocation.isLoading || doTheThing.isLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading
|
||||
rightArea={
|
||||
<div className="flex-row space-x-2">
|
||||
{/* TODO: We need UI for this. I wish I could use `prompt` for now but Tauri doesn't have it :( */}
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Input
|
||||
className="grow"
|
||||
value={locationName}
|
||||
onInput={(e) => setLocationName(e.currentTarget.value)}
|
||||
placeholder="My sick location"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (locationName === '') return;
|
||||
createLocation.mutate(locationName);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Create Location
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title="Hosted Locations"
|
||||
description="Augment your local storage with our cloud!"
|
||||
/>
|
||||
|
||||
{/* TODO: Cleanup this mess + styles */}
|
||||
{locations.status === 'loading' ? <div>Loading!</div> : null}
|
||||
{locations.status !== 'loading' && locations.data?.length === 0 ? (
|
||||
<div>Looks like you don't have any!</div>
|
||||
) : (
|
||||
<div>
|
||||
{locations.data?.map((location) => (
|
||||
<div key={location.id} className="flex flex-row space-x-5">
|
||||
<h1>{location.name}</h1>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => removeLocation.mutate(location.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
doTheThing.mutate({
|
||||
id: location.id,
|
||||
path
|
||||
})
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Do the thing
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p>Path to save when clicking 'Do the thing':</p>
|
||||
<Input
|
||||
className="grow"
|
||||
value={path}
|
||||
onInput={(e) => setPath(e.currentTarget.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Procedures = {
|
||||
{ key: "buildInfo", input: never, result: BuildInfo } |
|
||||
{ key: "cloud.library.get", input: LibraryArgs<null>, result: { uuid: string; name: string; instances: CloudInstance[]; ownerId: string } | null } |
|
||||
{ key: "cloud.library.list", input: never, result: CloudLibrary[] } |
|
||||
{ key: "cloud.locations.list", input: never, result: CloudLocation[] } |
|
||||
{ key: "ephemeralFiles.getMediaData", input: string, result: ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) | null } |
|
||||
{ key: "files.get", input: LibraryArgs<number>, result: { item: Reference<ObjectWithFilePaths2>; nodes: CacheNode[] } | null } |
|
||||
{ key: "files.getConvertableImageExtensions", input: never, result: string[] } |
|
||||
@@ -56,6 +57,9 @@ export type Procedures = {
|
||||
{ key: "backups.restore", input: string, result: null } |
|
||||
{ key: "cloud.library.create", input: LibraryArgs<null>, result: null } |
|
||||
{ key: "cloud.library.join", input: string, result: LibraryConfigWrapped } |
|
||||
{ key: "cloud.locations.create", input: string, result: CloudLocation } |
|
||||
{ key: "cloud.locations.remove", input: string, result: null } |
|
||||
{ key: "cloud.locations.testing", input: TestingParams, result: null } |
|
||||
{ key: "ephemeralFiles.copyFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
|
||||
{ key: "ephemeralFiles.createFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
|
||||
{ key: "ephemeralFiles.cutFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
|
||||
@@ -156,6 +160,8 @@ export type CloudInstance = { id: string; uuid: string; identity: string }
|
||||
|
||||
export type CloudLibrary = { uuid: string; name: string; instances: CloudInstance[]; ownerId: string }
|
||||
|
||||
export type CloudLocation = { id: string; name: string }
|
||||
|
||||
export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait"
|
||||
|
||||
export type Composite =
|
||||
@@ -559,6 +565,8 @@ export type TagUpdateArgs = { id: number; name: string | null; color: string | n
|
||||
|
||||
export type Target = { Object: number } | { FilePath: number }
|
||||
|
||||
export type TestingParams = { id: string; path: string }
|
||||
|
||||
export type TextMatch = { contains: string } | { startsWith: string } | { endsWith: string } | { equals: string }
|
||||
|
||||
export type ThumbnailerPreferences = { background_processing_percentage: number }
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { BackendFeature } from '../core';
|
||||
import { valtioPersist } from '../lib/valito';
|
||||
import { nonLibraryClient, useBridgeQuery } from '../rspc';
|
||||
|
||||
export const features = ['spacedrop', 'p2pPairing', 'backups', 'debugRoutes'] as const;
|
||||
export const features = [
|
||||
'spacedrop',
|
||||
'p2pPairing',
|
||||
'backups',
|
||||
'debugRoutes',
|
||||
'hostedLocations'
|
||||
] 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.
|
||||
|
||||
Reference in New Issue
Block a user