Custom google app script doesn't work after copying spreadsheet with google java client - google-apps-script

I have google spreadsheet template with custom add-on which I'm trying to copy
def copyTemplateSpreadsheet(Drive driveService) {
File templateCopy = new File()
templateCopy.setName("excel-template")
def copiedFile = driveService.files().copy(templateSpreadsheetId, templateCopy).execute()
setCorrectPermission(driveService, copiedFile.getId())
copiedFile
}
private void setCorrectPermission(Drive driveService, def fileId) {
Permission newPermission = new Permission();
newPermission.setType("anyone");
newPermission.setRole("writer");
driveService.permissions().create(fileId, newPermission).execute();
}
The problem is that copied spreadsheet has broken add-on (isn't displayed in add-ons menu). There is correct add-on code in script editor but when I try to run any function I get error message
"We're sorry, a server error occurred. Please wait a bit and try again"
Keep in mind that the very same code work well in my template spreadsheet. Even if I delete all the code and leave empty onOpen function the error still appears.
Copying add-ons works well when I do it using regular google drive website (drive.google.com) and also worked when I tried to use google's API Explorer (https://developers.google.com/drive/v3/reference/files/copy#try-it). The problem seems to only when using sdk (at least java one - I haven't tried any other)
Also keep in mind I'm using google service account created as described in this article https://developers.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount
and creating Drive instance with following code
Drive getDriveService() throws GeneralSecurityException, IOException, URISyntaxException {
HttpTransport httpTransport = new NetHttpTransport();
JacksonFactory jsonFactory = new JacksonFactory();
GoogleCredential credential = new GoogleCredential.Builder()
.setTransport(httpTransport)
.setJsonFactory(jsonFactory)
.setServiceAccountId(G_SERVICE_EMAIL)
.setServiceAccountScopes(Arrays.asList(DriveScopes.DRIVE))
.setServiceAccountPrivateKeyFromP12File(PKC_12_FILE)
.build();
Drive service = new Drive.Builder(httpTransport, jsonFactory, null)
.setHttpRequestInitializer(credential)
.build();
return service;
}
not sure if it matters though, especially since everything else seems to work just fine
Any fix ideas? I'm open to any workarounds as long as they work.
Also I would be fine with creating new file and just adding add-on code but it seems like I can't do it with API

I've found an acceptable solution which I described there
How can I create spreadsheet with included gs script by API?
Hope it helps someone ;)

Following the comment in How can I create spreadsheet with included gs script by API?, this is the app Scripts solution for making an authenticated POST:
function sendToHR(url,data){
var forDriveScope = DriveApp.getStorageUsed(); //needed to get Drive Scope requested
var dataToSend = [getName(),getID()];
for(key in data){
dataToSend.push(data[key])
}
var paylod = {
"data" : dataToSend
};
paylod = JSON.stringify(paylod);
var param = {
"method":"POST",
"headers" : {"Accept":"application/json","Authorization": "Bearer " + ScriptApp.getOAuthToken()},
"payload": paylod
};
return UrlFetchApp.fetch(url,param).getContentText();
}
And I have an example in python, that might be a bit more useful to you, in order for the python script to execute as a certien user I downloaded a JSON file with the keys from the projects console -> create credential -> get Key and download the file
def get_service():
global http_auth
global delegated_credentials
scopes = ['https://www.googleapis.com/auth/userinfo.email']
keyfile = os.path.join(CURR_DIR, JSON_FILENAME)
credentials = ServiceAccountCredentials.from_json_keyfile_name(
keyfile, scopes=scopes)
delegated_credentials = credentials.create_delegated(ADMIN_EMAIL)
http_auth = delegated_credentials.authorize(Http())
return build('SERVICE', 'v1', http=http_auth,
discoveryServiceUrl='DISCOVERY API SERVICE')
ADMIN_EMAIL is the actual admin email address and CURR_DIR and JSON_FILENAME are related to the downloaded file in your case I'm guessing you dont need admin rights just download the JSON file from the console of your current project and use your email address. Mine works when using the discovery API but a regular POST should be a bit faster to make

Related

Create apps script service accounts to access Drive, Sheet, and Docs

