diff --git a/pkg/arvo/lib/gcp.hoon b/pkg/arvo/lib/gcp.hoon new file mode 100644 index 0000000000..0b472ea975 --- /dev/null +++ b/pkg/arvo/lib/gcp.hoon @@ -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) + ~ + == +-- diff --git a/pkg/arvo/mar/gcp-token.hoon b/pkg/arvo/mar/gcp-token.hoon new file mode 100644 index 0000000000..3816324f23 --- /dev/null +++ b/pkg/arvo/mar/gcp-token.hoon @@ -0,0 +1,13 @@ +/+ *gcp +|_ tok=token +++ grad %noun +++ grow + |% + ++ noun tok + ++ json (token-to-json tok) + -- +++ grab + |% + ++ noun token + -- +-- diff --git a/pkg/arvo/sur/gcp.hoon b/pkg/arvo/sur/gcp.hoon new file mode 100644 index 0000000000..268ba4d1e8 --- /dev/null +++ b/pkg/arvo/sur/gcp.hoon @@ -0,0 +1,6 @@ +|% ++$ token + $: access-key=@t + expires-in=@dr + == +-- diff --git a/pkg/arvo/ted/gcp/get-token.hoon b/pkg/arvo/ted/gcp/get-token.hoon new file mode 100644 index 0000000000..3562aaf268 --- /dev/null +++ b/pkg/arvo/ted/gcp/get-token.hoon @@ -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) +-- diff --git a/pkg/arvo/ted/gcp/is-configured.hoon b/pkg/arvo/ted/gcp/is-configured.hoon new file mode 100644 index 0000000000..02b67e60b3 --- /dev/null +++ b/pkg/arvo/ted/gcp/is-configured.hoon @@ -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) +:: +-- diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index d4a06d132a..a8f560f0ba 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -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 + } } } } diff --git a/pkg/interface/package.json b/pkg/interface/package.json index a65578c438..52a3bd555f 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -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", diff --git a/pkg/interface/src/logic/api/gcp.ts b/pkg/interface/src/logic/api/gcp.ts new file mode 100644 index 0000000000..c6b955b22d --- /dev/null +++ b/pkg/interface/src/logic/api/gcp.ts @@ -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 { + 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 + }); + }); + } +}; diff --git a/pkg/interface/src/logic/api/global.ts b/pkg/interface/src/logic/api/global.ts index 8ed02020b4..edf6f0cc5d 100644 --- a/pkg/interface/src/logic/api/global.ts +++ b/pkg/interface/src/logic/api/global.ts @@ -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 { 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); diff --git a/pkg/interface/src/logic/lib/GcpClient.ts b/pkg/interface/src/logic/lib/GcpClient.ts new file mode 100644 index 0000000000..b03c7fd0a6 --- /dev/null +++ b/pkg/interface/src/logic/lib/GcpClient.ts @@ -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); + } +} diff --git a/pkg/interface/src/logic/lib/StorageClient.ts b/pkg/interface/src/logic/lib/StorageClient.ts new file mode 100644 index 0000000000..31e12f8233 --- /dev/null +++ b/pkg/interface/src/logic/lib/StorageClient.ts @@ -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; +}; + +export interface StorageClient { + upload(params: UploadParams): StorageUpload; +}; diff --git a/pkg/interface/src/logic/lib/gcpManager.ts b/pkg/interface/src/logic/lib/gcpManager.ts new file mode 100644 index 0000000000..628bcfc29f --- /dev/null +++ b/pkg/interface/src/logic/lib/gcpManager.ts @@ -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; diff --git a/pkg/interface/src/logic/lib/s3.js b/pkg/interface/src/logic/lib/s3.js deleted file mode 100644 index 64bda11be7..0000000000 --- a/pkg/interface/src/logic/lib/s3.js +++ /dev/null @@ -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(); - } -} - diff --git a/pkg/interface/src/logic/lib/useS3.ts b/pkg/interface/src/logic/lib/useStorage.ts similarity index 55% rename from pkg/interface/src/logic/lib/useS3.ts rename to pkg/interface/src/logic/lib/useStorage.ts index 0717d52f7f..c322f159ff 100644 --- a/pkg/interface/src/logic/lib/useS3.ts +++ b/pkg/interface/src/logic/lib/useStorage.ts @@ -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; uploadDefault: (file: File) => Promise; @@ -11,31 +18,43 @@ export interface IuseS3 { promptUpload: () => Promise; } -const useS3 = (s3: S3State, { accept = '*' } = { accept: '*' }): IuseS3 => { +const useStorage = ({gcp, s3}: StorageState, + { accept = '*' } = { accept: '*' }): IuseStorage => { const [uploading, setUploading] = useState(false); - const client = useRef(null); + const client = useRef(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; \ No newline at end of file +export default useStorage; diff --git a/pkg/interface/src/logic/reducers/gcp-reducer.ts b/pkg/interface/src/logic/reducers/gcp-reducer.ts new file mode 100644 index 0000000000..2900df976d --- /dev/null +++ b/pkg/interface/src/logic/reducers/gcp-reducer.ts @@ -0,0 +1,37 @@ +import _ from 'lodash'; +import {StoreState} from '../store/type'; +import {GcpToken} from '../../types/gcp-state'; + +type GcpState = Pick; + +export default class GcpReducer{ + 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'); + } +} diff --git a/pkg/interface/src/logic/reducers/s3-update.ts b/pkg/interface/src/logic/reducers/s3-update.ts index d05f741b39..ba224cd9bf 100644 --- a/pkg/interface/src/logic/reducers/s3-update.ts +++ b/pkg/interface/src/logic/reducers/s3-update.ts @@ -23,14 +23,14 @@ export default class S3Reducer { 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 { 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; } } } diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index b2ab60c6c8..cf86c00d13 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -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 { launchReducer = new LaunchReducer(); connReducer = new ConnectionReducer(); settingsReducer = new SettingsReducer(); + gcpReducer = new GcpReducer(); pastActions: Record = {} @@ -71,12 +73,15 @@ export default class GlobalStore extends BaseStore { }, 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 { 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); } diff --git a/pkg/interface/src/logic/store/type.ts b/pkg/interface/src/logic/store/type.ts index 96b68f8a2d..662652ac8f 100644 --- a/pkg/interface/src/logic/store/type.ts +++ b/pkg/interface/src/logic/store/type.ts @@ -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; nackedContacts: Set - s3: S3State; + storage: StorageState; graphs: Graphs; graphKeys: Set; diff --git a/pkg/interface/src/types/gcp-state.ts b/pkg/interface/src/types/gcp-state.ts new file mode 100644 index 0000000000..cdbcbc1c97 --- /dev/null +++ b/pkg/interface/src/types/gcp-state.ts @@ -0,0 +1,9 @@ +export interface GcpToken { + accessKey: string; + expiresIn: number; +}; + +export interface GcpState { + configured?: boolean; + token?: GcpToken +}; diff --git a/pkg/interface/src/types/index.ts b/pkg/interface/src/types/index.ts index 5c1d81b0b9..39efb1cef6 100644 --- a/pkg/interface/src/types/index.ts +++ b/pkg/interface/src/types/index.ts @@ -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'; diff --git a/pkg/interface/src/types/storage-state.ts b/pkg/interface/src/types/storage-state.ts new file mode 100644 index 0000000000..61bf612ada --- /dev/null +++ b/pkg/interface/src/types/storage-state.ts @@ -0,0 +1,8 @@ +import {GcpState} from './gcp-state'; +import {S3State} from './s3-update'; + + +export interface StorageState { + gcp: GcpState; + s3: S3State; +}; diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 3e1650dc34..4089ec1eae 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -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(); diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index 6b47a44091..6ba85b3272 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -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} diff --git a/pkg/interface/src/views/apps/chat/components/ChatInput.tsx b/pkg/interface/src/views/apps/chat/components/ChatInput.tsx index 664e4e9ac5..9b6a04b363 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatInput.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatInput.tsx @@ -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 { } } -export default withLocalState(withS3(ChatInput, {accept: 'image/*'}), ['hideAvatars']); +export default withLocalState(withStorage(ChatInput, {accept: 'image/*'}), ['hideAvatars']); diff --git a/pkg/interface/src/views/apps/links/LinkResource.tsx b/pkg/interface/src/views/apps/links/LinkResource.tsx index d85a2d7706..ea45a2ed6d 100644 --- a/pkg/interface/src/views/apps/links/LinkResource.tsx +++ b/pkg/interface/src/views/apps/links/LinkResource.tsx @@ -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 ( { canWrite ? ( - + ) : ( There are no links here yet. You do not have permission to post to this collection. ) @@ -96,7 +97,7 @@ export function LinkWindow(props: LinkWindowProps) { return ( - + diff --git a/pkg/interface/src/views/apps/links/components/LinkSubmit.tsx b/pkg/interface/src/views/apps/links/components/LinkSubmit.tsx index 2eaba45559..67c5f90fe9 100644 --- a/pkg/interface/src/views/apps/links/components/LinkSubmit.tsx +++ b/pkg/interface/src/views/apps/links/components/LinkSubmit.tsx @@ -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; \ No newline at end of file +export default LinkSubmit; diff --git a/pkg/interface/src/views/apps/profile/components/EditProfile.tsx b/pkg/interface/src/views/apps/profile/components/EditProfile.tsx index d43cfbaad2..3d84ee4608 100644 --- a/pkg/interface/src/views/apps/profile/components/EditProfile.tsx +++ b/pkg/interface/src/views/apps/profile/components/EditProfile.tsx @@ -114,15 +114,15 @@ export function EditProfile(props: any) { Description - + - + - + diff --git a/pkg/interface/src/views/apps/profile/components/Profile.tsx b/pkg/interface/src/views/apps/profile/components/Profile.tsx index 0072b591bc..b7cd9b24e5 100644 --- a/pkg/interface/src/views/apps/profile/components/Profile.tsx +++ b/pkg/interface/src/views/apps/profile/components/Profile.tsx @@ -107,7 +107,7 @@ export function Profile(props: any) { ); diff --git a/pkg/interface/src/views/apps/publish/components/EditPost.tsx b/pkg/interface/src/views/apps/publish/components/EditPost.tsx index 0f6ed4dcc0..188a9833e0 100644 --- a/pkg/interface/src/views/apps/publish/components/EditPost.tsx +++ b/pkg/interface/src/views/apps/publish/components/EditPost.tsx @@ -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..." /> diff --git a/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx b/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx index 32f372f5d8..3bafea2b4c 100644 --- a/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx +++ b/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx @@ -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) => { diff --git a/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx b/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx index 750a649784..8712f70860 100644 --- a/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx +++ b/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx @@ -6,7 +6,7 @@ import { MarkdownEditor } from "./MarkdownEditor"; export const MarkdownField = ({ id, - s3, + storage, ...rest }: { id: string } & Parameters[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} /> {error} diff --git a/pkg/interface/src/views/apps/publish/components/NoteForm.tsx b/pkg/interface/src/views/apps/publish/components/NoteForm.tsx index 6fbaaee20a..06b1cc10d0 100644 --- a/pkg/interface/src/views/apps/publish/components/NoteForm.tsx +++ b/pkg/interface/src/views/apps/publish/components/NoteForm.tsx @@ -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; 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 ( @@ -66,7 +66,7 @@ export function PostForm(props: PostFormProps) { type="button">Cancel} - + diff --git a/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx b/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx index 916347038a..7152fe9fc5 100644 --- a/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx +++ b/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx @@ -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) { diff --git a/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx b/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx index a3d1f5474f..18d6c10bfd 100644 --- a/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx +++ b/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx @@ -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} /> ); diff --git a/pkg/interface/src/views/apps/publish/components/new-post.tsx b/pkg/interface/src/views/apps/publish/components/new-post.tsx index 2b0dd366a2..6f5cdbee0e 100644 --- a/pkg/interface/src/views/apps/publish/components/new-post.tsx +++ b/pkg/interface/src/views/apps/publish/components/new-post.tsx @@ -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} /> ); } diff --git a/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx b/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx index 53aed1b3e9..f1a76dae9e 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx @@ -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({ - + ); diff --git a/pkg/interface/src/views/components/ImageInput.tsx b/pkg/interface/src/views/components/ImageInput.tsx index d234462b13..868228f7e5 100644 --- a/pkg/interface/src/views/components/ImageInput.tsx +++ b/pkg/interface/src/views/components/ImageInput.tsx @@ -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[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); diff --git a/pkg/interface/src/views/components/SubmitDragger.tsx b/pkg/interface/src/views/components/SubmitDragger.tsx index 7bece2aafd..b8a98f7103 100644 --- a/pkg/interface/src/views/components/SubmitDragger.tsx +++ b/pkg/interface/src/views/components/SubmitDragger.tsx @@ -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 = () => ( ( ); -export default SubmitDragger; \ No newline at end of file +export default SubmitDragger; diff --git a/pkg/interface/src/views/components/withS3.tsx b/pkg/interface/src/views/components/withS3.tsx deleted file mode 100644 index 5bb9bae475..0000000000 --- a/pkg/interface/src/views/components/withS3.tsx +++ /dev/null @@ -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 ; - }); -}; - -export default withS3; \ No newline at end of file diff --git a/pkg/interface/src/views/components/withStorage.tsx b/pkg/interface/src/views/components/withStorage.tsx new file mode 100644 index 0000000000..a0898d7455 --- /dev/null +++ b/pkg/interface/src/views/components/withStorage.tsx @@ -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 ; + }); +}; + +export default withStorage; diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx index 34fc6f9abf..d97be994b7 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx @@ -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} /> )} {view === "participants" && ( diff --git a/sh/poke-gcp-account-json b/sh/poke-gcp-account-json new file mode 100755 index 0000000000..3c5c24fa05 --- /dev/null +++ b/sh/poke-gcp-account-json @@ -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()