mirror of
https://github.com/urbit/shrub.git
synced 2024-11-28 13:54:20 +03:00
Merge pull request #4484 from urbit/jo/gcp-settings-store
GCP storage support
This commit is contained in:
commit
b7583bf3e2
15
pkg/arvo/lib/gcp.hoon
Normal file
15
pkg/arvo/lib/gcp.hoon
Normal file
@ -0,0 +1,15 @@
|
||||
/- *gcp
|
||||
|%
|
||||
++ token-to-json
|
||||
|= =token
|
||||
^- json
|
||||
=, enjs:format
|
||||
%+ frond %gcp-token
|
||||
%: pairs
|
||||
[%'accessKey' s+access-key.token]
|
||||
:- %'expiresIn'
|
||||
%- numb
|
||||
(div (mul 1.000 expires-in.token) ~s1)
|
||||
~
|
||||
==
|
||||
--
|
13
pkg/arvo/mar/gcp-token.hoon
Normal file
13
pkg/arvo/mar/gcp-token.hoon
Normal file
@ -0,0 +1,13 @@
|
||||
/+ *gcp
|
||||
|_ tok=token
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun tok
|
||||
++ json (token-to-json tok)
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun token
|
||||
--
|
||||
--
|
6
pkg/arvo/sur/gcp.hoon
Normal file
6
pkg/arvo/sur/gcp.hoon
Normal file
@ -0,0 +1,6 @@
|
||||
|%
|
||||
+$ token
|
||||
$: access-key=@t
|
||||
expires-in=@dr
|
||||
==
|
||||
--
|
144
pkg/arvo/ted/gcp/get-token.hoon
Normal file
144
pkg/arvo/ted/gcp/get-token.hoon
Normal file
@ -0,0 +1,144 @@
|
||||
:: Gets a Google Storage access token.
|
||||
::
|
||||
:: This thread produces a pair of [access-key expires-in], where
|
||||
:: access-key is a @t that can be used as a bearer token to talk
|
||||
:: to the GCP Storage API on behalf of some service account, and
|
||||
:: expires-in is a @dr after which the token will stop working and
|
||||
:: need to be refreshed.
|
||||
::
|
||||
:: It expects settings-store to contain relevant fields from
|
||||
:: a GCP service account JSON file, generally as poked by
|
||||
:: sh/poke-gcp-account-json. Specifically, it depends on the
|
||||
:: `token_uri`, `client_email`, `private_key_id`, and `private_key`
|
||||
:: fields. If these fields are not in settings-store at the time
|
||||
:: the thread is run, it will fail.
|
||||
::
|
||||
:: The thread works by first constructing a self-signed JWT using
|
||||
:: the fields in settings-store. Then, it sends this JWT to the
|
||||
:: specified token URI (usually https://oauth2.googleapis.com/token),
|
||||
:: which responds with a bearer token and expiry.
|
||||
::
|
||||
::
|
||||
/- gcp, spider, settings
|
||||
/+ jose, pkcs, primitive-rsa, strandio
|
||||
=, strand=strand:spider
|
||||
=, rsa=primitive-rsa
|
||||
^- thread:spider
|
||||
|^
|
||||
|= *
|
||||
=/ m (strand ,vase)
|
||||
^- form:m
|
||||
;< =bowl:spider bind:m get-bowl:strandio
|
||||
;< iss=@t bind:m (read-setting %client-email)
|
||||
;< =key:rsa bind:m read-private-key
|
||||
;< kid=@t bind:m (read-setting %private-key-id)
|
||||
;< aud=@t bind:m (read-setting %token-uri)
|
||||
=* scope
|
||||
'https://www.googleapis.com/auth/devstorage.read_write'
|
||||
=/ jot=@t
|
||||
(make-jwt key kid iss scope aud now.bowl)
|
||||
;< =token:gcp bind:m
|
||||
(get-access-token jot aud)
|
||||
(pure:m !>(token))
|
||||
::
|
||||
++ read-setting
|
||||
|= key=term
|
||||
=/ m (strand @t) ^- form:m
|
||||
;< has=? bind:m
|
||||
%+ scry:strandio ?
|
||||
/gx/settings-store/has-entry/gcp-store/[key]/noun
|
||||
?. has
|
||||
(strand-fail:strandio (rap 3 %gcp-missing- key ~) ~)
|
||||
;< =data:settings bind:m
|
||||
%+ scry:strandio
|
||||
data:settings
|
||||
/gx/settings-store/entry/gcp-store/[key]/settings-data
|
||||
?> ?=([%entry %s @] data)
|
||||
(pure:m p.val.data)
|
||||
::
|
||||
++ read-private-key
|
||||
=/ m (strand ,key:rsa) ^- form:m
|
||||
;< dat=@t bind:m (read-setting %private-key)
|
||||
%- pure:m
|
||||
%. dat
|
||||
;: cork
|
||||
to-wain:format
|
||||
ring:de:pem:pkcs8:pkcs
|
||||
need
|
||||
==
|
||||
:: construct and return a self-signed JWT issued now, expiring in ~h1.
|
||||
:: TODO: maybe move this into lib/jose/hoon
|
||||
::
|
||||
++ make-jwt
|
||||
|= [=key:rsa kid=@t iss=@t scope=@t aud=@t iat=@da]
|
||||
^- @t
|
||||
=/ job=json
|
||||
=, enjs:format
|
||||
%^ sign:jws:jose key
|
||||
:: the JWT's "header"
|
||||
%: pairs
|
||||
alg+s+'RS256'
|
||||
typ+s+'JWT'
|
||||
kid+s+kid
|
||||
~
|
||||
==
|
||||
:: the JWT's "payload"
|
||||
%: pairs
|
||||
iss+s+iss
|
||||
sub+s+iss :: per g.co, use iss for sub
|
||||
scope+s+scope
|
||||
aud+s+aud
|
||||
iat+(sect iat)
|
||||
exp+(sect (add iat ~h1))
|
||||
~
|
||||
==
|
||||
=/ [pod=@t pad=@t sig=@t]
|
||||
=, dejs:format
|
||||
((ot 'protected'^so 'payload'^so 'signature'^so ~) job)
|
||||
(rap 3 (join '.' `(list @t)`~[pod pad sig]))
|
||||
:: RPC to get an access token. Probably only works with Google.
|
||||
:: Described at:
|
||||
:: https://developers.google.com/identity/protocols/oauth2/service-account
|
||||
::
|
||||
++ get-access-token
|
||||
|= [jot=@t url=@t]
|
||||
=/ m (strand ,token:gcp) ^- form:m
|
||||
;< ~ bind:m
|
||||
%: send-request:strandio
|
||||
method=%'POST'
|
||||
url=url
|
||||
header-list=['Content-Type'^'application/json' ~]
|
||||
^= body
|
||||
%- some %- as-octt:mimes:html
|
||||
%- en-json:html
|
||||
%: pairs:enjs:format
|
||||
:- 'grant_type'
|
||||
s+'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
||||
assertion+s+jot
|
||||
~
|
||||
==
|
||||
==
|
||||
;< rep=client-response:iris bind:m
|
||||
take-client-response:strandio
|
||||
?> ?=(%finished -.rep)
|
||||
?~ full-file.rep
|
||||
(strand-fail:strandio %gcp-no-response ~)
|
||||
=/ body=@t q.data.u.full-file.rep
|
||||
=/ jon=(unit json) (de-json:html body)
|
||||
?~ jon
|
||||
~| body
|
||||
(strand-fail:strandio %gcp-bad-body ~)
|
||||
=* job u.jon
|
||||
~| job
|
||||
=, dejs:format
|
||||
=/ [typ=@t =token:gcp]
|
||||
%. job
|
||||
%: ot
|
||||
'token_type'^so
|
||||
'access_token'^so
|
||||
'expires_in'^(cu |=(a=@ (mul a ~s1)) ni)
|
||||
~
|
||||
==
|
||||
?> =('Bearer' typ)
|
||||
(pure:m token)
|
||||
--
|
49
pkg/arvo/ted/gcp/is-configured.hoon
Normal file
49
pkg/arvo/ted/gcp/is-configured.hoon
Normal file
@ -0,0 +1,49 @@
|
||||
:: Tells whether GCP Storage appears to be configured.
|
||||
::
|
||||
:: Thread since it needs to be called from Landscape.
|
||||
::
|
||||
::
|
||||
/- gcp, spider, settings
|
||||
/+ strandio
|
||||
=, strand=strand:spider
|
||||
=, enjs:format
|
||||
^- thread:spider
|
||||
|^
|
||||
|= *
|
||||
=/ m (strand ,vase)
|
||||
^- form:m
|
||||
;< has=? bind:m
|
||||
%: has-settings
|
||||
%client-email
|
||||
%private-key
|
||||
%private-key-id
|
||||
%token-uri
|
||||
~
|
||||
==
|
||||
%- pure:m
|
||||
!>
|
||||
%+ frond %gcp-configured
|
||||
b+has
|
||||
::
|
||||
++ has-settings
|
||||
|= set=(list @tas)
|
||||
=/ m (strand ?)
|
||||
^- form:m
|
||||
?~ set
|
||||
(pure:m %.y)
|
||||
;< has=? bind:m (has-setting i.set)
|
||||
?. has
|
||||
(pure:m %.n)
|
||||
;< has=? bind:m (has-settings t.set)
|
||||
(pure:m has)
|
||||
::
|
||||
++ has-setting
|
||||
|= key=@tas
|
||||
=/ m (strand ?)
|
||||
^- form:m
|
||||
;< has=? bind:m
|
||||
%+ scry:strandio ?
|
||||
/gx/settings-store/has-entry/gcp-store/[key]/noun
|
||||
(pure:m has)
|
||||
::
|
||||
--
|
36
pkg/interface/package-lock.json
generated
36
pkg/interface/package-lock.json
generated
@ -2149,6 +2149,13 @@
|
||||
"url": "0.10.3",
|
||||
"uuid": "3.3.2",
|
||||
"xml2js": "0.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
|
||||
}
|
||||
}
|
||||
},
|
||||
"babel-eslint": {
|
||||
@ -6642,6 +6649,14 @@
|
||||
"requires": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7402,9 +7417,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
|
||||
"integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg=="
|
||||
},
|
||||
"querystring-es3": {
|
||||
"version": "0.2.1",
|
||||
@ -9587,6 +9602,13 @@
|
||||
"requires": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
|
||||
}
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
@ -10345,6 +10367,14 @@
|
||||
"requires": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@
|
||||
"normalize-wheel": "1.0.1",
|
||||
"oembed-parser": "^1.4.5",
|
||||
"prop-types": "^15.7.2",
|
||||
"querystring": "^0.2.0",
|
||||
"react": "^16.14.0",
|
||||
"react-codemirror2": "^6.0.1",
|
||||
"react-dom": "^16.14.0",
|
||||
|
24
pkg/interface/src/logic/api/gcp.ts
Normal file
24
pkg/interface/src/logic/api/gcp.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import BaseApi from './base';
|
||||
import {StoreState} from '../store/type';
|
||||
import {GcpToken} from '../types/gcp-state';
|
||||
|
||||
|
||||
export default class GcpApi extends BaseApi<StoreState> {
|
||||
isConfigured() {
|
||||
return this.spider('noun', 'json', 'gcp-is-configured', {})
|
||||
.then((data) => {
|
||||
this.store.handleEvent({
|
||||
data
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.spider('noun', 'gcp-token', 'gcp-get-token', {})
|
||||
.then((token) => {
|
||||
this.store.handleEvent({
|
||||
data: token
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -10,6 +10,7 @@ import GroupsApi from './groups';
|
||||
import LaunchApi from './launch';
|
||||
import GraphApi from './graph';
|
||||
import S3Api from './s3';
|
||||
import GcpApi from './gcp';
|
||||
import {HarkApi} from './hark';
|
||||
import SettingsApi from './settings';
|
||||
|
||||
@ -20,6 +21,7 @@ export default class GlobalApi extends BaseApi<StoreState> {
|
||||
contacts = new ContactsApi(this.ship, this.channel, this.store);
|
||||
groups = new GroupsApi(this.ship, this.channel, this.store);
|
||||
launch = new LaunchApi(this.ship, this.channel, this.store);
|
||||
gcp = new GcpApi(this.ship, this.channel, this.store);
|
||||
s3 = new S3Api(this.ship, this.channel, this.store);
|
||||
graph = new GraphApi(this.ship, this.channel, this.store);
|
||||
hark = new HarkApi(this.ship, this.channel, this.store);
|
||||
|
67
pkg/interface/src/logic/lib/GcpClient.ts
Normal file
67
pkg/interface/src/logic/lib/GcpClient.ts
Normal file
@ -0,0 +1,67 @@
|
||||
// Very simple GCP Storage client.
|
||||
//
|
||||
// It's designed to match a subset of the S3 client upload API. The upload
|
||||
// function on S3 returns a ManagedUpload, which has a promise() method on
|
||||
// it. We don't care about any of the other methods on ManagedUpload, so we
|
||||
// just do the work in its promise() method.
|
||||
//
|
||||
import querystring from 'querystring';
|
||||
import {
|
||||
StorageAcl,
|
||||
StorageClient,
|
||||
StorageUpload,
|
||||
UploadParams,
|
||||
UploadResult
|
||||
} from './StorageClient';
|
||||
|
||||
|
||||
const ENDPOINT = 'storage.googleapis.com';
|
||||
|
||||
class GcpUpload implements StorageUpload {
|
||||
#params: UploadParams;
|
||||
#accessKey: string;
|
||||
|
||||
constructor(params: UploadParams, accessKey: string) {
|
||||
this.#params = params;
|
||||
this.#accessKey = accessKey;
|
||||
}
|
||||
|
||||
async promise(): UploadResult {
|
||||
const {Bucket, Key, ContentType, Body} = this.#params;
|
||||
const urlParams = {
|
||||
uploadType: 'media',
|
||||
name: Key,
|
||||
predefinedAcl: 'publicRead'
|
||||
};
|
||||
const url = `https://${ENDPOINT}/upload/storage/v1/b/${Bucket}/o?` +
|
||||
querystring.stringify(urlParams);
|
||||
const headers = new Headers();
|
||||
headers.append('Authorization', `Bearer ${this.#accessKey}`);
|
||||
headers.append('Content-Type', ContentType);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'default',
|
||||
headers,
|
||||
referrerPolicy: 'no-referrer',
|
||||
body: Body
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('GcpClient server error', await response.json());
|
||||
throw new Error(`GcpClient: response ${response.status}`);
|
||||
}
|
||||
return {Location: `https://${ENDPOINT}/${Bucket}/${Key}`};
|
||||
}
|
||||
}
|
||||
|
||||
export default class GcpClient implements StorageClient {
|
||||
#accessKey: string;
|
||||
|
||||
constructor(accessKey: string) {
|
||||
this.#accessKey = accessKey;
|
||||
}
|
||||
|
||||
upload(params: UploadParams): StorageUpload {
|
||||
return new GcpUpload(params, this.#accessKey);
|
||||
}
|
||||
}
|
32
pkg/interface/src/logic/lib/StorageClient.ts
Normal file
32
pkg/interface/src/logic/lib/StorageClient.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// Defines a StorageClient interface interoperable between S3 and GCP Storage.
|
||||
//
|
||||
|
||||
|
||||
// XX kind of gross. S3 needs 'public-read', GCP needs 'publicRead'.
|
||||
// Rather than write a wrapper around S3, we offer this field here, which
|
||||
// should always be passed, and will be replaced by 'publicRead' in the
|
||||
// GCP client.
|
||||
export enum StorageAcl {
|
||||
PublicRead = 'public-read'
|
||||
};
|
||||
|
||||
export interface UploadParams {
|
||||
Bucket: string; // the bucket to upload the object to
|
||||
Key: string; // the desired location within the bucket
|
||||
ContentType: string; // the object's mime-type
|
||||
ACL: StorageAcl; // ACL, always 'public-read'
|
||||
Body: File; // the object itself
|
||||
};
|
||||
|
||||
export interface UploadResult {
|
||||
Location: string;
|
||||
};
|
||||
|
||||
// Extra layer of indirection used by S3 client.
|
||||
export interface StorageUpload {
|
||||
promise(): Promise<UploadResult>;
|
||||
};
|
||||
|
||||
export interface StorageClient {
|
||||
upload(params: UploadParams): StorageUpload;
|
||||
};
|
141
pkg/interface/src/logic/lib/gcpManager.ts
Normal file
141
pkg/interface/src/logic/lib/gcpManager.ts
Normal file
@ -0,0 +1,141 @@
|
||||
// Singleton that manages GCP token state.
|
||||
//
|
||||
// To use:
|
||||
//
|
||||
// 1. call configure with a GlobalApi and GlobalStore.
|
||||
// 2. call start() to start the token refresh loop.
|
||||
//
|
||||
// If the ship has S3 credentials set, we don't try to get a token, but we keep
|
||||
// checking at regular intervals to see if they get unset. Otherwise, we try to
|
||||
// invoke the GCP token thread on the ship until it gives us an access token.
|
||||
// Once we have a token, we refresh it every hour or so, since it has an
|
||||
// intrinsic expiry.
|
||||
//
|
||||
//
|
||||
import GlobalApi from '../api/global';
|
||||
import GlobalStore from '../store/store';
|
||||
|
||||
|
||||
class GcpManager {
|
||||
#api: GlobalApi | null = null;
|
||||
#store: GlobalStore | null = null;
|
||||
|
||||
configure(api: GlobalApi, store: GlobalStore) {
|
||||
this.#api = api;
|
||||
this.#store = store;
|
||||
}
|
||||
|
||||
#running = false;
|
||||
#timeoutId: number | null = null;
|
||||
|
||||
start() {
|
||||
if (this.#running) {
|
||||
console.warn('GcpManager already running');
|
||||
return;
|
||||
}
|
||||
if (!this.#api || !this.#store) {
|
||||
console.error('GcpManager must have api and store set');
|
||||
return;
|
||||
}
|
||||
this.#running = true;
|
||||
this.refreshLoop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.#running) {
|
||||
console.warn('GcpManager already stopped');
|
||||
console.assert(this.#timeoutId === null);
|
||||
return;
|
||||
}
|
||||
this.#running = false;
|
||||
if (this.#timeoutId !== null) {
|
||||
clearTimeout(this.#timeoutId);
|
||||
this.#timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
restart() {
|
||||
if (this.#running) {
|
||||
this.stop();
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
|
||||
#consecutiveFailures: number = 0;
|
||||
|
||||
private isConfigured() {
|
||||
return this.#store.state.storage.gcp.configured;
|
||||
}
|
||||
|
||||
private refreshLoop() {
|
||||
if (!this.isConfigured()) {
|
||||
this.#api.gcp.isConfigured()
|
||||
.then(() => {
|
||||
if (this.isConfigured() === undefined) {
|
||||
throw new Error("can't check whether GCP is configured?");
|
||||
}
|
||||
if (this.isConfigured()) {
|
||||
this.refreshLoop();
|
||||
} else {
|
||||
this.refreshAfter(10_000);
|
||||
}
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.error('GcpManager failure; stopping.', reason);
|
||||
this.stop();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#api.gcp.getToken()
|
||||
.then(() => {
|
||||
const token = this.#store.state.storage.gcp?.token;
|
||||
if (token) {
|
||||
this.#consecutiveFailures = 0;
|
||||
const interval = this.refreshInterval(token.expiresIn);
|
||||
console.log('GcpManager got token; refreshing after', interval);
|
||||
this.refreshAfter(interval);
|
||||
} else {
|
||||
throw new Error('thread succeeded, but returned no token?');
|
||||
}
|
||||
})
|
||||
.catch((reason) => {
|
||||
this.#consecutiveFailures++;
|
||||
console.warn('GcpManager token refresh failed; retrying with backoff');
|
||||
this.refreshAfter(this.backoffInterval());
|
||||
});
|
||||
}
|
||||
|
||||
private refreshAfter(durationMs) {
|
||||
if (!this.#running) {
|
||||
return;
|
||||
}
|
||||
if (this.#timeoutId !== null) {
|
||||
console.warn('GcpManager already has a timeout set');
|
||||
return;
|
||||
}
|
||||
this.#timeoutId = setTimeout(() => {
|
||||
this.#timeoutId = null;
|
||||
this.refreshLoop();
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
private refreshInterval(expiresIn: number) {
|
||||
// Give ourselves a minute for processing delays, but never refresh sooner
|
||||
// than 30 minutes from now. (The expiry window should be about an hour.)
|
||||
return Math.max(30 * 60_000, expiresIn - 60_000);
|
||||
}
|
||||
|
||||
private backoffInterval() {
|
||||
// exponential backoff.
|
||||
const slotMs = 5_000;
|
||||
const maxSlot = 60; // 5 minutes
|
||||
const backoffSlots =
|
||||
Math.floor(Math.random() * Math.min(maxSlot, this.#consecutiveFailures));
|
||||
return slotMs * backoffSlots;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new GcpManager();
|
||||
Object.freeze(instance);
|
||||
|
||||
export default instance;
|
@ -1,41 +0,0 @@
|
||||
import S3 from 'aws-sdk/clients/s3';
|
||||
|
||||
export default class S3Client {
|
||||
constructor() {
|
||||
this.s3 = null;
|
||||
|
||||
this.endpoint = '';
|
||||
this.accessKeyId = '';
|
||||
this.secretAccesskey = '';
|
||||
}
|
||||
|
||||
setCredentials(endpoint, accessKeyId, secretAccessKey) {
|
||||
this.endpoint = endpoint;
|
||||
this.accessKeyId = accessKeyId;
|
||||
this.secretAccessKey = secretAccessKey;
|
||||
|
||||
this.s3 = new S3({
|
||||
endpoint: endpoint,
|
||||
credentials: {
|
||||
accessKeyId: this.accessKeyId,
|
||||
secretAccessKey: this.secretAccessKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async upload(bucket, filename, buffer) {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ACL: 'public-read',
|
||||
ContentType: buffer.type
|
||||
};
|
||||
|
||||
if(!this.s3) {
|
||||
throw new Error('S3 not initialized');
|
||||
}
|
||||
return this.s3.upload(params).promise();
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { useCallback, useMemo, useEffect, useRef, useState } from "react";
|
||||
import { S3State } from "../../types/s3-update";
|
||||
import {useCallback, useMemo, useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
GcpState,
|
||||
S3State,
|
||||
StorageState
|
||||
} from '../../types';
|
||||
import S3 from "aws-sdk/clients/s3";
|
||||
import { dateToDa, deSig } from "./util";
|
||||
import GcpClient from './GcpClient';
|
||||
import {StorageClient, StorageAcl} from './StorageClient';
|
||||
import {dateToDa, deSig} from "./util";
|
||||
|
||||
export interface IuseS3 {
|
||||
|
||||
export interface IuseStorage {
|
||||
canUpload: boolean;
|
||||
upload: (file: File, bucket: string) => Promise<string>;
|
||||
uploadDefault: (file: File) => Promise<string>;
|
||||
@ -11,31 +18,43 @@ export interface IuseS3 {
|
||||
promptUpload: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => {
|
||||
const useStorage = ({gcp, s3}: StorageState,
|
||||
{ accept = '*' } = { accept: '*' }): IuseStorage => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const client = useRef<S3 | null>(null);
|
||||
const client = useRef<StorageClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!s3.credentials) {
|
||||
return;
|
||||
// prefer GCP if available, else use S3.
|
||||
if (gcp.token !== undefined) {
|
||||
client.current = new GcpClient(gcp.token.accessKey);
|
||||
} else {
|
||||
// XX ships currently always have S3 credentials, but the fields are all
|
||||
// set to '' if they are not configured.
|
||||
if (!s3.credentials ||
|
||||
!s3.credentials.accessKeyId ||
|
||||
!s3.credentials.secretAccessKey) {
|
||||
return;
|
||||
}
|
||||
client.current = new S3({
|
||||
credentials: s3.credentials,
|
||||
endpoint: s3.credentials.endpoint
|
||||
});
|
||||
}
|
||||
client.current = new S3({
|
||||
credentials: s3.credentials,
|
||||
endpoint: s3.credentials.endpoint
|
||||
});
|
||||
}, [s3.credentials]);
|
||||
}, [gcp.token, s3.credentials]);
|
||||
|
||||
const canUpload = useMemo(
|
||||
() =>
|
||||
(client && s3.credentials && s3.configuration.currentBucket !== "") || false,
|
||||
[s3.credentials, s3.configuration.currentBucket, client]
|
||||
((gcp.token || (s3.credentials && s3.credentials.accessKeyId &&
|
||||
s3.credentials.secretAccessKey)) &&
|
||||
s3.configuration.currentBucket !== "") || false,
|
||||
[s3.credentials, gcp.token, s3.configuration.currentBucket]
|
||||
);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File, bucket: string) => {
|
||||
if (!client.current) {
|
||||
throw new Error("S3 not ready");
|
||||
if (client.current === null) {
|
||||
throw new Error("Storage not ready");
|
||||
}
|
||||
|
||||
const fileParts = file.name.split('.');
|
||||
@ -47,7 +66,7 @@ const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => {
|
||||
Bucket: bucket,
|
||||
Key: `${window.ship}/${timestamp}-${fileName}.${fileExtension}`,
|
||||
Body: file,
|
||||
ACL: "public-read",
|
||||
ACL: StorageAcl.PublicRead,
|
||||
ContentType: file.type,
|
||||
};
|
||||
|
||||
@ -63,11 +82,11 @@ const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => {
|
||||
);
|
||||
|
||||
const uploadDefault = useCallback(async (file: File) => {
|
||||
if (s3.configuration.currentBucket === "") {
|
||||
if (s3.configuration.currentBucket === '') {
|
||||
throw new Error("current bucket not set");
|
||||
}
|
||||
return upload(file, s3.configuration.currentBucket);
|
||||
}, [s3]);
|
||||
}, [s3, upload]);
|
||||
|
||||
const promptUpload = useCallback(
|
||||
() => {
|
||||
@ -88,12 +107,11 @@ const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => {
|
||||
document.body.appendChild(fileSelector);
|
||||
fileSelector.click();
|
||||
})
|
||||
|
||||
},
|
||||
[uploadDefault]
|
||||
);
|
||||
|
||||
return { canUpload, upload, uploadDefault, uploading, promptUpload };
|
||||
return {canUpload, upload, uploadDefault, uploading, promptUpload};
|
||||
};
|
||||
|
||||
export default useS3;
|
||||
export default useStorage;
|
37
pkg/interface/src/logic/reducers/gcp-reducer.ts
Normal file
37
pkg/interface/src/logic/reducers/gcp-reducer.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import _ from 'lodash';
|
||||
import {StoreState} from '../store/type';
|
||||
import {GcpToken} from '../../types/gcp-state';
|
||||
|
||||
type GcpState = Pick<StoreState, 'gcp'>;
|
||||
|
||||
export default class GcpReducer<S extends GcpState>{
|
||||
reduce(json: Cage, state: S) {
|
||||
this.reduceConfigured(json, state);
|
||||
this.reduceToken(json, state);
|
||||
}
|
||||
|
||||
reduceConfigured(json, state) {
|
||||
let data = json['gcp-configured'];
|
||||
if (data !== undefined) {
|
||||
state.storage.gcp.configured = data;
|
||||
}
|
||||
}
|
||||
|
||||
reduceToken(json: Cage, state: S) {
|
||||
let data = json['gcp-token'];
|
||||
if (data) {
|
||||
this.setToken(data, state);
|
||||
}
|
||||
}
|
||||
|
||||
setToken(data: any, state: S) {
|
||||
if (this.isToken(data)) {
|
||||
state.storage.gcp.token = data;
|
||||
}
|
||||
}
|
||||
|
||||
isToken(token: any): token is GcpToken {
|
||||
return (typeof(token.accessKey) === 'string' &&
|
||||
typeof(token.expiresIn) === 'number');
|
||||
}
|
||||
}
|
@ -23,14 +23,14 @@ export default class S3Reducer<S extends S3State> {
|
||||
credentials(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'credentials', false);
|
||||
if (data) {
|
||||
state.s3.credentials = data;
|
||||
state.storage.s3.credentials = data;
|
||||
}
|
||||
}
|
||||
|
||||
configuration(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'configuration', false);
|
||||
if (data) {
|
||||
state.s3.configuration = {
|
||||
state.storage.s3.configuration = {
|
||||
buckets: new Set(data.buckets),
|
||||
currentBucket: data.currentBucket
|
||||
};
|
||||
@ -39,44 +39,44 @@ export default class S3Reducer<S extends S3State> {
|
||||
|
||||
currentBucket(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'setCurrentBucket', false);
|
||||
if (data && state.s3) {
|
||||
state.s3.configuration.currentBucket = data;
|
||||
if (data && state.storage.s3) {
|
||||
state.storage.s3.configuration.currentBucket = data;
|
||||
}
|
||||
}
|
||||
|
||||
addBucket(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'addBucket', false);
|
||||
if (data) {
|
||||
state.s3.configuration.buckets =
|
||||
state.s3.configuration.buckets.add(data);
|
||||
state.storage.s3.configuration.buckets =
|
||||
state.storage.s3.configuration.buckets.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
removeBucket(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'removeBucket', false);
|
||||
if (data) {
|
||||
state.s3.configuration.buckets.delete(data);
|
||||
state.storage.s3.configuration.buckets.delete(data);
|
||||
}
|
||||
}
|
||||
|
||||
endpoint(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'setEndpoint', false);
|
||||
if (data && state.s3.credentials) {
|
||||
state.s3.credentials.endpoint = data;
|
||||
if (data && state.storage.s3.credentials) {
|
||||
state.storage.s3.credentials.endpoint = data;
|
||||
}
|
||||
}
|
||||
|
||||
accessKeyId(json: S3Update , state: S) {
|
||||
const data = _.get(json, 'setAccessKeyId', false);
|
||||
if (data && state.s3.credentials) {
|
||||
state.s3.credentials.accessKeyId = data;
|
||||
if (data && state.storage.s3.credentials) {
|
||||
state.storage.s3.credentials.accessKeyId = data;
|
||||
}
|
||||
}
|
||||
|
||||
secretAccessKey(json: S3Update, state: S) {
|
||||
const data = _.get(json, 'setSecretAccessKey', false);
|
||||
if (data && state.s3.credentials) {
|
||||
state.s3.credentials.secretAccessKey = data;
|
||||
if (data && state.storage.s3.credentials) {
|
||||
state.storage.s3.credentials.secretAccessKey = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import GroupReducer from '../reducers/group-update';
|
||||
import LaunchReducer from '../reducers/launch-update';
|
||||
import ConnectionReducer from '../reducers/connection';
|
||||
import SettingsReducer from '../reducers/settings-update';
|
||||
import GcpReducer from '../reducers/gcp-reducer';
|
||||
import {OrderedMap} from '../lib/OrderedMap';
|
||||
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
|
||||
import {GroupViewReducer} from '../reducers/group-view';
|
||||
@ -30,6 +31,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
launchReducer = new LaunchReducer();
|
||||
connReducer = new ConnectionReducer();
|
||||
settingsReducer = new SettingsReducer();
|
||||
gcpReducer = new GcpReducer();
|
||||
|
||||
pastActions: Record<string, any> = {}
|
||||
|
||||
@ -71,12 +73,15 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
},
|
||||
weather: {},
|
||||
userLocation: null,
|
||||
s3: {
|
||||
configuration: {
|
||||
buckets: new Set(),
|
||||
currentBucket: ''
|
||||
storage: {
|
||||
gcp: {},
|
||||
s3: {
|
||||
configuration: {
|
||||
buckets: new Set(),
|
||||
currentBucket: ''
|
||||
},
|
||||
credentials: null
|
||||
},
|
||||
credentials: null
|
||||
},
|
||||
isContactPublic: false,
|
||||
contacts: {},
|
||||
@ -115,6 +120,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
GraphReducer(data, this.state);
|
||||
HarkReducer(data, this.state);
|
||||
ContactReducer(data, this.state);
|
||||
this.gcpReducer.reduce(data, this.state);
|
||||
this.settingsReducer.reduce(data, this.state);
|
||||
GroupViewReducer(data, this.state);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { Invites } from '~/types/invite-update';
|
||||
import { Associations } from '~/types/metadata-update';
|
||||
import { Rolodex } from '~/types/contact-update';
|
||||
import { Groups } from '~/types/group-update';
|
||||
import { S3State } from '~/types/s3-update';
|
||||
import { StorageState } from '~/types/storage-state';
|
||||
import { LaunchState, WeatherState } from '~/types/launch-update';
|
||||
import { ConnectionStatus } from '~/types/connection';
|
||||
import {Graphs} from '~/types/graph-update';
|
||||
@ -31,7 +31,7 @@ export interface StoreState {
|
||||
groups: Groups;
|
||||
groupKeys: Set<Path>;
|
||||
nackedContacts: Set<Patp>
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
graphs: Graphs;
|
||||
graphKeys: Set<string>;
|
||||
|
||||
|
9
pkg/interface/src/types/gcp-state.ts
Normal file
9
pkg/interface/src/types/gcp-state.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface GcpToken {
|
||||
accessKey: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
export interface GcpState {
|
||||
configured?: boolean;
|
||||
token?: GcpToken
|
||||
};
|
@ -11,6 +11,8 @@ export * from './launch-update';
|
||||
export * from './local-update';
|
||||
export * from './metadata-update';
|
||||
export * from './noun';
|
||||
export * from './storage-state';
|
||||
export * from './gcp-state';
|
||||
export * from './s3-update';
|
||||
export * from './workspace';
|
||||
export * from './util';
|
||||
|
8
pkg/interface/src/types/storage-state.ts
Normal file
8
pkg/interface/src/types/storage-state.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import {GcpState} from './gcp-state';
|
||||
import {S3State} from './s3-update';
|
||||
|
||||
|
||||
export interface StorageState {
|
||||
gcp: GcpState;
|
||||
s3: S3State;
|
||||
};
|
@ -27,6 +27,7 @@ import GlobalSubscription from '~/logic/subscription/global';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { foregroundFromBackground } from '~/logic/lib/sigil';
|
||||
import gcpManager from '~/logic/lib/gcpManager';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
|
||||
|
||||
@ -78,6 +79,7 @@ class App extends React.Component {
|
||||
|
||||
this.appChannel = new window.channel();
|
||||
this.api = new GlobalApi(this.ship, this.appChannel, this.store);
|
||||
gcpManager.configure(this.api, this.store);
|
||||
this.subscription =
|
||||
new GlobalSubscription(this.store, this.api, this.appChannel);
|
||||
|
||||
@ -97,6 +99,7 @@ class App extends React.Component {
|
||||
this.api.local.getBaseHash();
|
||||
this.api.settings.getAll();
|
||||
this.store.rehydrate();
|
||||
gcpManager.start();
|
||||
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
@ -13,7 +13,6 @@ import { ShareProfile } from '~/views/apps/chat/components/ShareProfile';
|
||||
import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
|
||||
import { Loading } from '~/views/components/Loading';
|
||||
import useS3 from '~/logic/lib/useS3';
|
||||
import { isWriter, resourceFromPath } from '~/logic/lib/group';
|
||||
|
||||
import './css/custom.css';
|
||||
@ -180,7 +179,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
(!showBanner && hasLoadedAllowed) ? contacts : modifiedContacts
|
||||
}
|
||||
onUnmount={appendUnsent}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
placeholder="Message..."
|
||||
message={unsent[station] || ''}
|
||||
deleteMessage={clearUnsent}
|
||||
|
@ -1,18 +1,18 @@
|
||||
import React, { Component } from 'react';
|
||||
import ChatEditor from './chat-editor';
|
||||
import { IuseS3 } from '~/logic/lib/useS3';
|
||||
import { IuseStorage } from '~/logic/lib/useStorage';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { createPost } from '~/logic/api/graph';
|
||||
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Envelope } from '~/types/chat-update';
|
||||
import { Contacts, Content } from '~/types';
|
||||
import { StorageState, Contacts, Content } from '~/types';
|
||||
import { Row, BaseImage, Box, Icon, LoadingSpinner } from '@tlon/indigo-react';
|
||||
import withS3 from '~/views/components/withS3';
|
||||
import withStorage from '~/views/components/withStorage';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
|
||||
type ChatInputProps = IuseS3 & {
|
||||
type ChatInputProps = IuseStorage & {
|
||||
api: GlobalApi;
|
||||
numMsgs: number;
|
||||
station: any;
|
||||
@ -20,7 +20,7 @@ type ChatInputProps = IuseS3 & {
|
||||
envelopes: Envelope[];
|
||||
contacts: Contacts;
|
||||
onUnmount(msg: string): void;
|
||||
s3: any;
|
||||
storage: StorageState;
|
||||
placeholder: string;
|
||||
message: string;
|
||||
deleteMessage(): void;
|
||||
@ -200,4 +200,4 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withLocalState(withS3(ChatInput, {accept: 'image/*'}), ['hideAvatars']);
|
||||
export default withLocalState(withStorage(ChatInput, {accept: 'image/*'}), ['hideAvatars']);
|
||||
|
@ -34,7 +34,7 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
associations,
|
||||
graphKeys,
|
||||
unreads,
|
||||
s3,
|
||||
storage,
|
||||
history
|
||||
} = props;
|
||||
|
||||
@ -70,7 +70,7 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
render={(props) => {
|
||||
return (
|
||||
<LinkWindow
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
association={resource}
|
||||
contacts={contacts}
|
||||
resource={resourcePath}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
LocalUpdateRemoteContentPolicy,
|
||||
Group,
|
||||
Rolodex,
|
||||
GcpState,
|
||||
S3State,
|
||||
} from "~/types";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
@ -29,7 +30,7 @@ interface LinkWindowProps {
|
||||
group: Group;
|
||||
path: string;
|
||||
api: GlobalApi;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
export function LinkWindow(props: LinkWindowProps) {
|
||||
const { graph, api, association } = props;
|
||||
@ -65,7 +66,7 @@ export function LinkWindow(props: LinkWindowProps) {
|
||||
return (
|
||||
<Col key={0} mx="auto" mt="4" maxWidth="768px" width="100%" flexShrink={0} px={3}>
|
||||
{ canWrite ? (
|
||||
<LinkSubmit s3={props.s3} name={name} ship={ship.slice(1)} api={api} />
|
||||
<LinkSubmit storage={props.storage} name={name} ship={ship.slice(1)} api={api} />
|
||||
) : (
|
||||
<Text>There are no links here yet. You do not have permission to post to this collection.</Text>
|
||||
)
|
||||
@ -96,7 +97,7 @@ export function LinkWindow(props: LinkWindowProps) {
|
||||
return (
|
||||
<React.Fragment key={index.toString()}>
|
||||
<Col key={index.toString()} mx="auto" mt="4" maxWidth="768px" width="100%" flexShrink={0} px={3}>
|
||||
<LinkSubmit s3={props.s3} name={name} ship={ship.slice(1)} api={api} />
|
||||
<LinkSubmit storage={props.storage} name={name} ship={ship.slice(1)} api={api} />
|
||||
</Col>
|
||||
<LinkItem {...linkProps} />
|
||||
</React.Fragment>
|
||||
|
@ -2,21 +2,22 @@ import { BaseInput, Box, Button, LoadingSpinner, Text } from "@tlon/indigo-react
|
||||
import React, { useCallback, useState } from "react";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { useFileDrag } from "~/logic/lib/useDrag";
|
||||
import useS3 from "~/logic/lib/useS3";
|
||||
import { S3State } from "~/types";
|
||||
import useStorage from "~/logic/lib/useStorage";
|
||||
import { StorageState } from "~/types";
|
||||
import SubmitDragger from "~/views/components/SubmitDragger";
|
||||
import { createPost } from "~/logic/api/graph";
|
||||
import { hasProvider } from "oembed-parser";
|
||||
|
||||
interface LinkSubmitProps {
|
||||
api: GlobalApi;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
name: string;
|
||||
ship: string;
|
||||
};
|
||||
|
||||
const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
let { canUpload, uploadDefault, uploading, promptUpload } = useS3(props.s3);
|
||||
let { canUpload, uploadDefault, uploading, promptUpload } =
|
||||
useStorage(props.storage);
|
||||
|
||||
const [submitFocused, setSubmitFocused] = useState(false);
|
||||
const [urlFocused, setUrlFocused] = useState(false);
|
||||
@ -223,4 +224,4 @@ const LinkSubmit = (props: LinkSubmitProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkSubmit;
|
||||
export default LinkSubmit;
|
||||
|
@ -114,15 +114,15 @@ export function EditProfile(props: any) {
|
||||
<Input id="nickname" label="Name" mb={3} />
|
||||
<Col width="100%">
|
||||
<Text mb={2}>Description</Text>
|
||||
<MarkdownField id="bio" mb={3} s3={props.s3} />
|
||||
<MarkdownField id="bio" mb={3} storage={props.storage} />
|
||||
</Col>
|
||||
<ColorInput id="color" label="Sigil Color" mb={3} />
|
||||
<Row mb={3} width="100%">
|
||||
<Col pr={2} width="50%">
|
||||
<ImageInput id="cover" label="Cover Image" s3={props.s3} />
|
||||
<ImageInput id="cover" label="Cover Image" storage={props.storage} />
|
||||
</Col>
|
||||
<Col pl={2} width="50%">
|
||||
<ImageInput id="avatar" label="Profile Image" s3={props.s3} />
|
||||
<ImageInput id="avatar" label="Profile Image" storage={props.storage} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Checkbox mb={3} id="isPublic" label="Public Profile" />
|
||||
|
@ -107,7 +107,7 @@ export function Profile(props: any) {
|
||||
<EditProfile
|
||||
ship={ship}
|
||||
contact={contact}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
api={props.api}
|
||||
groups={props.groups}
|
||||
associations={props.associations}
|
||||
|
@ -50,7 +50,7 @@ export default function ProfileScreen(props: any) {
|
||||
groups={props.groups}
|
||||
contact={contact}
|
||||
api={props.api}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
isEdit={isEdit}
|
||||
isPublic={isPublic}
|
||||
nackedContacts={props.nackedContacts}
|
||||
|
@ -37,7 +37,7 @@ export function PublishResource(props: PublishResourceProps) {
|
||||
location={props.location}
|
||||
unreads={props.unreads}
|
||||
graphs={props.graphs}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
@ -4,7 +4,7 @@ import { PostFormSchema, PostForm } from "./NoteForm";
|
||||
import { FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { RouteComponentProps, useLocation } from "react-router-dom";
|
||||
import { GraphNode, TextContent, Association, S3State } from "~/types";
|
||||
import { GraphNode, TextContent, Association, StorageState } from "~/types";
|
||||
import { getLatestRevision, editPost } from "~/logic/lib/publish";
|
||||
import {useWaitForProps} from "~/logic/lib/useWaitForProps";
|
||||
interface EditPostProps {
|
||||
@ -13,11 +13,11 @@ interface EditPostProps {
|
||||
note: GraphNode;
|
||||
api: GlobalApi;
|
||||
book: string;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export function EditPost(props: EditPostProps & RouteComponentProps) {
|
||||
const { note, book, noteId, api, ship, history, s3 } = props;
|
||||
const { note, book, noteId, api, ship, history, storage } = props;
|
||||
const [revNum, title, body] = getLatestRevision(note);
|
||||
const location = useLocation();
|
||||
|
||||
@ -54,7 +54,7 @@ export function EditPost(props: EditPostProps & RouteComponentProps) {
|
||||
cancel
|
||||
history={history}
|
||||
onSubmit={onSubmit}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
submitLabel="Update"
|
||||
loadingText="Updating..."
|
||||
/>
|
||||
|
@ -16,8 +16,8 @@ import "codemirror/lib/codemirror.css";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { useFileDrag } from "~/logic/lib/useDrag";
|
||||
import SubmitDragger from "~/views/components/SubmitDragger";
|
||||
import useS3 from "~/logic/lib/useS3";
|
||||
import { S3State } from "~/types";
|
||||
import useStorage from "~/logic/lib/useStorage";
|
||||
import { StorageState } from "~/types";
|
||||
|
||||
const MARKDOWN_CONFIG = {
|
||||
name: "markdown",
|
||||
@ -28,7 +28,7 @@ interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onBlur?: (e: any) => void;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
const PromptIfDirty = () => {
|
||||
@ -74,7 +74,7 @@ export function MarkdownEditor(
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const { uploadDefault, canUpload } = useS3(props.s3);
|
||||
const { uploadDefault, canUpload } = useStorage(props.storage);
|
||||
|
||||
const onFileDrag = useCallback(
|
||||
async (files: FileList | File[], e: DragEvent) => {
|
||||
|
@ -6,7 +6,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
||||
|
||||
export const MarkdownField = ({
|
||||
id,
|
||||
s3,
|
||||
storage,
|
||||
...rest
|
||||
}: { id: string } & Parameters<typeof Box>[0]) => {
|
||||
const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id);
|
||||
@ -35,7 +35,7 @@ export const MarkdownField = ({
|
||||
onBlur={handleBlur}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
/>
|
||||
<ErrorLabel mt="2" hasError={!!(error && touched)}>
|
||||
{error}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
import { AsyncButton } from "../../../components/AsyncButton";
|
||||
import { Formik, Form, FormikHelpers } from "formik";
|
||||
import { MarkdownField } from "./MarkdownField";
|
||||
import { S3State } from "~/types";
|
||||
import { StorageState } from "~/types";
|
||||
|
||||
interface PostFormProps {
|
||||
initial: PostFormSchema;
|
||||
@ -21,7 +21,7 @@ interface PostFormProps {
|
||||
) => Promise<any>;
|
||||
submitLabel: string;
|
||||
loadingText: string;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
@ -35,7 +35,7 @@ export interface PostFormSchema {
|
||||
}
|
||||
|
||||
export function PostForm(props: PostFormProps) {
|
||||
const { initial, onSubmit, submitLabel, loadingText, s3, cancel, history } = props;
|
||||
const { initial, onSubmit, submitLabel, loadingText, storage, cancel, history } = props;
|
||||
|
||||
return (
|
||||
<Col width="100%" height="100%" p={[2, 4]}>
|
||||
@ -66,7 +66,7 @@ export function PostForm(props: PostFormProps) {
|
||||
type="button">Cancel</Button>}
|
||||
</Row>
|
||||
</Row>
|
||||
<MarkdownField flexGrow={1} id="body" s3={s3} />
|
||||
<MarkdownField flexGrow={1} id="body" storage={storage} />
|
||||
</Form>
|
||||
</Formik>
|
||||
</Col>
|
||||
|
@ -6,7 +6,14 @@ import { RouteComponentProps } from "react-router-dom";
|
||||
import Note from "./Note";
|
||||
import { EditPost } from "./EditPost";
|
||||
|
||||
import { GraphNode, Graph, Contacts, Association, S3State, Group } from "~/types";
|
||||
import {
|
||||
GraphNode,
|
||||
Graph,
|
||||
Contacts,
|
||||
Association,
|
||||
StorageState,
|
||||
Group
|
||||
} from "~/types";
|
||||
|
||||
interface NoteRoutesProps {
|
||||
ship: string;
|
||||
@ -20,7 +27,7 @@ interface NoteRoutesProps {
|
||||
baseUrl?: string;
|
||||
rootUrl?: string;
|
||||
group: Group;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
Contacts,
|
||||
Rolodex,
|
||||
Unreads,
|
||||
S3State
|
||||
StorageState
|
||||
} from "~/types";
|
||||
import { Center, LoadingSpinner } from "@tlon/indigo-react";
|
||||
import bigInt from 'big-integer';
|
||||
@ -33,7 +33,7 @@ interface NotebookRoutesProps {
|
||||
rootUrl: string;
|
||||
association: Association;
|
||||
associations: Associations;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export function NotebookRoutes(
|
||||
@ -80,7 +80,7 @@ export function NotebookRoutes(
|
||||
association={props.association}
|
||||
graph={graph}
|
||||
baseUrl={baseUrl}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -112,7 +112,7 @@ export function NotebookRoutes(
|
||||
contacts={notebookContacts}
|
||||
association={props.association}
|
||||
group={group}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
{...routeProps}
|
||||
/>
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ import { RouteComponentProps } from "react-router-dom";
|
||||
import { PostForm, PostFormSchema } from "./NoteForm";
|
||||
import {createPost} from "~/logic/api/graph";
|
||||
import {Graph} from "~/types/graph-update";
|
||||
import {Association, S3State} from "~/types";
|
||||
import {Association, StorageState} from "~/types";
|
||||
import {newPost} from "~/logic/lib/publish";
|
||||
|
||||
interface NewPostProps {
|
||||
@ -16,7 +16,7 @@ interface NewPostProps {
|
||||
graph: Graph;
|
||||
association: Association;
|
||||
baseUrl: string;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
@ -53,7 +53,7 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
onSubmit={onSubmit}
|
||||
submitLabel="Publish"
|
||||
loadingText="Posting..."
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from "@tlon/indigo-react";
|
||||
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { S3State } from "~/types";
|
||||
import { StorageState } from "~/types";
|
||||
import { ImageInput } from "~/views/components/ImageInput";
|
||||
import {ColorInput} from "~/views/components/ColorInput";
|
||||
|
||||
@ -19,12 +19,12 @@ export function BackgroundPicker({
|
||||
bgType,
|
||||
bgUrl,
|
||||
api,
|
||||
s3,
|
||||
storage,
|
||||
}: {
|
||||
bgType: BgType;
|
||||
bgUrl?: string;
|
||||
api: GlobalApi;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}) {
|
||||
|
||||
const rowSpace = { my: 0, alignItems: 'center' };
|
||||
@ -38,7 +38,7 @@ export function BackgroundPicker({
|
||||
<ImageInput
|
||||
ml="3"
|
||||
api={api}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
id="bgUrl"
|
||||
name="bgUrl"
|
||||
label="URL"
|
||||
|
@ -10,7 +10,7 @@ import * as Yup from 'yup';
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { S3State, BackgroundConfig } from '~/types';
|
||||
import { StorageState, BackgroundConfig } from '~/types';
|
||||
import { BackgroundPicker, BgType } from './BackgroundPicker';
|
||||
import useLocalState, { LocalState } from '~/logic/state/local';
|
||||
|
||||
@ -34,11 +34,11 @@ interface FormSchema {
|
||||
|
||||
interface DisplayFormProps {
|
||||
api: GlobalApi;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export default function DisplayForm(props: DisplayFormProps) {
|
||||
const { api, s3 } = props;
|
||||
const { api, storage } = props;
|
||||
|
||||
const { hideAvatars, hideNicknames, background, set: setLocalState } = useLocalState();
|
||||
|
||||
@ -94,7 +94,7 @@ export default function DisplayForm(props: DisplayFormProps) {
|
||||
bgType={props.values.bgType}
|
||||
bgUrl={props.values.bgUrl}
|
||||
api={api}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Disable avatars"
|
||||
|
@ -13,7 +13,7 @@ type ProfileProps = StoreState & { api: GlobalApi; ship: string };
|
||||
|
||||
export default function Settings({
|
||||
api,
|
||||
s3
|
||||
storage,
|
||||
}: ProfileProps) {
|
||||
return (
|
||||
<Box
|
||||
@ -27,10 +27,10 @@ export default function Settings({
|
||||
>
|
||||
<DisplayForm
|
||||
api={api}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
/>
|
||||
<RemoteContentForm api={api} />
|
||||
<S3Form api={api} s3={s3} />
|
||||
<S3Form api={api} s3={storage.s3} />
|
||||
<SecuritySettings api={api} />
|
||||
</Box>
|
||||
);
|
||||
|
@ -10,20 +10,20 @@ import {
|
||||
BaseInput
|
||||
} from "@tlon/indigo-react";
|
||||
import { useField } from "formik";
|
||||
import { S3State } from "~/types/s3-update";
|
||||
import useS3 from "~/logic/lib/useS3";
|
||||
import { StorageState } from "~/types/storage-state";
|
||||
import useStorage from "~/logic/lib/useStorage";
|
||||
|
||||
type ImageInputProps = Parameters<typeof Box>[0] & {
|
||||
id: string;
|
||||
label: string;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function ImageInput(props: ImageInputProps) {
|
||||
const { id, label, s3, caption, placeholder, ...rest } = props;
|
||||
const { id, label, storage, caption, placeholder, ...rest } = props;
|
||||
|
||||
const { uploadDefault, canUpload, uploading } = useS3(s3);
|
||||
const { uploadDefault, canUpload, uploading } = useStorage(storage);
|
||||
|
||||
const [field, meta, { setValue, setError }] = useField(id);
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { BaseInput, Box, Icon, LoadingSpinner, Text } from "@tlon/indigo-react";
|
||||
import React, { useCallback } from "react";
|
||||
import useS3 from "~/logic/lib/useS3";
|
||||
import { S3State } from "~/types";
|
||||
|
||||
const SubmitDragger = () => (
|
||||
<Box
|
||||
@ -24,4 +22,4 @@ const SubmitDragger = () => (
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default SubmitDragger;
|
||||
export default SubmitDragger;
|
||||
|
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import useS3 from "~/logic/lib/useS3";
|
||||
|
||||
const withS3 = (Component, params = {}) => {
|
||||
return React.forwardRef((props: any, ref) => {
|
||||
const s3 = useS3(props.s3, params);
|
||||
|
||||
return <Component ref={ref} {...s3} {...props} />;
|
||||
});
|
||||
};
|
||||
|
||||
export default withS3;
|
12
pkg/interface/src/views/components/withStorage.tsx
Normal file
12
pkg/interface/src/views/components/withStorage.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import useStorage from "~/logic/lib/useStorage";
|
||||
|
||||
const withStorage = (Component, params = {}) => {
|
||||
return React.forwardRef((props: any, ref) => {
|
||||
const storage = useStorage(props.storage, params);
|
||||
|
||||
return <Component ref={ref} {...storage} {...props} />;
|
||||
});
|
||||
};
|
||||
|
||||
export default withStorage;
|
@ -22,7 +22,7 @@ import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import {S3State} from "~/types";
|
||||
import {StorageState} from "~/types";
|
||||
import {ImageInput} from "~/views/components/ImageInput";
|
||||
|
||||
interface FormSchema {
|
||||
@ -46,11 +46,11 @@ interface GroupAdminSettingsProps {
|
||||
group: Group;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
}
|
||||
|
||||
export function GroupAdminSettings(props: GroupAdminSettingsProps) {
|
||||
const { group, association, s3 } = props;
|
||||
const { group, association, storage } = props;
|
||||
const { metadata } = association;
|
||||
const history = useHistory();
|
||||
const currentPrivate = "invite" in props.group.policy;
|
||||
@ -132,7 +132,7 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
|
||||
caption="A picture for your group"
|
||||
placeholder="Enter URL"
|
||||
disabled={disabled}
|
||||
s3={s3}
|
||||
storage={storage}
|
||||
/>
|
||||
<Checkbox
|
||||
id="isPrivate"
|
||||
|
@ -6,7 +6,7 @@ import GlobalApi from "~/logic/api/global";
|
||||
|
||||
import { GroupAdminSettings } from "./Admin";
|
||||
import { GroupPersonalSettings } from "./Personal";
|
||||
import { GroupNotificationsConfig, S3State } from "~/types";
|
||||
import { GroupNotificationsConfig, StorageState } from "~/types";
|
||||
import { GroupChannelSettings } from "./Channels";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import {resourceFromPath, roleForShip} from "~/logic/lib/group";
|
||||
@ -21,7 +21,7 @@ interface GroupSettingsProps {
|
||||
associations: Associations;
|
||||
api: GlobalApi;
|
||||
notificationsGroupConfig: GroupNotificationsConfig;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
baseUrl: string;
|
||||
}
|
||||
export function GroupSettings(props: GroupSettingsProps) {
|
||||
|
@ -71,7 +71,7 @@ export function GroupsPane(props: GroupsPaneProps) {
|
||||
association={groupAssociation!}
|
||||
group={group!}
|
||||
api={api}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
notificationsGroupConfig={props.notificationsGroupConfig}
|
||||
associations={associations}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { Contacts, Contact } from "~/types/contact-update";
|
||||
import { Group } from "~/types/group-update";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { GroupNotificationsConfig, S3State, Associations } from "~/types";
|
||||
import { GroupNotificationsConfig, StorageState, Associations } from "~/types";
|
||||
|
||||
import { GroupSettings } from "./GroupSettings/GroupSettings";
|
||||
import { Participants } from "./Participants";
|
||||
@ -23,7 +23,7 @@ export function PopoverRoutes(
|
||||
group: Group;
|
||||
association: Association;
|
||||
associations: Associations;
|
||||
s3: S3State;
|
||||
storage: StorageState;
|
||||
api: GlobalApi;
|
||||
notificationsGroupConfig: GroupNotificationsConfig;
|
||||
rootIdentity: Contact;
|
||||
@ -127,7 +127,7 @@ export function PopoverRoutes(
|
||||
api={props.api}
|
||||
notificationsGroupConfig={props.notificationsGroupConfig}
|
||||
associations={props.associations}
|
||||
s3={props.s3}
|
||||
storage={props.storage}
|
||||
/>
|
||||
)}
|
||||
{view === "participants" && (
|
||||
|
53
sh/poke-gcp-account-json
Executable file
53
sh/poke-gcp-account-json
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def herb_poke_gcp_setting(pier, key, val):
|
||||
"""
|
||||
Poke a value into settings-store under the %gcp-store bucket.
|
||||
|
||||
This does not sanitize or check its inputs. Please make sure they are
|
||||
correct before calling this function.
|
||||
|
||||
:pier: Pier of the ship to poke.
|
||||
:key: Key to poke. Must be a @tas (i.e. include the '%').
|
||||
:val: Value to poke. Must be a @t. (will be passed through crude_t.)
|
||||
"""
|
||||
print('herb_poke ' + key)
|
||||
# XXX use +same because herb's cell parser is cursed.
|
||||
poke_arg = "(same %put-entry %gcp-store {} %s {})".format(
|
||||
key, crude_t(val))
|
||||
return subprocess.run(['herb', pier, '-p', 'settings-store', '-d',
|
||||
poke_arg, '-m', 'settings-event'],
|
||||
check=True)
|
||||
|
||||
def crude_t(pin):
|
||||
"""
|
||||
Very crude, bad, dangerous, and evil @t transform.
|
||||
|
||||
Puts single quotes around the string. Escapes instances of single quote and
|
||||
backslash within the string, and turns newlines into \0a.
|
||||
"""
|
||||
replaces = [(r'\\', r'\\\\'), ("'", r"\\'"), ("\n", r'\\0a')]
|
||||
for pattern, replace in replaces:
|
||||
pin = re.sub(pattern, replace, pin, flags=re.MULTILINE)
|
||||
return "'{}'".format(pin)
|
||||
|
||||
def read_gcp_json(keyfile):
|
||||
with open(keyfile, 'r') as f:
|
||||
return json.loads(f.read())
|
||||
|
||||
def main():
|
||||
pier, keyfile = sys.argv[1:]
|
||||
obj = read_gcp_json(keyfile)
|
||||
herb_poke_gcp_setting(pier, '%token-uri', obj['token_uri'])
|
||||
herb_poke_gcp_setting(pier, '%client-email', obj['client_email'])
|
||||
herb_poke_gcp_setting(pier, '%private-key-id', obj['private_key_id'])
|
||||
herb_poke_gcp_setting(pier, '%private-key', obj['private_key'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Loading…
Reference in New Issue
Block a user