I am in a similar situation to the OP of this post:
User access request when GAS run as the user
I need to run a web app as an 'active user', allow this user to access Drive, Docs, and Sheets resources, but not having the user direct access to them.
However my knowledge is much less on the subject.
As I understand it, I need to create a service account so that the script running as the 'active user' can access Drive, Sheet, and Docs resources that the active user does not have access to.
I am also looking at other resources as well as Google's documentation, but it's a bit overwhelming.
Can anyone explain the basics for this? Maybe a tutorial (or a link to such) that really inexperienced users can understand? I just need to get started on the right direction.
Thank you in advance!
Impersonation of users using App Script
It should be possible to generate a key and start the process of impersonation and call off the scopes and API.
function getJWT(sub) {
var header = { "alg": "RS256", "typ": "JWT" }
var encodedheader = Utilities.base64EncodeWebSafe(JSON.stringify(header))
var key = "-----BEGIN PRIVATE KEY----- fjsklfjl;sdjfasd -----END PRIVATE KEY-----\n"
var time = Math.floor(new Date().getTime() / 1000)
var claim = {
"iss": "yourserviceaccount#mail-p-any.iam.gserviceaccount.com",
"scope": "https://mail.google.com/",
"aud": "https://oauth2.googleapis.com/token",
"iat": time,
"exp": time + 3600,
"sub": sub[0]
}
var encodedclaim = Utilities.base64EncodeWebSafe(JSON.stringify(claim))
var input = encodedheader + "." + encodedclaim
var signed = Utilities.computeRsaSha256Signature(input, key)
var base64signed = Utilities.base64Encode(signed)
var jwt = encodedheader + "." + encodedclaim + "." + base64signed
return jwt
}
function getAccessToken(user) {
var payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": getJWT(user)
}
var params = {
"method": "POST",
"contentType": "application/x-www-form-urlencoded",
"payload": payload,
"muteHttpExceptions": true
}
var response = UrlFetchApp.fetch("https://oauth2.googleapis.com/token", params)
var output = JSON.parse(response.getContentText())
console.log(output.access_token)
return output.access_token
}
You can also review the library and step by step process on how you can implement it in another way from here:
https://github.com/googleworkspace/apps-script-oauth2
My code sample was based on the sample script from:
https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority
You can also review the other sample code from the references below.
This way you are able to impersonate the user and run or make calls on behalf of the user from your organization without having access to it. This might be where you can start your idea on how to start.
References
https://cloud.google.com/iam/docs/impersonating-service-accounts
https://github.com/googleworkspace/apps-script-oauth2/blob/main/samples/GoogleServiceAccount.gs
I got this to work, for the benefit of those who are the same level in this subject as I am, and in the similar situation. Anyone please expound or correct me if I'm wrong, thanks.
You cannot use the methods to access Drive, Docs, and Sheets in the
same code that runs as the 'active user'.
You have to access these Google services using the equivalent HTTP
API calls of the methods.
The HTTP API calls need a user that would interact with the resources
(because it's being called from publicly from the internet and not
from the script).
You create a service account for this. This acts as the user for the
calls.
I started with Ricardo Jose Velasquez Cruz's response, and found other resources, as I was calling the API from Apps Script.
https://medium.com/geekculture/how-to-use-service-accounts-and-oauth2-in-google-apps-script-99c4bc91dc31
Note that Apps Script requires an OAUTH2 library to connect, not sure why this was not built-in to GAS itself:
https://github.com/googleworkspace/apps-script-oauth2
How to create a service account and use it to access Google Drive (you use the same code to access Docs and Sheet as well, you just need to use the corresponding URL and parameters for the services):
https://www.labnol.org/code/20375-service-accounts-google-apps-script
it's basically the same code as another post I found here:
Google Service Accounts / API - I keep getting the Error: Access not granted or expired. (line 454, file "Service")
Hope this helps :)

Why do I get an empty 200 OK response when uploading a file to a shared Google Drive folder when using a service account?

