Chrome 83でGASで作った直リンクからダウンロードできない

数日前、とある方からメールを頂き、以下のような問題が現在進行系で発生しているのを確認しました。

Chrome Version 83.xにバージョンアップしたら、Google Apps Scriptのダイアログで表示してるリンクをクリックしてもファイルがダウンロードできない」という現象。

調べてみると既にいろいろな場所で報告があがっているのを確認しました。かなり広範囲に影響のある問題だと判明しています。

※Google SitesにGASアプリ埋め込んでリンクをクリックしても動かない等の現象もコレが原因。

※2020年7月30日、この問題はバグフィックスされて無事にこれまでの手法でダウンロードできるようになりました

今回の現象の詳細

現象の概要

下記のスクリーンショットのようにPDF化したものをクリックすると、Google DriveのPDFへの直リンクから本来ダウンロードできるはずが「右クリックして、新しいウィンドウで表示」をしてあげないと、ダウンロードがなされない。

また、スクリプトで新タブで表示するように仕込んでも表示されず、Enterキーで改めてURLを叩かないと実行されない現象が出ている。今回のファイルはこちらになります。

図:リンクをクリックしても反応無し

ソースコード

上記スクリーンショットはこのファイルのメニューから「PDF化新方式」⇒ダウンロードするを実行すると本来はPDFがダウンロードできなければならない。

GAS側コード

//PDFをPOST通信で生成する(直接ダウンロード)
function exportPDF(ssID,source,options,format){
  var filename = "temp";
  var dt=new Date();
  var d=encodeDate(dt.getFullYear(),dt.getMonth(),dt.getDate(),dt.getHours(),dt.getMinutes(),dt.getSeconds());
  var pc=[null,null,null,null,null,null,null,null,null,0,
          source,
          10000000,null,null,null,null,null,null,null,null,null,null,null,null,null,null,
          d,
          null,null,
          options,
          format,
          null,0,null,0];
  
  var folder=null;
  var parents = DriveApp.getFileById(ssID).getParents();
  if (parents.hasNext())folder = parents.next();
  else folder = DriveApp.getRootFolder();
  
  var options = {
    "method": "post",
    "payload": "a=true&pc="+JSON.stringify(pc)+"&gf=[]",
    "headers": 
      {
        Authorization: 'Bearer ' + ScriptApp.getOAuthToken()
      },
    'muteHttpExceptions': true
  }
  
  var theBlob = UrlFetchApp.fetch("https://docs.google.com/spreadsheets/d/"+ssID+"/pdf?id="+ssID, options).getBlob();
  var fileid = folder.createFile(theBlob).setName(filename+".pdf").getId();
  
  var directlink = "https://drive.google.com/uc?export=download&id=" + fileid;
  
  var output = HtmlService.createTemplateFromFile('autodownload').evaluate().getContent();
  var html =  HtmlService.createTemplate(output + "<script>\n" + "doIt( " + JSON.stringify(directlink) + ");\n</script>")
              .evaluate()
              .setSandboxMode(HtmlService.SandboxMode.IFRAME) 
              .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
              .setWidth(200)
              .setHeight(100);
              
  //ダイアログを表示する
  SpreadsheetApp.getUi().showModalDialog(html, "Save To PDF"); 
}

HTML側コード

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      //HTMLオープン時に自動的に実行される
      function doIt(data){
        //受け取ったデータを元にclickmanエリアにダウンロード用のリンクを生成
        var html = "<a href='" + data + "'>downloadリンク</a>"
        document.getElementById("clickman").innerHTML = html;
      }
    </script>
  </head>
  <body>
    <div id="clickman"></div>
  </body>
</html>
  • https://drive.google.com/uc?export=download&id=といったファイル直リンクであっても動作しません。

Issue Trackerによると・・・

