Merge pull request #4484 from urbit/jo/gcp-settings-store

GCP storage support
This commit is contained in:
Jōshin 2021-03-01 18:01:07 -08:00 committed by GitHub
commit b7583bf3e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 800 additions and 175 deletions

15
pkg/arvo/lib/gcp.hoon Normal file
View 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)
~
==
--

View 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
View File

@ -0,0 +1,6 @@
|%
+$ token
$: access-key=@t
expires-in=@dr
==
--

View 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)
--

View 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)
::
--

View File

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

View File

@ -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",

View 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
});
});
}
};

View File

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

View 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);
}
}

View 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;
};

View 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;

View File

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

View File

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

View 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');
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
export interface GcpToken {
accessKey: string;
expiresIn: number;
};
export interface GcpState {
configured?: boolean;
token?: GcpToken
};

View File

@ -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';

View File

@ -0,0 +1,8 @@
import {GcpState} from './gcp-state';
import {S3State} from './s3-update';
export interface StorageState {
gcp: GcpState;
s3: S3State;
};

View File

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

View File

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

View File

@ -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']);

View File

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

View File

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

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

@ -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) {

View File

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

View File

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