将 API 密钥和机密存储在 Google AppScript 用户 属性 中
Storing API keys and secrets in Google AppScript user property
我对 Google AppScript 很陌生,正在尝试编写连接器到自定义 REST API。为此 API 我需要每个用户的 API 密钥(或秘密)。由于在脚本中以纯文本形式存储秘密并不是最好的主意,我想将它存储在 Google PropertyService 中并从那里检索它。像这样:
var userProperties = PropertiesService.getUserProperties();
var apiKey = userProperties.getProperty('MY_SECRET')
但我不明白的是,用户怎么能先存储密钥?我还没有找到用户(在本例中是我)可以查看或编辑属性的任何地方。然后我发现这个很好的 introduction to user properties 可以在脚本容器中创建一个菜单,允许用户手动输入机密。
const API_KEY = 'API_KEY';
var ui = SpreadsheetApp.getUi();
var userProperties = PropertiesService.getUserProperties();
function onOpen(){
ui.createMenu('API Keys')
.addItem('Set API Key', 'userPromptApiKey')
.addItem('Delete API Key', 'deleteApiKey')
.addToUi();
}
function userPromptApiKey(){
var userValue = ui.prompt('API Key ', ui.ButtonSet.OK);
// ToDo: add current key to the prompt
userProperties.setProperty(API_KEY, userValue.getResponseText());
}
function deleteApiKey(){
userProperties.deleteProperty(API_KEY)
}
问题是,我的脚本没有绑定到任何容器(没有电子表格,没有文档)。相反,我想稍后在 Google DataStudio 中使用它。这就是为什么
SpreadsheetApp.getUi();
不起作用。关于如何处理这个问题的任何想法或建议?是否有任何其他推荐的方法来处理这些秘密?
您需要 UI 来获取用户的输入数据。
您可以创建一个Web App来构建一个获取Keys的接口。
此外,如果您正在构建脚本但尚未发布,您可以在发布之前对密钥进行硬编码。
现在,几周后我学到了很多东西。首先,您需要区分 UI 和逻辑脚本。二、是容器绑定脚本还是独立脚本。
容器绑定脚本绑定到Google电子表格、Google文档或任何其他允许用户交互的UI。在这种情况下,您可以访问代码中的 UI 并将自定义菜单添加到 UI,一旦用户单击该菜单,该菜单将调用脚本中的方法。缺点是您需要知道它是电子表格还是文档,因为 UI class 不同。您还需要指示用户使用自定义菜单输入他或她的凭据。网上有个very nice instruction。以下代码片段的灵感来自于该指令。确保为 onOpen 创建触发器。
var ui = SpreadsheetApp.getUi();
var userProperties = PropertiesService.getUserProperties();
const API_KEY = 'api.key';
function onOpen(){
ui.createMenu('Credentials & Authentication')
.addItem('Set API key', 'setKey')
.addItem('Delete API key', 'resetKey')
.addItem('Delete all credentials', 'deleteAll')
.addToUi();
}
function setKey(){
var scriptValue = ui.prompt('Please provide your API key.' , ui.ButtonSet.OK);
userProperties.setProperty(API_KEY, scriptValue.getResponseText());
}
function resetKey(){
userProperties.deleteProperty(API_KEY);
}
function deleteAll(){
userProperties.deleteAllProperties();
}
对于独立脚本,您需要找到任何其他方式连接到UI。在我的情况下,我正在实施 custom connector for Google Data Studio for which there is a very nice example online as well. There is a quite detailed instruction on authentication and an API reference on authentication as well. This custom connector for Kaggle was very helpful as well. It is open-source on the Google Data Studio GitHub。以下演示代码的灵感来自于这些示例。看看 getCredentials
、validateCredentials
、getAuthType
、resetAuth
、isAuthValid
和 setCredentials
.
var cc = DataStudioApp.createCommunityConnector();
const URL_DATA = 'https://www.myverysecretdomain.com/api';
const URL_PING = 'https://www.myverysecretdomain.com/ping';
const AUTH_USER = 'auth.user'
const AUTH_KEY = 'auth.key';
const JSON_TAG = 'user';
String.prototype.format = function() {
// https://coderwall.com/p/flonoa/simple-string-format-in-javascript
a = this;
for (k in arguments) {
a = a.replace("{" + k + "}", arguments[k])
}
return a
}
function httpGet(user, token, url, params) {
try {
// this depends on the URL you are connecting to
var headers = {
'ApiUser': user,
'ApiToken': token,
'User-Agent': 'my super freaky Google Data Studio connector'
};
var options = {
headers: headers
};
if (params && Object.keys(params).length > 0) {
var params_ = [];
for (const [key, value] of Object.entries(params)) {
var value_ = value;
if (Array.isArray(value))
value_ = value.join(',');
params_.push('{0}={1}'.format(key, encodeURIComponent(value_)))
}
var query = params_.join('&');
url = '{0}?{1}'.format(url, query);
}
var response = UrlFetchApp.fetch(url, options);
return {
code: response.getResponseCode(),
json: JSON.parse(response.getContentText())
}
} catch (e) {
throwConnectorError(e);
}
}
function getCredentials() {
var userProperties = PropertiesService.getUserProperties();
return {
username: userProperties.getProperty(AUTH_USER),
token: userProperties.getProperty(AUTH_KEY)
}
}
function validateCredentials(user, token) {
if (!user || !token)
return false;
var response = httpGet(user, token, URL_PING);
if (response.code == 200)
console.log('API key for the user %s successfully validated', user);
else
console.error('API key for the user %s is invalid. Code: %s', user, response.code);
return response;
}
function getAuthType() {
var cc = DataStudioApp.createCommunityConnector();
return cc.newAuthTypeResponse()
.setAuthType(cc.AuthType.USER_TOKEN)
.setHelpUrl('https://www.myverysecretdomain.com/index.html#authentication')
.build();
}
function resetAuth() {
var userProperties = PropertiesService.getUserProperties();
userProperties.deleteProperty(AUTH_USER);
userProperties.deleteProperty(AUTH_KEY);
console.info('Credentials have been reset.');
}
function isAuthValid() {
var credentials = getCredentials()
if (credentials == null) {
console.info('No credentials found.');
return false;
}
var response = validateCredentials(credentials.username, credentials.token);
return (response != null && response.code == 200);
}
function setCredentials(request) {
var credentials = request.userToken;
var response = validateCredentials(credentials.username, credentials.token);
if (response == null || response.code != 200) return { errorCode: 'INVALID_CREDENTIALS' };
var userProperties = PropertiesService.getUserProperties();
userProperties.setProperty(AUTH_USER, credentials.username);
userProperties.setProperty(AUTH_KEY, credentials.token);
console.info('Credentials have been stored');
return {
errorCode: 'NONE'
};
}
function throwConnectorError(text) {
DataStudioApp.createCommunityConnector()
.newUserError()
.setDebugText(text)
.setText(text)
.throwException();
}
function getConfig(request) {
// ToDo: handle request.languageCode for different languages being displayed
console.log(request)
var params = request.configParams;
var config = cc.getConfig();
// ToDo: add your config if necessary
config.setDateRangeRequired(true);
return config.build();
}
function getDimensions() {
var types = cc.FieldType;
return [
{
id:'id',
name:'ID',
type:types.NUMBER
},
{
id:'name',
name:'Name',
isDefault:true,
type:types.TEXT
},
{
id:'email',
name:'Email',
type:types.TEXT
}
];
}
function getMetrics() {
return [];
}
function getFields(request) {
Logger.log(request)
var fields = cc.getFields();
var dimensions = this.getDimensions();
var metrics = this.getMetrics();
dimensions.forEach(dimension => fields.newDimension().setId(dimension.id).setName(dimension.name).setType(dimension.type));
metrics.forEach(metric => fields.newMetric().setId(metric.id).setName(metric.name).setType(metric.type).setAggregation(metric.aggregations));
var defaultDimension = dimensions.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
var defaultMetric = metrics.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
if (defaultDimension)
fields.setDefaultDimension(defaultDimension.id);
if (defaultMetric)
fields.setDefaultMetric(defaultMetric.id);
return fields;
}
function getSchema(request) {
var fields = getFields(request).build();
return { schema: fields };
}
function convertValue(value, id) {
// ToDo: add special conversion if necessary
switch(id) {
default:
// value will be converted automatically
return value[id];
}
}
function entriesToDicts(schema, data, converter, tag) {
return data.map(function(element) {
var entry = element[tag];
var row = {};
schema.forEach(function(field) {
// field has same name in connector and original data source
var id = field.id;
var value = converter(entry, id);
// use UI field ID
row[field.id] = value;
});
return row;
});
}
function dictsToRows(requestedFields, rows) {
return rows.reduce((result, row) => ([...result, {'values': requestedFields.reduce((values, field) => ([...values, row[field]]), [])}]), []);
}
function getParams (request) {
var schema = this.getSchema();
var params;
if (request) {
params = {};
// ToDo: handle pagination={startRow=1.0, rowCount=100.0}
} else {
// preview only
params = {
limit: 20
}
}
return params;
}
function getData(request) {
Logger.log(request)
var credentials = getCredentials()
var schema = getSchema();
var params = getParams(request);
var requestedFields; // fields structured as I want them (see above)
var requestedSchema; // fields structured as Google expects them
if (request) {
// make sure the ordering of the requested fields is kept correct in the resulting data
requestedFields = request.fields.filter(field => !field.forFilterOnly).map(field => field.name);
requestedSchema = getFields(request).forIds(requestedFields);
} else {
// use all fields from schema
requestedFields = schema.map(field => field.id);
requestedSchema = api.getFields(request);
}
var filterPresent = request && request.dimensionsFilters;
//var filter = ...
if (filterPresent) {
// ToDo: apply request filters on API level (before the API call) to minimize data retrieval from API (number of rows) and increase speed
// see https://developers.google.com/datastudio/connector/filters
// filter = ... // initialize filter
// filter.preFilter(params); // low-level API filtering if possible
}
// get HTTP response; e.g. check for HTTT RETURN CODE on response.code if necessary
var response = httpGet(credentials.username, credentials.token, URL_DATA, params);
// get JSON data from HTTP response
var data = response.json;
// convert the full dataset including all fields (the full schema). non-requested fields will be filtered later on
var rows = entriesToDicts(schema, data, convertValue, JSON_TAG);
// match rows against filter (high-level filtering)
//if (filter)
// rows = rows.filter(row => filter.match(row) == true);
// remove non-requested fields
var result = dictsToRows(requestedFields, rows);
console.log('{0} rows received'.format(result.length));
//console.log(result);
return {
schema: requestedSchema.build(),
rows: result,
filtersApplied: filter ? true : false
};
}
如果其中 none 符合您的要求,则按照@kessy 的其他答案中的建议使用 WebApp。
我对 Google AppScript 很陌生,正在尝试编写连接器到自定义 REST API。为此 API 我需要每个用户的 API 密钥(或秘密)。由于在脚本中以纯文本形式存储秘密并不是最好的主意,我想将它存储在 Google PropertyService 中并从那里检索它。像这样:
var userProperties = PropertiesService.getUserProperties();
var apiKey = userProperties.getProperty('MY_SECRET')
但我不明白的是,用户怎么能先存储密钥?我还没有找到用户(在本例中是我)可以查看或编辑属性的任何地方。然后我发现这个很好的 introduction to user properties 可以在脚本容器中创建一个菜单,允许用户手动输入机密。
const API_KEY = 'API_KEY';
var ui = SpreadsheetApp.getUi();
var userProperties = PropertiesService.getUserProperties();
function onOpen(){
ui.createMenu('API Keys')
.addItem('Set API Key', 'userPromptApiKey')
.addItem('Delete API Key', 'deleteApiKey')
.addToUi();
}
function userPromptApiKey(){
var userValue = ui.prompt('API Key ', ui.ButtonSet.OK);
// ToDo: add current key to the prompt
userProperties.setProperty(API_KEY, userValue.getResponseText());
}
function deleteApiKey(){
userProperties.deleteProperty(API_KEY)
}
问题是,我的脚本没有绑定到任何容器(没有电子表格,没有文档)。相反,我想稍后在 Google DataStudio 中使用它。这就是为什么
SpreadsheetApp.getUi();
不起作用。关于如何处理这个问题的任何想法或建议?是否有任何其他推荐的方法来处理这些秘密?
您需要 UI 来获取用户的输入数据。
您可以创建一个Web App来构建一个获取Keys的接口。
此外,如果您正在构建脚本但尚未发布,您可以在发布之前对密钥进行硬编码。
现在,几周后我学到了很多东西。首先,您需要区分 UI 和逻辑脚本。二、是容器绑定脚本还是独立脚本。
容器绑定脚本绑定到Google电子表格、Google文档或任何其他允许用户交互的UI。在这种情况下,您可以访问代码中的 UI 并将自定义菜单添加到 UI,一旦用户单击该菜单,该菜单将调用脚本中的方法。缺点是您需要知道它是电子表格还是文档,因为 UI class 不同。您还需要指示用户使用自定义菜单输入他或她的凭据。网上有个very nice instruction。以下代码片段的灵感来自于该指令。确保为 onOpen 创建触发器。
var ui = SpreadsheetApp.getUi();
var userProperties = PropertiesService.getUserProperties();
const API_KEY = 'api.key';
function onOpen(){
ui.createMenu('Credentials & Authentication')
.addItem('Set API key', 'setKey')
.addItem('Delete API key', 'resetKey')
.addItem('Delete all credentials', 'deleteAll')
.addToUi();
}
function setKey(){
var scriptValue = ui.prompt('Please provide your API key.' , ui.ButtonSet.OK);
userProperties.setProperty(API_KEY, scriptValue.getResponseText());
}
function resetKey(){
userProperties.deleteProperty(API_KEY);
}
function deleteAll(){
userProperties.deleteAllProperties();
}
对于独立脚本,您需要找到任何其他方式连接到UI。在我的情况下,我正在实施 custom connector for Google Data Studio for which there is a very nice example online as well. There is a quite detailed instruction on authentication and an API reference on authentication as well. This custom connector for Kaggle was very helpful as well. It is open-source on the Google Data Studio GitHub。以下演示代码的灵感来自于这些示例。看看 getCredentials
、validateCredentials
、getAuthType
、resetAuth
、isAuthValid
和 setCredentials
.
var cc = DataStudioApp.createCommunityConnector();
const URL_DATA = 'https://www.myverysecretdomain.com/api';
const URL_PING = 'https://www.myverysecretdomain.com/ping';
const AUTH_USER = 'auth.user'
const AUTH_KEY = 'auth.key';
const JSON_TAG = 'user';
String.prototype.format = function() {
// https://coderwall.com/p/flonoa/simple-string-format-in-javascript
a = this;
for (k in arguments) {
a = a.replace("{" + k + "}", arguments[k])
}
return a
}
function httpGet(user, token, url, params) {
try {
// this depends on the URL you are connecting to
var headers = {
'ApiUser': user,
'ApiToken': token,
'User-Agent': 'my super freaky Google Data Studio connector'
};
var options = {
headers: headers
};
if (params && Object.keys(params).length > 0) {
var params_ = [];
for (const [key, value] of Object.entries(params)) {
var value_ = value;
if (Array.isArray(value))
value_ = value.join(',');
params_.push('{0}={1}'.format(key, encodeURIComponent(value_)))
}
var query = params_.join('&');
url = '{0}?{1}'.format(url, query);
}
var response = UrlFetchApp.fetch(url, options);
return {
code: response.getResponseCode(),
json: JSON.parse(response.getContentText())
}
} catch (e) {
throwConnectorError(e);
}
}
function getCredentials() {
var userProperties = PropertiesService.getUserProperties();
return {
username: userProperties.getProperty(AUTH_USER),
token: userProperties.getProperty(AUTH_KEY)
}
}
function validateCredentials(user, token) {
if (!user || !token)
return false;
var response = httpGet(user, token, URL_PING);
if (response.code == 200)
console.log('API key for the user %s successfully validated', user);
else
console.error('API key for the user %s is invalid. Code: %s', user, response.code);
return response;
}
function getAuthType() {
var cc = DataStudioApp.createCommunityConnector();
return cc.newAuthTypeResponse()
.setAuthType(cc.AuthType.USER_TOKEN)
.setHelpUrl('https://www.myverysecretdomain.com/index.html#authentication')
.build();
}
function resetAuth() {
var userProperties = PropertiesService.getUserProperties();
userProperties.deleteProperty(AUTH_USER);
userProperties.deleteProperty(AUTH_KEY);
console.info('Credentials have been reset.');
}
function isAuthValid() {
var credentials = getCredentials()
if (credentials == null) {
console.info('No credentials found.');
return false;
}
var response = validateCredentials(credentials.username, credentials.token);
return (response != null && response.code == 200);
}
function setCredentials(request) {
var credentials = request.userToken;
var response = validateCredentials(credentials.username, credentials.token);
if (response == null || response.code != 200) return { errorCode: 'INVALID_CREDENTIALS' };
var userProperties = PropertiesService.getUserProperties();
userProperties.setProperty(AUTH_USER, credentials.username);
userProperties.setProperty(AUTH_KEY, credentials.token);
console.info('Credentials have been stored');
return {
errorCode: 'NONE'
};
}
function throwConnectorError(text) {
DataStudioApp.createCommunityConnector()
.newUserError()
.setDebugText(text)
.setText(text)
.throwException();
}
function getConfig(request) {
// ToDo: handle request.languageCode for different languages being displayed
console.log(request)
var params = request.configParams;
var config = cc.getConfig();
// ToDo: add your config if necessary
config.setDateRangeRequired(true);
return config.build();
}
function getDimensions() {
var types = cc.FieldType;
return [
{
id:'id',
name:'ID',
type:types.NUMBER
},
{
id:'name',
name:'Name',
isDefault:true,
type:types.TEXT
},
{
id:'email',
name:'Email',
type:types.TEXT
}
];
}
function getMetrics() {
return [];
}
function getFields(request) {
Logger.log(request)
var fields = cc.getFields();
var dimensions = this.getDimensions();
var metrics = this.getMetrics();
dimensions.forEach(dimension => fields.newDimension().setId(dimension.id).setName(dimension.name).setType(dimension.type));
metrics.forEach(metric => fields.newMetric().setId(metric.id).setName(metric.name).setType(metric.type).setAggregation(metric.aggregations));
var defaultDimension = dimensions.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
var defaultMetric = metrics.find(field => field.hasOwnProperty('isDefault') && field.isDefault == true);
if (defaultDimension)
fields.setDefaultDimension(defaultDimension.id);
if (defaultMetric)
fields.setDefaultMetric(defaultMetric.id);
return fields;
}
function getSchema(request) {
var fields = getFields(request).build();
return { schema: fields };
}
function convertValue(value, id) {
// ToDo: add special conversion if necessary
switch(id) {
default:
// value will be converted automatically
return value[id];
}
}
function entriesToDicts(schema, data, converter, tag) {
return data.map(function(element) {
var entry = element[tag];
var row = {};
schema.forEach(function(field) {
// field has same name in connector and original data source
var id = field.id;
var value = converter(entry, id);
// use UI field ID
row[field.id] = value;
});
return row;
});
}
function dictsToRows(requestedFields, rows) {
return rows.reduce((result, row) => ([...result, {'values': requestedFields.reduce((values, field) => ([...values, row[field]]), [])}]), []);
}
function getParams (request) {
var schema = this.getSchema();
var params;
if (request) {
params = {};
// ToDo: handle pagination={startRow=1.0, rowCount=100.0}
} else {
// preview only
params = {
limit: 20
}
}
return params;
}
function getData(request) {
Logger.log(request)
var credentials = getCredentials()
var schema = getSchema();
var params = getParams(request);
var requestedFields; // fields structured as I want them (see above)
var requestedSchema; // fields structured as Google expects them
if (request) {
// make sure the ordering of the requested fields is kept correct in the resulting data
requestedFields = request.fields.filter(field => !field.forFilterOnly).map(field => field.name);
requestedSchema = getFields(request).forIds(requestedFields);
} else {
// use all fields from schema
requestedFields = schema.map(field => field.id);
requestedSchema = api.getFields(request);
}
var filterPresent = request && request.dimensionsFilters;
//var filter = ...
if (filterPresent) {
// ToDo: apply request filters on API level (before the API call) to minimize data retrieval from API (number of rows) and increase speed
// see https://developers.google.com/datastudio/connector/filters
// filter = ... // initialize filter
// filter.preFilter(params); // low-level API filtering if possible
}
// get HTTP response; e.g. check for HTTT RETURN CODE on response.code if necessary
var response = httpGet(credentials.username, credentials.token, URL_DATA, params);
// get JSON data from HTTP response
var data = response.json;
// convert the full dataset including all fields (the full schema). non-requested fields will be filtered later on
var rows = entriesToDicts(schema, data, convertValue, JSON_TAG);
// match rows against filter (high-level filtering)
//if (filter)
// rows = rows.filter(row => filter.match(row) == true);
// remove non-requested fields
var result = dictsToRows(requestedFields, rows);
console.log('{0} rows received'.format(result.length));
//console.log(result);
return {
schema: requestedSchema.build(),
rows: result,
filtersApplied: filter ? true : false
};
}
如果其中 none 符合您的要求,则按照@kessy 的其他答案中的建议使用 WebApp。