Google Issue Trackerの情報によると、以下のように要約できます。

  • Chrome 81.xでは問題なく動作する
  • FirefoxやSafariでは問題なく動作する
  • Chrome 83.xからセキュリティ強化された結果、iframeのsandboxに「allow-downloads」が付いていない場合ダウンロードが実行されない
  • 同様にスクリプトにて、URLをHTML側でwindow.openで開いてもタブが開かれるだけでURLは実行されない(手動でそのURLをEnterキーで実行しなければならない)
  • 本来、.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)を付けることで、sandboxにallow-downloadsがつくべきなのに、ついていない為、動作しない
  • リンクを右クリック⇒名前を付けてリンク先を保存は機能する(唯一の回避策
  • GASのWebアプリはすべてiFrameのsandbox内で動作してるので、このようなトラブルに遭っている
  • trackerの返信に「I have reported the issue internally to add "allow-downloads" in ``HtmlService.XFrameOptionsMode.ALLOWALL`` so the downloads are allowed inside a GAS web app.」とあるので、次回以降の改修まで待たなければならない。
  • GASだけでなく、Teamsアプリ内でのファイルのダウンロードでも動作しなくなってる
  • jQueryを使ってリンクを自動でクリックするようにしても、結果は同じ。target属性で_blankであっても同様です。
  • Google Sitesに埋め込みで直接JavaScriptで記述しても、アレもiframeのsandboxなので同様の現象が発生します。
  • エラーとしては以下のようなコードがChrome Developer Toolsのコンソールに残ったりします。

Download is disallowed. The frame initiating or instantiating the download is sandboxed, but the flag ‘allow-downloads’ is not set. See

もしくは

Unsafe JavaScript attempt to initiate navigation for frame with origin 'https://docs.google.com' from frame with URL 'https://n-aptkcbokyqzm7ogk27okuflxr3vmcyzizasmmxq-1lu-script.googleusercontent.com/userCodeAppPanel'. The frame attempting navigation of the top-level window is sandboxed, but the flag of 'allow-top-navigation' or 'allow-top-navigation-by-user-activation' is not set.

回避策

結論

現在、有効な回避策はありません。window.openを利用した自動ダウンロードは実行されませんし、ファイルへの直リンクを表示するようにしても、クリックしてダウンロードが実行されません。

唯一の代替案はリンクを右クリックで名前をつけて保存を手動で実行するのみ。Chrome81の方はアップデートを実行しないほうが良いでしょう(ただ、Chromeはサイレントアップデートで自動で最新になったりするので回避が難しい)

直接開くURLを例え短縮URLや転送サービスにしてもURLが開かれる事はなくブロックされてしまうほど強固です。ただしダウンロードを伴わないURLをポップアップで表示する分には問題なく表示されます。

バグフィックス後の対策

Google Sitesにて、GASのガジェット内のリンクをクリックしても、Developer Toolにて「Unsafe JavaScript attempt to initiate navigation for frame with URL」というエラーが出てダウンロード出来ないケースが今回のケースに該当します。

FireFoxなどでは起きない現象で、Chromeにて発生します。この場合、以下の2点に注意してコードを修正します。

  • setXFrameOptionsModeをつけないイントラ内のGoogle Sitesの場合は、リンクにtarget="_blank"属性をつけることで、新しいタブで開かせて、ダウンロードを実行できます。。
  • 同様に、target属性をつけない場合には、setXFrameOptionsModeをつけて上げれば、同様にダウンロードさせることが出来るようになりました。

トリッキーな回避策

ダウンロードを伴わないURLのオープンならば問題はないので、以下のような方法を実行してみた所うまく動作した。このトリッキーな方法を実現したサンプルスプレッドシートはこちら。余計なコード削ってないので、以下のexportPDFだけに注目してください。

  1. どこかHTMLを配置でき直接オープンできるサイトにindex.htmlファイルを配置する
  2. GAS側からsave pdfのダイアログで自動で開くサイトを1.のindex.htmlとする。この時?id=ファイルのIDとして生成したPDFファイルのIDをつなげたURLを開くようにする
  3. GASを実行すると2.のURLが自動でオープンされる。そしてそこにはdownloadリンクが表示されるので、クリックしてもらう(おそらくjQueryなどでクリックさせるようにしても動くんじゃないかな?)
  4. クリックしたら2.を閉じるスクリプトをindex.html側に入れておくのも良いかもしれない。

この時のソースは以下の通り

GAS側

function exportPDF(ssID,source,options,format){
  var filename = "temp";
  var dt=new Date();
  var d=encodeDate(dt.getFullYear(),dt.getMonth(),dt.getDate(),dt.getHours(),dt.getMinutes(),dt.getSeconds());
  var pc=[null,null,null,null,null,null,null,null,null,0,
          source,
          10000000,null,null,null,null,null,null,null,null,null,null,null,null,null,null,
          d,
          null,null,
          options,
          format,
          null,0,null,0];
  
  var folder=null;
  var parents = DriveApp.getFileById(ssID).getParents();
  if (parents.hasNext())folder = parents.next();
  else folder = DriveApp.getRootFolder();
  
  var options = {
    "method": "post",
    "payload": "a=true&pc="+JSON.stringify(pc)+"&gf=[]",
    "headers": 
      {
        Authorization: 'Bearer ' + ScriptApp.getOAuthToken()
      },
    'muteHttpExceptions': true
  }
  
  var theBlob = UrlFetchApp.fetch("https://docs.google.com/spreadsheets/d/"+ssID+"/pdf?id="+ssID, options).getBlob();
  var fileid = folder.createFile(theBlob).setName(filename+".pdf").getId();

  var js = " \
    <script> \
      window.open('https://officeforest.org/testman/index.html?id="+fileid+"'); \
      google.script.host.close(); \
    </script> \
  ";
              
  var html = HtmlService.createHtmlOutput(js)
    .setHeight(10)
    .setWidth(100);
  SpreadsheetApp.getUi().showModalDialog(html, "Save To PDF"); 
}
  • 変数jsに配置したindex.htmlとパラメータとしてfileidを渡す文字列をつなげたURLを自動オープンさせるようにする
  • 開くとダイアログは自動で閉じて、新規タブで対象のURLがパラメータ付きで開かれる

配置するHTML側

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
    <script>
      //HTMLオープン時に自動的に実行される
		function getParam(){
			//URLパラメータを受け取る
			var url   = location.href
			parameters    = url.split("?")
			params   = parameters[1].split("&")
			var fileid = params[0].split("=")
			

			//IDを元にURLを組み立てる
			var fileurl = "https://drive.google.com/uc?export=download&id=" + fileid[1];
			
			//リンクを挿入する
			var link = document.createElement('a');
			link.href=fileurl;
			link.target="_blank"
			link.download = "temp.pdf";  //ダウンロードするファイル名を指定
			document.body.appendChild(link);
      		
			//自動でリンクをクリックする
			link.click();

			//3秒後に自身のwindowをcloseする
			return new Promise((resolve, reject) => {
  				setTimeout(()=>{
      				window.open('about:blank', '_self').close();
      				resolve();
    			}, 3000);
  			});
		}
	  
		jQuery(function(){
			//URLからパラメータを取り出す
			const idx = getParam();
		})
    </script>
  </head>
  <body>
  		
  </body>
</html>
  1. 呼び出された側のindex.htmlは起動時にgetParamを起動する
  2. getParamは現在のURLからid=以下のファイルのIDをこれで取得できる。
  3. element生成して、それに対して、download用のリンクを生成する。
  4. このdownloadリンクをユーザがクリックされるとGoogle Driveにダイレクト生成されたファイルが呼び出されてダウンロードが可能になる。
  5. jQueryでこのリンクを直接クリックさせるような動作を付け加えたり、クリック後に自身を閉じるようなスクリプトがあれば自動ダウンロードが可能になるのではないか?
  6. 今回はlink.clickで自動でリンクをクリックさせて直リンクを更に新しいタブで開いています。
  7. setTimeoutにて3秒後に自身のindex.htmlを閉じさせています。6.のタブはダウンロードが始まると自動で閉じてくれます。
  8. この方法はポップアップの手法なのでChrome側で予めポップアップを許可しておく必要があります。

なお、index.htmlはUTF-8 BOM付きでないと呼び出した時に文字化けするので注意。

付記:HTML Serviceのオカシナ挙動

オカシナ結果が出るコード

お問い合わせにあった問題の解決策を探索中との事で頂いた中で、HTML Serviceのオカシナ挙動を見つけました。PDF生成リンクを作成しui.showDialogでHTMLタグを作り表示しクリックすると、生成したリンクの後半部分がオカシナ文字列に変換されるバグです。まだこの件はIssue Trackerにも報告はないようです。この問題のサンプルファイルはこちらになります。

function download2() {
  //問い合わせダイアログ
  var ui = SpreadsheetApp.getUi();
  var result = ui.alert("PDF生成しますか?", ui.ButtonSet.YES_NO_CANCEL);

  //処理の分岐
  switch(result){
    case ui.Button.YES:
      //次の処理へ進む
      break;
    case ui.Button.NO:
      return 0;
      break;
    case ui.Button.CANCEL:
      ui.alert("処理をキャンセルしました。");
      return 0;
     break;
  } 

  //各種パラメータ取得
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var range = encodeURIComponent("A1:AH40");
  var sheetID = ss.getSheetByName("パッションフルーツ").getSheetId();
  var key = ss.getId();
  
  //URL組み立て
  var url = "https://docs.google.com/spreadsheets/d/" + key + "/export?gid=" + sheetID
  + "&format=pdf&portrait=true&size=A4&gridlines=false&fitw=true"
  + "&scale=3"
  + "&top_margin=0.30&bottom_margin=0.30&left_margin=0.43&right_margin=0.00"
  + "&range=" + range;
  
  //HTML生成
  var html = "<p><a href='" + url + "' target='blank'>ここをクリックでPCにダウンロードできます</a></p>";
  
  var htmlOutput = HtmlService.createHtmlOutput(html)
                  .setSandboxMode(HtmlService.SandboxMode.IFRAME)
                  .setWidth(400)
                  .setHeight(100);
  
  //ダイアログ表示
  ui.showModelessDialog(htmlOutput, 'PDFダウンロード用URL作成完了');
}

上記のコードの時、ダイアログに表示されるリンクをクリックしても、「現在、ファイルを開くことができません。」と出てしまう。理由はURLの後半部分がオカシナ文字列に置き換えられてしまっている。以前は起きていなかった問題だと思われるのですが、URLを見てみると例えばscale=3が以下のようなオカシナ文字列に置き換わってしまっています。GAS側の問題なのか?それともChrome83の問題なのか。この問題はV8が有効であろうと無効であろうと発生しています。

図:URLがオカシナ文字列に置き換わっている

以下のようにHTML側でURLを組み立てた場合には正常に動作するので、createHtmlOutputではなくcreateHtmlOutputFromFileを使ってHTMLテンプレートを使った手法であれば問題なく表示が可能です。

修正したコード

GAS側コード
function download() {
  var ui = SpreadsheetApp.getUi();
  var result = ui.alert("PDF生成しますか?", ui.ButtonSet.YES_NO_CANCEL);

  //処理の分岐
  switch(result){
    case ui.Button.YES:
      //次の処理へ進む
      break;
    case ui.Button.NO:
      return 0;
      break;
    case ui.Button.CANCEL:
      ui.alert("処理をキャンセルしました。");
      return 0;
     break;
  } 
  
  //ダイアログを表示する
  var html = HtmlService.createHtmlOutputFromFile('download').setHeight(400).setWidth(500);
  ui.showModelessDialog(html, "PDF出力");
}

function getparam(){
  var array = [];
  
  //パラメータ取得
  var ss = SpreadsheetApp.getActiveSpreadsheet()
  var range = encodeURIComponent("A1:AH40");
  var sheetID = ss.getSheetByName("パッションフルーツ").getSheetId();
  var key = ss.getId();
  
  //配列にpush
  array.push(range);
  array.push(sheetID);
  array.push(key);
  
  //html側へreturn
  return JSON.stringify(array);
}
  • 通常通りのHTMLファイルを用意し、HTML側からgetparamを呼び出して値を返してあげているコードになります。
HTML側コード
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      //HTMLオープン時に自動的に実行される
      google.script.run.withSuccessHandler(onSuccess).getparam();
      
      //GAS側からパラメータ取得
      function onSuccess(data){
        var json = JSON.parse(data);
        
        //URL組み立て
        var url = "https://docs.google.com/spreadsheets/d/" + json[2] + "/export?gid=" + json[1]
          + "&format=pdf&portrait=true&size=A4&gridlines=false&fitw=true"
          + "&scale=3"
          + "&top_margin=0.30&bottom_margin=0.30&left_margin=0.43&right_margin=0.00"
          + "&range=" + json[0];
        
        //HTML作成
        var html = "<p><a href='" + url + "' target='blank'>ここをクリックでPCにダウンロードできます</a></p>"
      
        //URLをclickmanに挿入
        document.getElementById("clickman").innerHTML = html;
      }
    </script>
  </head>
  <body>
    <div id="clickman"></div>
  </body>
</html>
  • 起動時にgoogle.script.run.withSuccessHandlerにてGAS側のgetparamを呼び出してパラメータを受け取る
  • 受け取ったパラメータでHTML側でURLを組み立てる
  • URLと生成したHTMLコードをID:clickmanに挿入する

ただしこの方法でも、現状はURLは正しく表示されるのですが、Chrome83の問題で新しいタブで開かれてもURLは実行されないので、Ctrlキー押しながらクリックするか?URLを手動でEnter実行しないとファイルはダウンロードされないのは同じです(Chrome83の問題が解決すればこのコードでPDFがダウンロードされるはずです)

関連リンク