I am trying to use a Service Account to upload a file into a shared Google Drive folder, using Google Drive API v3 and the .Net client. The service account is added to the folder and "Can organise, add and edit". And if I do a list request I get the folder back (so I know the service account authentication works and that it at least has access to the shared folder).
If I try to upload a file though, I get a 200 response (so no error) but with an empty ResponseBody. And if I list again, the file isn't there. I don't understand what this means or why it is happening and can't find any information about this situation anywhere.
Here's my upload code. [PARENT_ID] is the ID of the shared folder and I've got the content of the CSV file I'm uploading and converted into bytes to put into the stream I've then sent.
using (var driveService = new DriveService(new BaseClientService.Initializer()
{
HttpClientInitializer = googleCredential.CreateScoped(DriveService.Scope.Drive),
ApplicationName = "FunctionApp"
}))
{
File fileMetadata = new File()
{
Name = "FileName",
MimeType = "application/vnd.google-apps.spreadsheet",
Parents = new List<string>() { "[PARENT_ID]" }
};
FilesResource.CreateMediaUpload request;
using (var csvStream = new MemoryStream())
{
csvStream.Write(csvBytes);
request = driveService.Files.Create(
fileMetadata, csvStream, "text/csv");
}
request.Fields = "id";
request.Upload();
var file = request.ResponseBody;
Console.WriteLine("File ID: " + file.Id);
}
The file variable is always null so obviously file.Id throws a NullReferenceException.
I've been staring at this for hours but have not been able to work out what is wrong. Any ideas?
I've tried saving the csv string to a file a then reading it in a file stream (closer to the example at the bottom of https://developers.google.com/drive/api/v3/manage-uploads#import_to_google_docs_types_) but this yields the same result.
Note: I've been able to create a file in the same folder using the "Try this API" tool on https://developers.google.com/drive/api/v3/reference/files/create using the parameters the same as what I pass in the metadata here, but of course that doesn't use a service account and doesn't pass any actual data to the file.
Thanks to #JonSkeet for the solution on GitHub (https://github.com/googleapis/google-api-dotnet-client/issues/1490)
The problem was that the stream was being disposed before the upload (ie after the using). The code change was to not use a using at all.
var csvStream = new MemoryStream(csvBytes);
request = driveService.Files.Create(fileMetadata, csvStream, "text/csv");
var result = request.Upload();
This did a successful upload as expected.
This also didn't use the request.Fields although this could maybe have been left in.

Google Sheets Advanced Google Services URL Shortener 403 Error: Forbidden

I am trying to create a small application in in Google Sheets to sorten URLs on my personal google account. I am using the following code which I found here: Google Sheets Function to get a shortened URL (from Bit.ly or goo.gl etc.)
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("Shorten")
.addItem("Go !!","rangeShort")
.addToUi()
}
function rangeShort() {
var range = SpreadsheetApp.getActiveRange(), data = range.getValues();
var output = [];
for(var i = 0, iLen = data.length; i < iLen; i++) {
//var url = UrlShortener.Url.insert({longUrl: data[i][0]});
var url = UrlShortener.Url.insert({longUrl: 'www.google.com'});
output.push([url.id]);
}
range.offset(0,1).setValues(output);
}
I created a new Google Cloud Project and enabled the URL shortener API in the project and on the Google sheet. The problem is that when I try and run the code I get an err on the line: var url = UrlShortener.Url.insert({longUrl: 'www.google.com'});
error 403, message:forbidden
when i try an execute the rangeShort() function. I have no idea how to fix this. Any ideas would be most appreciated! Thanks!
As it turns out, like Ruben mentioned, Google has moved away from their URL shortener. So after much research ans testing here is the solution:
Step 1
Migrate Google Cloud Project over to Firebase or create a new Firebase Project. See steps here
Step 2
Create a dummy project in order to create a base URL for the shortening. See this youtube video
Step 3
Get the Web API Key from your new Firebase Project (not the app you just created)
Step 4
Check the left side menu on the screen and navigate to Grow->Dynamic Links. You should see the new application you created and a URL at the top of the application. This will become the base of the new shortened URLs.
Step 5
Create the code in Google Apps Script inside the code builder from within Google Sheets. Here is the code that worked for me (I passed the url into this function) (This code is based on the answer found here):
function api_call(url){
var req='https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=[YOUR PROJECT WEB API KEY FROM STEP 3]';
var formData = {
"longDynamicLink": "[YOUR APPLICATION URL BASE FROM STEP 4]?link=" + url,
"suffix" : {
"option" : "UNGUESSABLE"
}
};
var options = {
'method': 'post',
'contentType': 'application/json',
'payload': JSON.stringify(formData)
};
var response = UrlFetchApp.fetch(req, options);
var res=JSON.parse(response);
return res.shortLink;
}
Additional Information
Documentation on Creating Dynamic Links in Firebase
Documentation on using UrlFetchApp() in Google Apps Script
If the url shortener service was used in your project before March 30,2018
Instead of
www.google.com
use
https://www.google.com
Reference: https://developers.google.com/url-shortener/v1/url/insert
but if your project was created on or after March 30, 2018
From https://developers.google.com/url-shortener/v1/
Starting March 30, 2018, we will be turning down support for goo.gl URL shortener. Please see this blog post for detailed timelines and alternatives.
Just to be clear, please note, from the linked blog post:
For developers
Starting May 30, 2018, only projects that have accessed URL Shortener
APIs before today can create short links.
I can attest to #alutz's answer here with a small addition/correction to their code.
Use encodeURIcomponent() for the input url while assigning it to the Long Dynamic Link in case you have more than one custom parameters.
"longDynamicLink": "[YOUR APPLICATION URL BASE FROM STEP 4]?link=" + encodeURIcomponent(url),
This allowed me to pass in multiple arguments for my telegram bot like chat_id, text and parse_mode.

