Dropbox APIを使ってファイル共有

Dropbox APIを使って、Dropboxにファイルをアップロードし、他の人とファイルを共有します。Dropboxを一時的な格納庫として利用します。
Dropbox APIで以下のことを実現します。

* ファイルをアップロードして、自身の複数の端末から共有できます。
* アップロードしたファイルを共有して、複数の人がダウンロードできます。
* 他の人からファイルのアップロードを受領します。

もろもろのソースコードはGithubに上げてあります。

https://github.com/poruruba/ClippingService

構成要素
* プライベートサーバ ： ブラウザで動作するクライアントとDropboxを仲介するためのサーバです。クライアント向けのWebページの提供とWebAPIの処理を担っています。また、DropboxからのWebhook呼び出しも受け取ります。
* Dropboxサーバ ： 共有するファイルを管理してもらうサーバです。
* 管理クライアント（ブラウザ）： 共有するファイルを管理するためのクライアントです。PWAです。
* 一般クライアント（ブラウザ）： 共有を許可されたファイルの取得や、自身のファイルのアップロードを行います。

登場人物
* 管理者 ： ファイルのすべての権限を有し、アップロードを受け取ったファイルやファイルの共有の設定を行います。一般ユーザにはURLを渡して、ファイルのダウンロードやアップロードを許可します。
* 一般ユーザ ： 管理者から通知されたURLを使ってファイルのダウンロードやアップロードを行います。

これを実現するにあたり、以下を考慮します。

* プライベートサーバにはファイルの保存はしません。(Render.comに立ち上げます)
* 管理クライアントはPWAとし、アプリとして端末に登録することができるようにします。(クライアントは、Windows、Android、iPhoneを対象とします。)
* 保持するファイルはDropboxに置きます。Dropboxにアカウント登録しておきます。
* 一般クライアントは、Dropboxを介してファイルのアップロード/ダウンロードをします。推測されにくいURLからのアクセスとし、一般ユーザはDropboxのユーザである必要はないです。
* Dropbox APIのアクセスに必要なアクセストークンは、自分用のサーバ内に閉じます。
* 管理クライアントからのWebAPIアクセスは、プライベートサーバと共用する秘匿のAPIキーで保護します。

## Dropbox APIを使う準備

Dropboxにアカウントを作成します。

DropboxコンソールのApp Consoleからアプリを作成します。

