Googleドライブで公開期限・パスワードを設定可能な簡易ファイル転送サイトを作る

* ペイン
    * 月次業務で毎月ほぼ同じ相手先にメールを送っているが、添付ファイルにパスワードをかけるのが面倒
* →そもそも別のURLからダウンロードするようにして、そちらにパスワードをかければいいのでは？

ということで、今回Google Apps Script の Webアプリ機能を使ってファイル転送サイトを作ってみます。なおアップロードするのは社員（つまりG Suiteのドメイン内の人）だけです。

スクリプトのソースはこちら。
https://github.com/kuinaein/watasu

画面はこんな感じ。
![ss_2018-07-08_03-03-27.jpg](https://qiita-image-store.s3.amazonaws.com/0/207347/1826451d-5ecb-6998-d674-5a0f898a1003.jpeg)

# Google Apps Script の Webアプリ機能の制限
公式のガイドはこのあたり。
https://developers.google.com/apps-script/guides/web

別途サーバー契約しなくてもWebアプリを作れるのは便利なのですが、結構制限が多いです。ざっと見ただけでも次のような制限があります。

* URLのパスパラメータを一切取れない。複数の画面を持つアプリを作りたかったら、GET/POSTパラメータで振り分けるかSPAにするしかない。
* Apps Scriptから直接返せるレスポンスはテキストのみ。バイナリは（直接には）返せない。
* リモートホストやユーザーエージェント（ブラウザ）といったサーバ変数をほとんど取得できない。知りたかったらフロントエンド側でリクエストパラメータとして仕込む必要がある。
* スクリプト（スクリプトの紐付いたドキュメント）のオーナーでないとWeb公開設定を変えられない。
* Apps Script標準では暗号論的疑似乱数を生成するメソッドが用意されていない。

# ドライブとスプレッドシートの準備
フォルダ構造は次のようにします。

+ ワタス君フォルダ
    + ワタス君 （スプレッドシート）
    + ファイル置き場
        + アップロードファイル

スプレッドシートの内容は次のようにします。（[サンプル](https://github.com/kuinaein/watasu/blob/master/example.csv)）

| 宛名 | ファイル名 | 宛先 | パスコード | 公開停止日 | 状態 |
|---|---|---|---|---|---|
| (メールの宛名) | (ドライブ上のファイル名) | (メールアドレス) | (自動設定) | (日付) | (公開準備完了 / 公開中 / 公開終了) |

このスプレッドシートにスクリプトを記述していきます。

# Webアプリ機能の有効化
スクリプトエディタのメニューより公開＞Webアプリケーションとして導入を実行することで公開できます。ただしスクリプトのオーナーのみ。

ここでアプリケーションにアクセスできるユーザーを「全員（匿名ユーザーを含む）」とすることで、社外からアクセスできるようになります。

なおテスト画面（URL末尾`/dev`）には最新のソースが反映されますが、本番の画面（URL末尾`/exec`）は「ウェブアプリケーションとして導入」でプロジェクトバージョンを新規作成としない限り更新されないので注意が必要です。

# ダウンロード確認画面
ユーザーが画面にアクセスすると、スクリプトの`doGet()`（POSTリクエストのときは`doPost()`）が呼び出されるのでまずはそのメソッドを記述します。

Apps Script側は後述のHTMLテンプレートにリクエストパラメータを渡すだけです。このときfileIdと一緒にパスワードも渡してしまいます。わざわざ人の手で打ち込ませる意味もないので。

```javascript
function doGet(req) {
  var tmpl = HtmlService.createTemplateFromFile('index.html');
  tmpl.fileId = req.parameter['fileId'] || '';
  tmpl.pwd = req.parameter['pwd'] || '';
  return tmpl.evaluate();
}
```

次にスクリプトエディタのファイル＞新規作成＞HTML ファイルを選択して、`index.html`を作成します。

```html:index.html
<form target="_top" method="POST" action="<?!= ScriptApp.getService().getUrl() ?>">
<input type="hidden" name="fileId" value="<?= fileId ?>" />
<input id="remoteIp" type="hidden" name="remoteIp" value="" /> 
<input id="browser" type="hidden" name="browser" value="" /> 
<table class="table"><tbody>
  <tr><th>パスワード</th><td><input type="password" name="pwd" value="<?= pwd ?>" required /></td></tr>
  <tr><th> </th><td><button class="btn btn-primary" type="submit">ダウンロード</button></tr>
</tbody></table>

<!-- 中略 -->

<script type="text/javascript" src="https://www.l2.io/ip.js?var=remoteIp"></script>
<script>
(function(){
'use strict';

document.getElementById('remoteIp').value = remoteIp;

const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.indexOf('msie') != -1 ||
userAgent.indexOf('trident') != -1 ||
userAgent.indexOf('edge') != -1) {
document.getElementById('browser').value = 'ms-family';
}

})();
</script>
```

テンプレート中で`<?= 変数名 ?>`とすることでApps Scriptから渡された変数を参照できます。その他`<? if (...) { ?>`、`<? } else {?>`等の制御構造も利用可能です。

さて、App ScriptのHTML中で自分自身にフォームリクエストを送らせたりリンクを貼る場合は注意が必要で、`target="_top"`を指定しないと同一オリジンポリシーに引っかかって動作しません。これはsandbox属性付きのインラインフレーム内に表示されるためです。

また、Apps Script側でリモートホストとUAを取得できないので、フロントエンド側でその情報を取得しています。とはいえフロントエンドからは自分自身（＝アクセスユーザー）のグローバルIPは分からないというジレンマ。ここでは[L2.IO](http://l2.io/)というサービスを利用していますが、可能であれば自社内でIPアドレスだけを返すAPIサーバ等を用意したほうが良いでしょう。

# ダウンロード実行画面
## ファイル情報の取得と公開期限・パスワードのチェック
Webアプリでも`SpreadsheetApp.getActiveSpreadsheet()`からスクリプトの紐付いたスプレッドシートを取得できるので、そこからデータを引っ張ってきます。
今回は単純に行番号をファイルIDにしているので、ファイル情報はその行を引いてくるだけです。

ちなみにApps Script内でもGoogle APIの呼び出しはかなりオーバーヘッドが大きいです。ここでは6セル分のデータしか取得していないのでどちらでも良いのですが、大量のセルのデータを引っ張ってくるときは1セルずつ`getValue()`で取ってくるのは悪手。

```javascript
function doPost(req) {

  // ...

  var fileId = req.parameter.fileId;

    // ...

    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('トップ');
    var row = sheet.getRange('A' + fileId + ':F' + fileId);
    var vals = row.getValues()[0];
    var passcode = vals[3];
    var fileDesc = {
      filename: filename = vals[1],
      timeLimit: vals[4],
      publishStatus: vals[5]
    }

    // ...

    if (passcode !== req.parameter.pwd) {
      reportDownloadFailed('パスワード不一致', reqArgs);
      return ContentService.createTextOutput('パスワードが違います');
    }
    if (new Date().getTime() > fileDesc.timeLimit.getTime() ||
        '公開中' !== fileDesc.publishStatus) {
      reportDownloadFailed('公開期間終了', reqArgs);
      return ContentService.createTextOutput('公開期間を過ぎています');
    }

```

## ダウンロードファイルの読み出し
あらかじめファイル置き場のIDをスクリプトにハードコードしておいて、指定されたファイル名のものを読み出すだけです……が、Googleドライブには下記のクセがあるので一応それを考慮する必要があります。

* ゴミ箱のファイルは、内部的には **元のフォルダに入ったまま** で削除済み属性がついているだけ。無視する必要がある。
* 同じフォルダに（バージョン管理とは関係なく）同名のファイルを複数置ける。

```javascript
var STORAGE_FOLDER_ID = 'xxxxxx';

// ...

var folder = DriveApp.getFolderById(STORAGE_FOLDER_ID);
var iter = folder.getFilesByName(filename);
var f;
do {
  if (!iter.hasNext()) {
    reportDownloadFailed('ファイルが存在しない', reqArgs);
    return ContentService.createTextOutput('ファイルが見つかりません');
  }
  f = iter.next();
} while(f.isTrashed());
if (iter.hasNext()) {
  while (iter.hasNext()) {
    var g = iter.next();
    if (!g.isTrashed()) {
      reportDownloadFailed('同名のファイルが複数存在', reqArgs);
      return ContentService.createTextOutput('内部エラー');
    }
  }
}
```

## ファイル送信
チェックが済んでファイルを読みだしたら後は送信するだけ……なのですが、Apps Scriptはバイナリデータを直接返せません。なので下記の黒魔術を使うことにします。

* 通常は Data URI としてHTMLに埋め込んで返す。
* IE、Edgeのみ、Data URIのサポートが弱いのでフロントエンドで`msSaveBlob()`を呼ぶ。

ダウンロードリクエストが来たあと10分ぐらいリンク共有にするようにした方がいい気もします……が今回は上記の通り行きます。

スクリプト側は後はファイル内容をBase64エンコードして後述のHTMLテンプレートを呼ぶだけです。

```javascript
var tmpl = HtmlService.createTemplateFromFile('download.html');
tmpl.browser = req.parameter.browser;
tmpl.filename = filename;
tmpl.mime = f.getMimeType();
tmpl.data = Utilities.base64Encode(f.getBlob().getBytes());
reportDownloadsucceeded(reqArgs);
return tmpl.evaluate();
```

HTMLテンプレートもIEとEdgeを除けば`a`タグに`download`属性をセットしてData URIへリンクを貼るだけ。

```html:download.html
<p><a id="dlbtn" class="btn btn-primary" download="<?= filename ?>"
    <? if (browser === 'ms-family') { ?>
      href="#"
    <? } else { ?>
      href="data:<?= mime ?>;base64,<?= data ?>"
    <? } ?>
    >ダウンロード: <?= filename ?></a></p>
```

画面上にはダウンロードボタンがありますが、表示されているときには既にダウンロードは完了している……という状況です。

### IE、Edgeの場合
前述の通りData URIが使えないのでフロントエンドJavaScriptを書きます。
どうもBase64デコードする標準メソッドが見当たらない……ので[こちらのBSDライセンスのコード](https://gist.github.com/xperiments/5965147)を使わせていただきました。

```javascript:download.html
const dlbtn = window.document.getElementById('dlbtn');
dlbtn.onclick = function (ev) {
  ev.preventDefault();
  const b64 = '<?= data ?>';
  // atob() は文字列へのデコードにしか使えない。。。
  const u8ar = Base64Binary.decode(b64);
  const blob = new Blob([u8ar], {type: '<?= mime ?>'});
  window.navigator.msSaveBlob(blob, '<?= filename ?>');
}
```

## ダウンロード失敗及びデータ送信時のメール報告
何かあったときのために、メールでログを取るようにしておきます。
なお、アクセスユーザーがダウンロード完了したかどうかはApps Scriptからは分かりません。上記の通り黒魔術を使っているので、一部の環境では動作しない可能性を否定しきれないからです。[^1]

[^1]: XP以前のWindowsを使い続けているとか。うちの部署の場合はそういう相手先は現時点でFAXで送ることになっていると思うので良いのですが……。

```javascript
var ADMIN_EMAIL = '+xxx@gmail.com';

function reportDownloadsucceeded(reqArgs) {
  _report('【ワタス君】ダウンロード対象データ送信', 'ダウンロード対象データを送信', reqArgs);
}

function reportDownloadFailed(msg, reqArgs) {
  _report('【ワタス君】ダウンロード失敗レポート', msg, reqArgs);
}

function _report(title, msg, reqArgs) {
  Logger.log(title);
  Logger.log(reqArgs);
  var body = '【メッセージ】\n' + msg;
  if (reqArgs.fileDesc) {
    body += '\n\n【ファイルの状況】\n' + JSON.stringify(reqArgs.fileDesc, null, 2);
  }
  body += '\n\n【リクエスト】\n' + JSON.stringify(reqArgs.req, null, 2);
  if (reqArgs.err) {
    body += '\n\n【例外】\n' + JSON.stringify(reqArgs.err, null, 2);
  }
  GmailApp.sendEmail(ADMIN_EMAIL, title, body, {
    from: ADMIN_EMAIL
  });
}
```

# 相手先へのダウンロードURL案内メール送信

さて、ダウンロード画面が完成したのでダウンロードURL案内メールもちゃっちゃとスクリプトで送るようにしたいと思います。こちらも定型文ですし。

```javascript
function publish() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('トップ');
  var lastRow = sheet.getLastRow();
  var data = sheet.getRange(2, 1, lastRow - 1, 6).getValues();
  for (var i = 0; i < data.length; ++i) {
    var toName = data[i][0];
    var filename = data[i][1];
    var to = data[i][2];
    var timeLimit = data[i][4];
    var status = data[i][5];
    if (status === '公開中') {
      if (new Date().getTime() > timeLimit.getTime()) {
        sheet.getRange(2 + i, 6).setValue('公開終了');
      }
    }
    if (status === '公開準備完了') {
      var passcode = generatePassword(1 + i, filename, to, timeLimit);
      sheet.getRange(2 + i, 4, 1, 3).setValues([[passcode, timeLimit, '公開中']]);
      GmailApp.sendEmail(to, filename + '送信のお知らせ', toName + '様\n\
\n\
ファイル名: ' + filename + '\n\
ダウンロード期限: ' + Utilities.formatDate(timeLimit, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm') + '\n\
\n\
下記URLよりダウンロードしてください: \n\
' + ScriptApp.getService().getUrl() + '?fileId=' + (2 + i) + '&pwd=' + passcode,
        { from: ADMIN_EMAIL, bcc: ADMIN_EMAIL });
    }
  }
}
```

## パスワード自動生成
最初に書いた通りApps Scriptは（標準では）暗号論的疑似乱数が作れません。`Math.random()`しかありません。実装がどうなっているか分かりませんが、出力が時刻のみで決まってしまいそうで怖いです……。

仕方がないので発想を変えて、ソルトとファイル名等をSHA-256でハッシュ化、Base64化したものをパスワードにすることにします。結果は44文字になるのですが手入力する訳でもないので大丈夫でしょう。

```javascript
var SALT = '7v37BuSWJAkHvwp1ySXSzUwWdbv5GyqM';

function generatePassword(fileId, filename, to, timeLimit) {
  var base = SALT + Math.random() + new Date() + fileId + filename + to + timeLimit;
  var digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, base);
  return Utilities.base64EncodeWebSafe(digest);
}
```

律儀に1セルずつ結合していますが、ランダムな値が出るならなんでも良いので実のところ`JSON.stringify(rowData)`でかまわないですね。