Hosted Locations - Part 1 (#1861)

* Hosted locations CRD

* Authorise + file upload demo

* Configurable path for testing perms
This commit is contained in:
Oscar Beaumont
2024-01-08 14:10:26 +08:00
committed by GitHub
parent d5c0cd7e37
commit c61efdcb67
6 changed files with 312 additions and 4 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -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]

View File

@@ -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(())
})
})
}
}

View File

@@ -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>
</>
);
}

View File

@@ -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 }

View File

@@ -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.