[Dropboxコンソール](https://www.dropbox.com/developers)

アクセストークンを払い出すために、リフレッシュトークンを生成します。リフレッシュトークンがあれば、アクセストークンを生成できます。アクセストークンの有効期限は有限ですが、リフレッシュトークンは期限がないんです。
ブラウザから以下を表示します。App keyは、作成したアプリに表示されています。

```https://www.dropbox.com/oauth2/authorize?client_id=<App key>&token_access_type=offline&response_type=code```

そうすると、ブラウザにアクセスコードが表示されます。
次に、以下のHTTP Postを発行すると、リフレッシュトークンが取得できます。

```
curl https://api.dropbox.com/oauth2/token -d code=[アクセスコード] -d grant_type=authorization_code -u client_id=[App key] -u client_secret=[App secret]
```

リフレッシュトークンが生成できましたので、大事にメモっておきます。
以下の呼び出しで、リフレッシュトークンとApp key(client_id)とApp secret(client_secret)からアクセストークンをいつでも生成できます。

```js:/api/controllers/clipping-api/lib_dropbox.js
      var input = {
        url: dropbox_base_url + "/oauth2/token",
        params : {
          grant_type: "refresh_token",
          refresh_token: this.refresh_token,
          client_id: this.client_id,
          client_secret: this.client_secret,
        },
        content_type: "application/x-www-form-urlencoded"
      };
      var result = await do_http(input);
      console.log(result);
```

## Dropboxでのファイル構造

App Consoleからアプリを作成したときに「App folder name」を決めました。
そうすると、Dropbox内に「/アプリ/[App folder name]/」というフォルダ名が作成され、その中にアップロードされたファイルが格納されます。
Dropbox APIから呼び出す際には、/ は、/アプリ/[App folder name]/ に読み替えられるため意識する必要はありません。

格納する際に、同じファイル名もアップロードされる可能性があるため、アップロードしたファイルは以下のようにエポック時間(msec)のフォルダの中にアップロードします。エポック時間はアップロード時の時間です。

/[エポック時間(msec)]/XXX.png

## Dropbox APIを使って実現すること

Dropbox APIには、様々なWebAPIが用意されており、組み合わせることで、やりたかったことが実現できます。利用したDropboxのWebAPIをそれぞれ示します。

詳細は、リファレンスを参照してください。

[Dropbox for HTTP Developers](https://www.dropbox.com/developers/documentation/http/documentation)

### ファイルをアップロードして、自身の複数の端末から共有

[file-upload](https://www.dropbox.com/developers/documentation/http/documentation#files-upload)
ファイルをDropboxにアップロードします。Dropbox上のファイルパスとファイルの内容を指定します。

[file-delete](https://www.dropbox.com/developers/documentation/http/documentation#files-delete)
アップロードしたファイルを削除します。Dropbox上のファイルパスを指定します。

[file-list_folder](https://www.dropbox.com/developers/documentation/http/documentation#files-list_folder)
アップロードしたファイルのリストを取得します。

### アップロードしたファイルを共有して、複数の人がダウンロード

[files-get_temporary_link](https://www.dropbox.com/developers/documentation/http/documentation#files-get_temporary_link)
4時間期限付きでファイルをダウンロードできるURLを生成します。Dropbox上のファイルパスを指定します。

[sharing-create_shared_link_with_settings](https://www.dropbox.com/developers/documentation/http/documentation#sharing-create_shared_link_with_settings)
アップロードしたファイルを共有状態にします。Dropbox上のファイルパスを指定します。

[sharing-list_shared_links](https://www.dropbox.com/developers/documentation/http/documentation#sharing-list_shared_links)
共有状態のファイルのリストを取得します。

### 他の人からファイルのアップロードを受領

[file_requests-create](https://dropbox.github.io/dropbox-api-v2-explorer/#file_requests_create)
アップロード先（ファイルリクエスト）を作成します。アップロードしたいDropbox上のフォルダパスを指定します。

[file_requests-update](https://dropbox.github.io/dropbox-api-v2-explorer/#file_requests_update)
今回は、ファイルリクエストを編集して、アップロードを停止するのに使います。ファイルリクエストのIDを指定します。

[file_requests-delete](https://www.dropbox.com/developers/documentation/http/documentation#file_requests-delete)
ファイルリクエストを終了します。ファイルリクエストのIDを指定します。

[file_requests-list](https://www.dropbox.com/developers/documentation/http/documentation#file_requests-list)
現在待ち受け中のファイルリクエストのリストを取得します。

## ソースコード

扱いやすいようにライブラリにしておきました。
npmのnode-fetch@2.7.0とform-dataを使っています。

```js:/api/controllers/clipping-api/lib_dropbox.js
const dropbox_base_url = "https://api.dropboxapi.com";
const dropbox_content_url = "https://content.dropboxapi.com";

const FormData = require('form-data');
const { URL, URLSearchParams } = require('url');
const fetch = require('node-fetch');
const Headers = fetch.Headers;

class Dropbox{
  constructor(client_id, client_secret, refresh_token){
    this.client_id = client_id;
    this.client_secret = client_secret;
    this.refresh_token = refresh_token;
    this.token_created = 0;
  }

  async retrieve_access_token(){
    var now = new Date().getTime();
    if( (now - this.token_created) >= (50 * 60 * 1000) ){
      var input = {
        url: dropbox_base_url + "/oauth2/token",
        params : {
          grant_type: "refresh_token",
          refresh_token: this.refresh_token,
          client_id: this.client_id,
          client_secret: this.client_secret,
        },
        content_type: "multipart/form-data"
      };
      var result = await do_http(input);
//      console.log(result);
      this.token = result.access_token;
      this.token_created = now;
    }
    return this.token;
  }

  async upload(path, buffer){
    await this.retrieve_access_token();
    var params = {
      path: path,
      mode: "overwrite",
    };
    var input = {
      url: dropbox_content_url + "/2/files/upload",
      body: buffer,
      content_type: "application/octet-stream",
      headers: {
        "Dropbox-API-Arg": JSON.stringify(params)
      },
      token: this.token,
    };
    var result = await do_http(input);
    return result;
  }
  
  async delete(path){
    await this.retrieve_access_token();
    var input = {
      url: dropbox_base_url + "/2/files/delete_v2",
      token: this.token,
      body: {
        path: path
      }
    };
    var result = await do_http(input);
    return result;
  }
  
  async list(){
    await this.retrieve_access_token();
    var input = {
      url: dropbox_base_url + "/2/files/list_folder",
      token: this.token,
      body: {
        path: "",
        recursive: true,
      }
    };
    var result = await do_http(input);
//    console.log(result);
    var entries = result.entries;
    while(result.has_more){
      var input = {
        url: dropbox_base_url + "/2/files/list_folder/continue",
        token: this.token,
        body: {
          cursor: result.cursor
        }
      };
      var result = await do_http(input);
//      console.log(result);
      entries = entries.concat(result.entries);
    }
  
    return entries;
  }
  
  async get_temporary_link(path){
    await this.retrieve_access_token();
    var input = {
      url: dropbox_base_url + "/2/files/get_temporary_link",
      token: this.token,
      body: {
        path: path
      }
    };
    var result = await do_http(input);
    return result;
  }

  async create_share_links(path){
    await this.retrieve_access_token();
    var settings = {
      audience: "public",
      allow_download: true
    };
    var body = {
      path: path,
      settings: settings
    };
    var input = {
      url: dropbox_base_url + "/2/sharing/create_shared_link_with_settings",
      token: this.token,
      body: body
    };
    var result = await do_http(input);
    return result;
  }
  
  async list_shared_links(){
    await this.retrieve_access_token();
    var input = {
      url: dropbox_base_url + "/2/sharing/list_shared_links",
      token: this.token,
      body: {}
    };
    var result = await do_http(input);
//    console.log(result);
    var links = result.links;
    while(result.has_more){
      var input = {
        url: dropbox_base_url + "/2/sharing/list_shared_links",
        token: this.token,
        body: {
          cursor: result.cursor
        }
      };
      var result = await do_http(input);
//      console.log(result);
      links = links.concat(result.links);
    }
  
    return links;
  }

  async make_create_request(path, title, description){
    await this.retrieve_access_token();
    var body = {
      title: title,
      description: description,
      destination: path,
      open: true
    };
    var input = {
      url: dropbox_base_url + "/2/file_requests/create",
      token: this.token,
      body: body
    };
    var result = await do_http(input);
    return result;
  }

  async close_request(id){
    await this.retrieve_access_token();
    var body = {
      id: id,
      open: false
    };
    var input = {
      url: dropbox_base_url + "/2/file_requests/update",
      token: this.token,
      body: body
    };
    var result = await do_http(input);
//    console.log(result);
  
    var input = {
      url: dropbox_base_url + "/2/file_requests/delete",
      token: this.token,
      body: {
        ids: [id]
      }
    };
    var result = await do_http(input);
    return result;
  }

  async list_request(){
    await this.retrieve_access_token();
    var input = {
      url: dropbox_base_url + "/2/file_requests/list_v2",
      token: this.token,
      body: {}
    };
    var result = await do_http(input);
//    console.log(result);
    var list = result.file_requests;
    while(result.has_more){
      var input = {
        url: dropbox_base_url + "/2/file_requests/list_v2",
        token: this.token,
        body: {},
        cursor: result.cursor
      };
      var result = await do_http(input);
//      console.log(result);
  
      list = list.concat(result.file_requests);
    }
    return list;
  }
}

// input: url, method, headers, qs, body, params, response_type, content_type, token, api_key
async function do_http(input){
  const method = input.method ? input.method : "POST";
  const content_type = input.content_type ? input.content_type : "application/json";
  const response_type = input.response_type ? input.response_type : "json";

  const headers = new Headers();
  if( content_type != "multipart/form-data" )
    headers.append("Content-Type", content_type);
  if( input.token )
    headers.append("Authorization", "Bearer " + input.token);
  if( input.api_key )
    headers.append("x-api-key", input.api_key);
	if( input.headers ){
		for( const key in input.headers )
			headers.append(key, input.headers[key]);
	}

  let body;
	if( content_type == "application/x-www-form-urlencoded"){
    body = new URLSearchParams(input.params);
  }else if( content_type == "multipart/form-data"){
    body = Object.entries(input.params).reduce((l, [k, v]) => { l.append(k, v); return l; }, new FormData());
  }else if( content_type == "application/json" ){
		if( input.body )
      body = JSON.stringify(input.body);
	}else{
		body = input.body
	}

  const params = new URLSearchParams(input.qs);
  var params_str = params.toString();
  var postfix = (params_str == "") ? "" : ((input.url.indexOf('?') >= 0) ? ('&' + params_str) : ('?' + params_str));

  return fetch(input.url + postfix, {
    method: method,
    body: body,
    headers: headers
  })
  .then((response) => {
    if (!response.ok)
      throw new Error('status is not 200');

    if( response_type == "json" )
      return response.json();
    else if( response_type == 'blob')
      return response.blob();
    else if( response_type == 'file'){
      const disposition = response.headers.get('Content-Disposition');
      let filename = "";
      if( disposition ){
				const parts = disposition.split(';').find(item => item.trim().startsWith("filename") );
				filename = parts.trim().split(/=(.+)/)[1];
				if (filename.toLowerCase().startsWith("utf-8''"))
					filename = decodeURIComponent(filename.replace(/utf-8''/i, ''));
				else
					filename = filename.replace(/['"]/g, '');
      }
      return response.blob()
      .then(blob =>{
        return new File([blob], filename, { type: blob.type })      
      });
    }
    else if( response_type == 'binary')
      return response.arrayBuffer();
    else // response_type == "text"
      return response.text();
  });
}

module.exports = Dropbox;
```

## DropboxからWebhookを受けるには

DropboxからWebhookを受け付けるプライベートサーバのエンドポイントには、GETとPOSTの2つのメソッドを受け取れるようにしておく必要があります。

GETは、最初のverification requestのときに呼ばれ、決まった応答を返す必要があります。
App Consoleで、Webhook URIsを設定したときに呼ばれます。

https://www.dropbox.com/developers/reference/webhooks#documentation

```yaml:/api/controllers/clipping-api/swagger.yaml
paths:
  /clipping-webhook:
    get:
      responses:
        200:
          description: Success
          schema:
            type: string
    post:
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object
```

実際のWebhookはPOSTで呼ばれるので、基本的にはPOSTの処理を実装することになります。

```js:/api/controllers/clipping-api/index.js
async function process_webhook(event, context, callback){
	console.log("process_webhook called");
	if( event.httpMethod == 'GET'){
		// verification requestの処理
		var qs = event.queryStringParameters;
		console.log(qs);
		return new TextResponse("text/plain", qs.challenge);
	}

	// 実際のWebhookの内容
	var body = JSON.parse(event.body);
	console.log(body);
・・・
```

# デプロイ方法

## Dropboxのセットアップ

まだDropboxにアカウントを作っていない場合は作成します。

Dropbox Consoleに移動します。

[Dropbox Console](https://www.dropbox.com/developers/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5cb9fa08-b35f-7605-725a-4daa3ada4227.png)

右上の「ログイン」からログインします。

「アプリを作成」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d7632b74-963f-0ee9-a865-d55737896b22.png)

Choose an APIとして「Scoped access｣を選択し、type of accessとして「App folder」を選択し、Name your appとして適当な名前を入力します。他の人と重複しないようにする必要があります。

次に、「Permission」タブを選択して、Permissionを設定します。
以下にチェックを入れます。
・files.metadata.write
・files.metadata.read
・files.content.write
・files.content.read
・sharing.write
・sharing.read
・file_request.write
・file_request.read

最後に、Submitをクリックします。
次に、「Settings」タブに戻って、Generated access tokenのところの「Generate」ボタンを押下します。そうすると、長い文字列のアクセストークンが生成されますのでメモっておきます。

## Render.comにデプロイ

Render.comにアカウントを作っていない場合は作成します。

[Render.com](https://render.com/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f06e65a4-bd8a-01d4-125a-78726d305eb8.png)

右上のDashboardを選択し、ログインします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/6fedee77-c867-7dbc-8ee2-fe5e9ce4ac69.png)

右上の「＋New」をクリックし、「Web Service」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3151c05c-5580-8d0c-7495-9b5dfd5ed305.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/72c768e1-20dc-4725-2a68-a770b87a71bf.png)

「Public Git Repository」タブを選択し、以下のURLを入力し、「Connect」ボタンを押下します。

```https://github.com/poruruba/ClippingService.git```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/853a6f69-fd02-9af9-85d3-0ab237724c5e.png)

Languageは「Node」です。
Instance TypeはFreeを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8ce91861-d9d6-fb10-172c-32601225174c.png)
 
Environment Variablesのところに、DropboxのリフレッシュトークンとApp keyとApp seretを指定します。
名前はそれぞれ「DROPBOX_REFRESH_TOKEN」、「DROPBOX_CLIENT_ID」、「DROPBOX_CLIENT_SECRET」です。
また、「CLIPPING_API_KEY」も追加し、秘匿の値を指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d6abe22b-9fec-72e2-c864-916d9b99c35a.png)

デプロイ完了まで5分近くかかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d87e8842-c44d-8b65-aabd-54f354c7ef33.png)

完了すると、左上に割り当てられたURLが表示されていますので、クリックして開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b321d6da-2123-76cf-4e32-e1ffa07e8bf7.png)

右上にある「APIキー」ボタンを押下して、先ほど指定した「CLIPPING_API_KEY」の秘匿の値を指定します。
もう一度Ctrl＋F5を押してリロードします。

これで成功したはずです。
ちなみに、このページはPWA対応にしているため、ChromeやSafariからアプリとして登録することができます。

## DropboxのWebhook設定

Dropboxの App Consoleに戻って、Settingsタブを開きます。
「Webhook URIs」のところに、以下のURLを入力します。

```https://[Render.comによって割り当てられたホスト名]/clipping-webhook```

「Add」ボタンを押下して、EnableになればOKです。

## 動作確認

テキストパネルの、ｱｯﾌﾟﾛｰﾄﾞの下のテキストエリアに適当な文字列を入力したのち、「ｱｯﾌﾟﾛｰﾄﾞ」ボタンを押下します。
そして、「ﾀﾞｳﾝﾛｰﾄﾞ」ボタンを押下すると、その下に入力した文字列と同じ文字列が表示されたら成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/946260a1-22b6-7719-09f0-c25402ab3972.png)

次に、ファイルパネルの「ファイルを選択」ボタンを押下して、適当なファイルを選択します。そして「ｱｯﾌﾟﾛｰﾄﾞ」ボタンを押下します。
「ﾁｪｯｸ」ボタンを押下すると、ちゃんとアップロードされていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/73f1a5d1-02c1-72ad-d826-2b7d7eb6e277.png)

「ﾀﾞｳﾝﾛｰﾄﾞ」ボタンを押下すると、アップロードしたファイルがダウンロードできることがわかります。
そして「保存」ボタンを押します。そうすると、Dropboxにアップロードしてあるファイルがコピーされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/cf96fb91-f68e-868f-70a3-db750561f2aa.png)

先ほどアップロードしたファイルが表示されています。

ファイル名をクリックすると、同様にファイル保存ダイアログが表示されてダウンロードされているのがわかります。
また、「ﾜﾝﾀｲﾑ」ボタンをクリックすると、QRコードが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/50030877-84b6-caa1-2eb9-a6104399c42e.png)

このQRコードは、その下のテキストボックスに入力されている文字列と同じです。
このQRコードを他の方にお知らせすることで、他の方が同じファイルをダウンロードできます。このURLは4時間有効です。

また、「共有」ボタンを押すことでShared Linkとなり、永続的に他の方と共有できるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/307d27c0-9c0a-9cc0-ab51-32ad9e3bfab7.png)

青い「共有」ボタンが表示され、こちらもQRコードが表示されます。このQRコードはこのファイルを削除するまでずっと有効です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0b591829-b781-a21c-a5b9-e56e759387c9.png)

この時開かれるのはDropboxのページになります。今回は画像ファイルをアップロードしたのでそれに応じたページになるようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8508bcd6-8811-a954-9194-940b2990cb7d.png)

他の方からファイルを受け取りたい場合は、「ｱｯﾌﾟﾛｰﾄﾞ」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1a0a6058-4fa0-9003-a7f1-9f5a271d5990.png)

ここでも、QRコードが表示されるので、そのQRコードから開いたWebページから任意のファイルをアップロードすることができます。

以下のようなページが開かれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d9391f2f-a5c9-6332-05e7-065d534570ff.png)

アップロードしたら、「保存・共有ファイル」パネルの右にある更新ボタンを押すとリストが更新されて、アップロードされたファイルがリストに表示されます。

以上