Calling a Google Apps Script web app with access token

I need to execute a GAS service on behalf of a user that is logged to my system. So I have her/his access token. I would like somehow to transfer the token to the web app and without having to authorize again the user to use it for some activities. Can this be accomplished? Thank you.
EDIT: I think I didn't explain right what I try to accomplish. Here is the work flow I try to achieve:
We authorize a user visiting our website using OAuth2 and Google;
We get hold of her/his access token that Google returns;
There is a Google Apps Script web app that is executed as the user running the web app;
We want to call this app (3) by providing the access token (2) so Google not to ask again for authorization;
Actually, we want to call this app (3) not by redirecting the user to it but by calling it as a web service.
Thanks
Martin's answer worked for me in the end, but when I was making a prototype there was a major hurdle.
I needed to add the following scope manually, as the "automatic scope detection system" of google apps script did not ask for it: "https://www.googleapis.com/auth/drive.readonly". This resulted in UrlFetchApp.fetch always giving 401 with additional information I did not understand. Logging this additional information would show html, including the following string
Sorry, unable to open the file at this time.</p><p> Please check the address and try again.
I still don't really understand why "https://www.googleapis.com/auth/drive.readonly" would be necessary. It may have to do with the fact that we can use the /dev url, but who may use the /dev url is managed is checked using the drive permissions of the script file.
That said, the following setup then works for me (it also works with doGet etc, but I chose doPost). I chose to list the minimally needed scopes explicitly in the manifest file, but you can also make sure the calling script will ask for permissions to access drive in different ways. We have two google apps script projects, Caller and WebApp.
In the manifest file of Caller, i.e. appsscript.json
{
...
"oauthScopes":
[
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/script.external_request"]
}
In Code.gs of Caller
function controlCallSimpleService(){
var webAppUrl ='https://script.google.com/a/DOMAIN/macros/s/id123123123/exec';
// var webAppUrl =
// 'https://script.google.com/a/DOMAIN/macros/s/id1212121212/dev'
var token = ScriptApp.getOAuthToken();
var options = {
'method' : 'post'
, 'headers': {'Authorization': 'Bearer '+ token}
, muteHttpExceptions: true
};
var response = UrlFetchApp.fetch(webAppUrl, options);
Logger.log(response.getContentText());
}
In Code.gs of WebApp (the web app being called)
function doPost(event){
return ContentService.createTextOutput("Hello World");
}
The hard answer is NO you can't use the built-in services of Apps Script with a service token. But if you already have the token for a user generated by a service account, access to the users data is pretty similar to any other language. All calls would be to the REST interface of the service your token is scoped for.
Take this small script for example. It will build a list of all the user's folders and return them as JSON:
function doGet(e){
var token = e.parameter.token;
var folderArray = [];
var pageToken = "";
var query = encodeURIComponent("mimeType = 'application/vnd.google-apps.folder'");
var params = {method:"GET",
contentType:'application/json',
headers:{Authorization:"Bearer "+token},
muteHttpExceptions:true
};
var url = "https://www.googleapis.com/drive/v2/files?q="+query;
do{
var results = UrlFetchApp.fetch(url,params);
if(results.getResponseCode() != 200){
Logger.log(results);
break;
}
var folders = JSON.parse(results.getContentText());
url = "https://www.googleapis.com/drive/v2/files?q="+query;
for(var i in folders.items){
folderArray.push({"name":folders.items[i].title, "id":folders.items[i].id})
}
pageToken = folders.nextPageToken;
url += "&pageToken="+encodeURIComponent(pageToken);
}while(pageToken != undefined)
var folderObj = {};
folderObj["folders"] = folderArray;
return ContentService.createTextOutput(JSON.stringify(folderObj)).setMimeType(ContentService.MimeType.JSON);
}
You do miss out on a lot of the convenience that makes Apps Script so powerful, mainly the built in services, but all functionality is available through the Google REST APIs.
I found a way! Just include the following header in the request:
Authorization: Bearer <user's_access_token>

Use Google Script's Web App as Webhook to receive Push Notification directly

My Goal: Changes in Google Drive => Push Notification to https://script.google.com/a/macros/my-domain/... => App is pushed to take action.
I don't want to setup an middle Webhook agent for receiving notification. Instead, let the Web App (by Google Script) to receive it and be pushed directly.
Since the relevant function is quite undocumented (just here: https://developers.google.com/drive/web/push) , below is the code I tried but failure.
1. Is above idea feasible??
2. My code doPost(R) seems cannot receive notification (R parameter) properly. Anyway, no response after I change the Google Drive. Any problem? (I have tried to log the input parameter R so as to see its real structure and decide if the parameter Obj for OAuth is the same as normal Drive App, but error occur before log)
function SetWatchByOnce(){
var Channel = {
'address': 'https://script.google.com/a/macros/my-domain/.../exec',
'type': 'web_hook',
'id': 'my-UUID'
};
var Result = Drive.Changes.watch(Channel);
...
}
function doPost(R) {
var SysEmail = "My Email";
MailApp.sendEmail(SysEmail, 'Testing ', 'Successfully to received Push Notification');
var Response = JSON.parse(R.parameters);
if (Response.kind == "drive#add") {
var FileId = Response.fileId;
MyFile = DriveApp.getFolderById(FileId);
...
}
}
function doGet(e) {
var HTMLToOutput;
var SysEmail = "My Email";
if (e.parameters.kind) {
//I think this part is not needed, since Push Notification by Drive is via Post, not Get. I should use onPost() to receive it. Right?
} else if (e.parameters.code) {
getAndStoreAccessToken(e.parameters.code);
HTMLToOutput = '<html><h1>App is successfully installed.</h1></html>';
} else { //we are starting from scratch or resetting
HTMLToOutput = "<html><h1>Install this App now...!</h1><a href='" + getURLForAuthorization() + "'>click here to start</a></html>";
}
return HtmlService.createHtmlOutput(HTMLToOutput);
}
....
Cloud Functions HTTP trigger(s) might also be an option ...
(which not yet existed at time of this question). this just requires setting the trigger URL as the notification URL, in the Google Drive settings - and adding some NodeJS code for the trigger; whatever it shall do. one can eg. send emails and/or FCM push notifications alike that. that trigger could also be triggered from App Script, with UrlFetchApp and there is the App Script API. one can have several triggers, which are performing different tasks (App Script is only one possibilty).
Cicada,
We have done similar functions to receive webhooks/API calls many times. Notes:
to get R, you need: var Response = R.parameters and then you can do Response.kind, Response.id, etc.
Logger will not work with doGet() and doPost(). I set it up a write to spreadsheet -- before any serious code. That way I know if it is getting triggered.