Google Drive™ API を活用した簡単な共有アプリの作り方

# 概要
- Google Drive APIを使えば、**無料で数十人程度の情報共有アプリ**を（Google審査以外は）簡単に作れる
- Google Drive標準GUIに乗っかれば、**共有関連UIも作成不要**
- この記事を参考アプリを作ってGoogleにOAuth申請をする際には、できれば[最後に記載の要望](#google%E3%81%AB%E9%80%81%E3%82%8B%E8%8B%B1%E8%AA%9E%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3google%E7%BF%BB%E8%A8%B3%E6%8E%9B%E3%81%91%E3%81%9F%E3%81%A0%E3%81%91%E3%81%A7%E3%81%99%E3%81%8C)を上げてほしい

# なぜ簡単に作れるか？
Google Drive上のユーザの手作業で共有関連処理を行えるようにしているからです。以下、[自作アプリ](https://qiita.com/largetownsky/items/e67787d58bced0d95b42)の[共有機能紹介ページ](https://worth-hole.com/posts/sharingNote/)からの説明画像の引用です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1747214/bd52f10f-9fda-dcd5-0425-21d8052f244f.png)

共有対象を探して設定してという様な情報をユーザとやり取りするのは面倒ですが、全てGoogle Driveが代わりにやってくれる流れになっています。このため、アプリ固定ファイル名のファイル作成・更新・読み込み・一覧という最低限のAPI利用で、共有アプリに必要な情報交換ができてしまいます。


# Google Drive APIとは？
Google Drive内のファイルアクセスをプログラムから自動で行うためのAPIです。[ここ](https://developers.google.com/drive/api/v3/reference)でリストアップされている様に、Google Driveを使ってユーザができる事はだいたいできます。

ファイルには個人的な共有したくない情報も入っていますが、Google Drive APIは野放しに使われると、その様な情報も含め漏れ放題になってしまいます。

このため、Googleは色々対策をしており、危険な情報にアクセスできるほど厳しいチェックが入ります。残念ながら、今回紹介する簡易共有アプリの作り方だと、Google Driveへのフルアクセスを必要とするため、[申請時に厳しいチェック](https://qiita.com/largetownsky/items/e67787d58bced0d95b42#google-api%E3%81%AE%E7%94%B3%E8%AB%8B%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)が入ります。（個人や限られたグループ内だけで使うなら、審査不要です。）

以下のGoogle Drive APIコードを見ると分かる通り、アプリ固定ファイル名以外には一切触れない様にはなっています。が、あくまで自主規制なので、悪いことをしようと思えば（そしてGoogleの審査が無ければ）プライバシーダダ洩れにできてしまいます。この懸念に対応するため、[共有機能紹介ページ](https://worth-hole.com/posts/sharingNote/)では、以下の様に専用アカウントを作る案内もしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1747214/6d8efeea-d640-5fbb-4cd6-2a697cce37b3.png)

# Google Drive APIを使ったデータ共有コード
以下、クロム拡張を前提としたコードにはなっていますが、GoogleDriveApi.getParamsだけフレームワークに合わせて取り換えれば、他は流用できるはずです。

```GoogleDriveApi.ts
// 自分のアプリで同期に使う、他と被りそうにないファイル名
// なお、以下ではファイル内容はJSON形式を前提としているので
// 違う場合は適宜ファイルタイプなどを調整してください
const GD_FILE_NAME = "My_App_Sync_Data.dat";

// Googleドライブから取得した同期に必要なファイル情報
export class GoogleDriveFileInf {
  constructor(
    // ファイルID
    readonly id: string,
    // ファイルバージョン（更新チェック用）
    readonly ver: number,
    // ファイル更新日時（更新チェック用）
    readonly updateTime: Date,
    // 所有者名（他ユーザの目視判別用）
    readonly ownerName: string,
    // 所有者メール（他ユーザの機械的判別用・所有者名は同じ名前にできるため）
    readonly ownerMail: string,
    // 所有者が自分かどうか
    readonly ownerIsMe: boolean,
    // 自分に共有されたか？（不正なファイルの所有者を押し付けられる事故防止用）
    readonly sharedWithMe: boolean
  ) {}

  static async fromObj(
    // Googleドライブから取得した結果JSON内のファイル情報オブジェクト
    obj: any,
    // 所有者名とメールを暗号化する変換器を外部から提供
    nameMailConv: (src: string) => Promise<string>,
    // ファイルIDが決まっている場合は外部から提供
    id?: string
  ): Promise<GoogleDriveFileInf> {
    try {
      if (id == null) {
        id = obj.id;
      }
      const version = parseInt(obj.version, 10);
      const updateTime = new Date(obj.modifiedTime);
      if (
        typeof id === "string" &&
        obj.owners instanceof Array &&
        obj.owners.length > 0 &&
        typeof obj.owners[0].displayName === "string" &&
        typeof obj.owners[0].emailAddress === "string" &&
        typeof obj.owners[0].me === "boolean"
      ) {
        return new GoogleDriveFileInf(
          id,
          version,
          updateTime,
          await nameMailConv(obj.owners[0].displayName),
          await nameMailConv(obj.owners[0].emailAddress),
          obj.owners[0].me,
          obj.sharedWithMeTime != null
        );
      }
    } catch {}
    throw new Error("Invalid obj");
  }
}

// GoogleドライブAPIで同期をするための必要最低限処理をまとめたクラス
export class GoogleDriveApi {
  // アクセスのためのOAuthトークンをchrome identity APIを通して取得
  // コード以外の準備は https://developer.chrome.com/docs/extensions/mv3/tut_oauth/
  private static async getParams(
    method: string = "GET",
    contentType: string = "application/json"
  ): Promise<RequestInit> {
    return new Promise((resolve, _reject) => {
      chrome.identity.getAuthToken({ interactive: true }, (token) => {
        const init = {
          method: method,
          async: true,
          headers: {
            Authorization: "Bearer " + token,
            "content-type": contentType,
          },
        };
        resolve(init);
      });
    });
  }

  // 指定した内容で、アプリ固定ファイル名のファイルをGoogleドライブに作る
  static async createFile(
    text: string,
    nameMailConv: (src: string) => Promise<string>
  ): Promise<GoogleDriveFileInf> {
    // 参考：https://qiita.com/comefigo/items/37e0e7668166d494b922
    // randamUUIDが衝突する確率は、隕石が衝突する程度で、事実上衝突しないものとして扱える
    const boundary = "---" + crypto.randomUUID() + "---";
    const delimiter = "\r\n--" + boundary + "\r\n";
    const closeDelim = "\r\n--" + boundary + "--";
    const init = await GoogleDriveApi.getParams(
      "POST",
      'multipart/related; boundary="' + boundary + '"'
    );
    const metadata = {
      name: GD_FILE_NAME,
      mimeType: "application/json",
    };
    const multiPartBody =
      delimiter +
      "content-type: application/json; charset=UTF-8\r\n\r\n" +
      JSON.stringify(metadata) +
      delimiter +
      "content-type: application/json; charset=UTF-8\r\n\r\n" +
      text +
      closeDelim;
    init.body = multiPartBody;
    const res = await fetch(
      "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,version,modifiedTime,owners,sharedWithMeTime",
      init
    );
    return GoogleDriveFileInf.fromObj(await res.json(), nameMailConv);
  }

  // 指定した内容で、指定したIDのGoogleドライブファイルの内容を更新する。
  // IDは、後述のqueryFilesで取得した値を使う。
  static async updateFile(
    text: string,
    nameMailConv: (src: string) => Promise<string>,
    id: string
  ): Promise<GoogleDriveFileInf> {
    const init = await GoogleDriveApi.getParams("PATCH");
    init.body = text;
    const res = await fetch(
      "https://www.googleapis.com/upload/drive/v3/files/" +
        encodeURI(id) +
        "?uploadType=media&fields=version,modifiedTime,owners,sharedWithMeTime",
      init
    );
    return GoogleDriveFileInf.fromObj(await res.json(), nameMailConv, id);
  }

  // 指定したIDのファイル内容を取得する。
  // IDは、後述のqueryFilesで取得した値を使う。
  static async readTextFromFile(id: string): Promise<string> {
    const init = await GoogleDriveApi.getParams();
    const res = await fetch(
      "https://www.googleapis.com/drive/v3/files/" +
        encodeURI(id) +
        "?alt=media&supportsAllDrives=true",
      init
    );
    return res.text();
  }

  // 他ユーザも含め、アプリ固定ファイル名のリストを得る。
  static async queryFiles(
    nameMailConv: (src: string) => Promise<string>,
    // ファイル所有者を指定する場合、ファイル所有者のメールアドレスを復号して渡す
    owner?: string
  ): Promise<GoogleDriveFileInf[]> {
    const init = await GoogleDriveApi.getParams();
    let query = "name = '" + GD_FILE_NAME + "'";
    if (owner) {
      query += " and '" + owner + "' in owners";
    }
    const res = await fetch(
      "https://www.googleapis.com/drive/v3/files?fields=files(id,version,modifiedTime,owners,sharedWithMeTime)&orderBy=modifiedTime+desc&q=" +
        encodeURI(query),
      init
    );
    const resJson = await res.json();
    if (resJson.files instanceof Array) {
      const promises = (resJson.files as any[]).map((f: any) =>
        GoogleDriveFileInf.fromObj(f, nameMailConv)
      );
      return Promise.all(promises);
    }
    throw new Error("Invalid json");
  }
}
```

# Googleへの要望

## 要望の背景
この様に、簡単に共有アプリが作れますが、何しろ審査が面倒です。しかも、[審査がさらに厳しくなる兆候](https://support.google.com/cloud/answer/9110914?hl=en#restricted-scopes)があります。Google APIは審査の厳しさで以下の３ランクに分かれていて、以下の通り**真ん中の厳しさから、一番厳しいランクに変更される可能性が高い**という事です。

- **Non-sensitive**
  - 審査不要
  - Google Driveで言えば、アプリ専用ファイルへのアクセスならこの範囲
    - ユーザファイルとは完全に別扱いで、ユーザファイルにアクセス不可、ユーザもこのファイルにアクセス不可
    - このため、ユーザがGUIで共有設定することはできない
- **Sensitive**
  - 審査があり、大変ではあるが、時間と手間をかければ、費用は要求されない
  - 現時点での、Google Drive内のファイルフルアクセス権はこの範囲
- **Restricted**
  - 第三者によるセキュリティ審査（**有料で十万単位らしい**）も必要なので、趣味でやるなら辛い
  - 上記の審査が厳しくなる兆候に、Google Drive内のファイルフルアクセス権がリストアップされている

本手法では、審査不要のアプリ専用ファイルをユーザが確認・共有できれば、フルアクセス権は全く必要ありません。このため、Googleに以下の要望を送りましたが、一人だけより複数人から要望があった方が、Googleも対応してくれる可能性が高いので、本手法を活用し続けるためにご協力をお願いします。

## 要望の内容
### Googleに送る英語バージョン（Google翻訳掛けただけですが）
Would it be possible for the Google Drive API app-only file access scope (https://www.googleapis.com/auth/drive.appdata) to be "viewable and operable by users"?

This brings the following benefits to Google, developers and users:

* Developer
  - Since the standard GUI of Google Drive can be reused, the barriers to creating shared applications between multiple users are lowered.

* User
  - Peace of mind because you can always check the recorded contents of the app.
  - It is safe because it is a unified GUI guaranteed by Google Drive, not a file operation UI created by individual developers that you do not know what is going on behind the scenes.

* Google
  - Google's monitoring responsibility and costs are reduced because the app-specific files are checked by many users. Especially for apps that initially appear safe and then undergo dangerous updates, the initial Google review alone is not enough.

I would appreciate it if you could consider it.


### 日本語バージョン
Google Drive API のアプリ専用ファイルアクセススコープ（ https://www.googleapis.com/auth/drive.appdata ）を、「ユーザから閲覧・操作可能」にして頂けないでしょうか？

これにより、Google、開発者、ユーザーに次のメリットがもたらされます。

* デベロッパー
  - Googleドライブ標準GUIを流用できるため、複数ユーザ間の共有アプリを作成するための障壁を下げる。

* ユーザー
  - アプリの記録内容をいつでも確認できるので安心。
  - 個々の開発者が作成した、裏で何をしているか分からないファイル操作UIではなく、Googleドライブが保証する統一的なGUIなので安心。

* Google
  - 多くのユーザの目で、アプリ専用ファイルがチェックされるため、Googleの監視責任・コストが軽減される。特に、最初は安全に見えて、その後危険な更新がされるアプリは、最初のGoogleの審査だけでは限界がある。

ご検討いただければ幸いです。