Box UI ElementsのContent OpenWithでBox Editをつかってみた

この記事のシリーズ：

[Box UI ElementsのContent OpenWithでBox Editをつかってみた](https://qiita.com/kobay/items/8f01353308141813881f)　←　この記事
[Box UI ElementsのContent OpenWithでG Suiteを開いてみた](https://qiita.com/kobay/items/cfa15d894e17a0fba384)
[Box UI ElementsのContent OpenWithでファイルの更新に反応してみた](https://qiita.com/kobay/items/685a0cdf139ad896d43c)
[Box UI ElementsのContent OpenWithでファイルの更新に反応してみた クライアントサイド編](https://qiita.com/kobay/items/0169158c78447ffd2763)

コードは、[Github](https://github.com/kobay/box-openwith-example)でも確認いただけます。


## Box UI ElementsのContent OpenWithとは

Content OpenWithのまえに、そもそもBox UI Elementsとはなにかという話ですが、これはBox社が公式に提供しているJavascriptとCSSによるWeb用の部品です。自作のウェブサイトに組み込むことで、標準のBoxの様なUIを簡単に実現可能です。


Box UI Elementsには、現時点で以下の6個のコンポーネントが用意されています。


- Content Explorer

- Content OpenWith <= 今回利用

- Content Picker

- Content Preview

- Content Sidebar

- Content Uploader



この中で、Content OpenWithは、一番ややこしかったので、使いかたを備忘録として残します。

Content OpenWithは、カスタムアプリケーション上にボタンを配置し、BoxEdit、Adobe Sign、Google Sheetなどの外部アプリケーションをコールすることができるコンポーネントです。
よくある使いかたとして、Box Editと連携して、ローカルでエディタを開き、保存時にBoxにデータを書き戻すことができるようになります。

このコンポーネントを利用すると、カスタムアプリケーションでもBoxからエディタを開くような滑らかな連携が可能になります。

Box UI Elements自体は、Reactで作られています。Reactを利用している場合、npm経由でライブラリをインストールして使うことが可能です。React以外の場合はCDN経由で提供されているjsファイルを利用できます。

基本的な部分は、ここに書いてあることをなぞっています。
https://ja.developer.box.com/guides/embed/ui-elements/


## 事前準備

このサンプルでは、以下の環境の利用を前提としています。実際にやってみたい人は予め準備が必要です。

- Boxのアカウント（管理者権限が利用可能なBoxテナント）
- Box Toolsのインストール（ローカルでエディタを開きBoxに同期するために必要。Box Toolsは、Box EditとDevice Trustが一緒になったものです。）
- Herokuのアカウント
- Heroku CLIのインストール
- Gitのインストール
- Nodeおよびパッケージマネージャ(npm or yarn)のインストール


## Box環境の準備



Boxの開発者コンソールにアクセスし、「アプリの新規作成」を押します

開発者コンソールへのアクセスが初めての場合は、`https://ja.developer.box.com/`にアクセスし、マイアプリボタンをクリックすることで、アクセスが可能となります。

アプリの種類はカスタムアプリを選択します。

今回は、認証方法に、「JWTを使用したOAuth 2.0 （サーバー認証）」を選択します。

アプリの名前に任意の文字を入れ、アプリの作成ボタンを押します。

マイアプリ > 構成を開き以下のように設定にします。
（多分こんな感じで大丈夫なはずです・・、アプリケーションアクセスやスコープがたらなければ変えてください）


![スクリーンショット 2020-05-11 20.09.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45767/fa258e28-3540-5feb-eee9-1d61c83df68e.png)


公開キーの追加と管理のセクションで、「公開/秘密キーペアを生成」を押します。

ダウンロードされるファイルは、あとでコードから利用するので、`config.json`とリネームしておきます。



CORSドメインは、公開するHerokuのURLを追加します。

Herokuでのアプリケーション作成が終わったら戻ってきて設定します。


## コードの準備


以下の手順で、プロジェクトを用意します。

```sh
# 適当な名前のフォルダを作って、フォルダに入る
mkdir box-openwith-sample
cd box-openwith-sample

# Nodeプロジェクトとして初期化 (npmでも）
yarn init -y

# 必要なライブラリの追加 
yarn add box-node-sdk express ejs axios

# 必要なファイルを追加
touch app.js
touch index.ejs

# こちらは事前準備用のファイル（必須ではない）
touch setup.js

```



`package.json`に、startをスクリプトを追加します。（Herokuがstartを実行します)

```json:package.json
{
  
  "scripts": {
    "start": "node app.js"
  },
  
}

```



Boxでアプリの設定をしたときにダウンロードした`config.json`をプロジェクトフォルダ直下に配置します。



任意のエディタで`setup.js`を作成します。

このファイルは事前に(1)Appユーザーを作成し、(2)ファイルをアップロードし、(3)BoxEditの統合を使えるようにするという操作をするために作成し、実行します。これらの処理が事前にできればいいだけなので、ファイルとしてプロジェクトに追加する必要はありません。

実行前に`Sample.docx`というダミーのMS Wordのファイルを用意しておきます。
`config.json`が同じフォルダにある前提となっています。

ダミーのファイルはとくにWordである必要も、このファイル名である必要も有りませんが、変更する場合コードも修正してください。

```javascript:setup.js
const boxSDK = require("box-node-sdk");
const config = require("./config.json");
const axios = require("axios");
const fs = require("fs");

/**
 * app.jsで利用する、AppUserと、サンプルのWordファイルを予め登録する。
 * また、OpenWithを利用するために必要な、統合の設定も行う。
 */
const main = async () => {
  try {
    // 作成済みのJWT認証用のConfigファイルを読み込む
    const sdk = await boxSDK.getPreconfiguredInstance(config);
    // ServiceAccountのClientオブジェクトを作成
    const saClient = sdk.getAppAuthClient("enterprise");

    // app userを作成 (OpenWithはServiceAccountではつかえず、AppUserが必要なため)
    let appUser = await saClient.enterprise.addAppUser("Sample App User");

    // app userのホームフォルダに、サンプルとしてSample.docをアップロードする
    saClient.asUser(appUser.id);
    const stream = fs.createReadStream("./Sample.docx");
    const files = await saClient.files.uploadFile("0", "Sample.docx", stream);
    const file = files.entries[0];
    saClient.asSelf();

    // 念の為、現在利用可能なWebApp統合を一覧する
    // 13418が入っていないなら、設定がまちがっている
    const appIntegs = await saClient.get("/app_integrations");
    console.log("利用可能なWebApp統合一覧", appIntegs.body);
    /*
    {
      next_marker: null,
      entries: [
        { type: "app_integration", id: "10897" },
        { type: "app_integration", id: "1338" },
        { type: "app_integration", id: "13418" },  <= 13418がBox Editの統合
        { type: "app_integration", id: "3282" },
      ],
      limit: 100,
    };
    */

    // 作成したAppUserに、BoxEditのアプリ統合を利用できるようにする。
    // clientオブジェクトから何故かpostの実行（client.post）がうまく機能しなかったので、axiosで実行する
    // Authorizationにつけるアクセストークンは、ServiceAccountのものを利用する必要がある。
    const saTokenInfo = await sdk.getEnterpriseAppAuthTokens();
    const saAxios = axios.create({
      baseURL: "https://api.box.com/2.0",
      headers: {
        Authorization: `Bearer ${saTokenInfo.accessToken}`,
      },
    });

    // ここでは13418(BoxEdit)のみを登録するが、他のものも登録しておくと開くボタンから利用可能になる
    await saAxios.post("/app_integration_assignments", {
      assignee: {
        type: "user",
        id: appUser.id,
      },
      app_integration: {
        type: "app_integration",
        id: "13418",
      },
    });

    // 以下のAppUserとFileのIDをapp.jsで利用する
    console.log(
      "==================== 以下のIDをメモして、app.jsで利用する ===================="
    );
    console.log(`const USER_ID = "${appUser.id}"`);
    console.log(`const FILE_ID = "${file.id}"`);
  } catch (e) {
    console.error(e.toString());
  }
};

main();

```



`config.json`と`Sample.docx`が同じフォルダにあることを確認して、以下のコードを実行し、USER_IDと、FILE_IDをとって置きます。app.jsでは、この部分を書き換えてください。

**【注意】`config.json`は、このサンプルでは簡易的にgitに入れていますが、本番環境ではファイルとしてgit等に入れるべきではなく、環境変数等に置くべきです。**


任意のエディタで`app.js`を以下の内容で作ります。

```javascript:app.js
const express = require("express");
const boxSDK = require("box-node-sdk");
const config = require("./config.json");

const app = express();

app.set("views", ".");
app.set("view engine", "ejs");

/**
 * setup.jsで作成したファイルとユーザーを書き換える
 */
const USER_ID = "";
const FILE_ID = "";

app.get("/", async (req, res) => {
  try {
    if (!USER_ID || !FILE_ID) {
      res.send("USER_IDとFILE_IDに値をいれてください。");
      return;
    }
    const sdk = await boxSDK.getPreconfiguredInstance(config);
    // AppUserの権限でClientオブジェクトを作成
    const auClient = await sdk.getAppAuthClient("user", USER_ID);

    // トークンをダウンスコープする
    // APIリファレンスには載っていないが、UI Elementsの説明には書いてあるAPI
    // ここでは、OpenWithで必要なものと、Previewで必要なものを両方スコープにいれてトークンをダウンスコープする
    const downToken = await auClient.exchangeToken(
      ["item_execute_integration", "item_readwrite", "item_preview"],
      `https://api.box.com/2.0/files/${FILE_ID}`
    );

    // テンプレート（index.ejs）にパラメータを渡して、HTMLを返す
    res.render("index", {
      fileId: FILE_ID,
      token: downToken.accessToken,
    });
  } catch (e) {
    console.error(e.toString());
  }
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`express started on port ${port}`);
});

```



##### ダウンスコープについて

Box UI Elementsは、Javascriptによりブラウザ内実行され、Box APIを直接実行します。このため、アクセストークンをブラウザに渡す必要があります。

これは、ブラウザ上の開発者用コンソールを利用すると、アクセストークンがエンドユーザーに見えてしまうということを意味しています。セキュリティ上問題があるので、アクセストークンのダウンスコープという手順が必要になります。ダウンスコープをすることで、指定したファイルIDに指定した操作だけを許すというアクセストークンに変換可能です。

ダウンスコープ前のアクセストークンをブラウザ側に渡すことは避けるべきでしょう。



任意のエディタで`index.ejs`を以下の内容で作ります。

```html:index.ejs

<!DOCTYPE html>
<html lang="en-US">
<head>
    <meta charset="utf-8" />
    <title>Sample</title>

    <link href="https://cdn01.boxcdn.net/platform/elements/11.0.2/ja-JP/openwith.css" rel="stylesheet" type="text/css"></link>
    <link href="https://cdn01.boxcdn.net/platform/preview/2.34.0/ja-JP/preview.css" rel="stylesheet" type="text/css"></link>

    <script src="https://cdn01.boxcdn.net/platform/elements/11.0.2/ja-JP/openwith.js"></script>
    <script src="https://cdn01.boxcdn.net/platform/preview/2.34.0/ja-JP/preview.js"></script>
</head>
<body>
    <div class="openwith" style=""></div>
    <div class="preview-container" style="height:800px; width:100%;"></div>

    <script>
        // app.jsから渡されたパラメータ
        const fileId = "<%= fileId %>"
        // ダウンスコープされたアクセストークン
        const token = "<%= token %>"

        const openWith = new Box.ContentOpenWith();
        openWith.show(fileId, token,  { container: ".openwith"})

        const preview = new Box.Preview();
        preview.show(fileId, token, { container: ".preview-container"});
    </script>
</body>
</html>

```





## Herokuの準備

Content OpenWithはhttpsでしか動かないからherokuにデプロイしているだけです。

ローカルでhttpsで実行でも大丈夫です。



以下のコマンドを実行してHerokuにアプリを作成し、デプロイします。

```shell

# herokuにアプリを作成
heroku create

# gitを導入
git init
git add .
git commit -m "init"

# herokuにデプロイ
git push heroku master
```

## Boxの設定変更

#### CORS設定

Box UI Elementsはブラウザ上でAPIを呼び出すので、CORSの設定が必要になります。
もう一度Boxの開発者コンソールに戻り、CORSの設定をします。
HerokuのURLを登録します。


#### アプリの承認

JWTアプリは管理者に承認されている必要があるので、以下の手順で承認します。
OAuth 2.0資格情報の、クライアントIDをコピー。
管理者コンソール > アプリ（左ナビ） > カスタムアプリ （上部タブ）に移動し、「新しいアプリケーションを承認」というボタンを押します。
クライアントIDを聞かれるので、コピーしたクライアントIDをペーストして承認します。



## ローカルのBoxEditの設定



続いて、ローカルマシンでのBoxEditのセーフリストの設定します。

詳細はこちらをご覧ください。

https://ja.developer.box.com/guides/embed/ui-elements/custom-domains/



Macの環境だと、`OpenWith.sh`をダウンロードし、herokuのドメイン：`xxx.heorkuapp.com`をコマンドから追加するイメージです。

エンドユーザーにContent OpenWithを利用させたい場合は、この操作を1回だけユーザーにやってもらう必要があるので、この点が少しハードルが高いかも、という懸念はあります。

ちなみにこれをやらないとエラーになるとかではなく、OpenWithで描画される開くボタンがdisable状態になるだけです。





## 実行してみる



（URLがわかっているならわざわざ叩く必要ないですが）以下のコマンドで、ブラウザが立ち上がります。

```sh
heroku open
```



以下のように開くボタンとプレビューが表示され、開くボタンを押すとBoxEditが呼び出され、ローカルのエディタがたちあがります。

![openwith.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45767/10e26c05-47b9-35c4-59df-228c379e32f1.png)


MS Wordで内容を変更し、保存ボタンを押した後、Web画面をリロードするとプレビューで変更されていることが確認できます。



## 感想・まとめ



ちょっとめんどくさいけど、作ると結構スムーズに動くのでカスタムアプリからBoxに連携する際に強力な道具になりそうです。
エンドユーザーのコンピュータにBoxToolsのインストールと、セーフリストの設定をする必要がある部分が少しつらそうです。

あと、OpenWithからBox Editを使う場合、以下が抜けていると動かないので注意。

- ローカルにBoxToolsが入っている必要がある
- ローカルでセーフリストにドメインを登録する必要がある
- HTTPS上でのみOpenWithが動く
- AppUserで動かす必要がある
- 統合を事前にAppUserに紐付ける必要あり （これはServiceAccountのトークンでやらないとだめ）
- BOXの開発者コンソールで適切なスコープとCORSの設定が必要





## よくわからなかったこと



上記の一連の手順で基本的にOpenWithの実装ができましたが、以下の点についてはよくわからなかったので、記しておきます。いつか答えがみつかるといいな・・・。


- MS Word（というか、どんなエディタでも）で保存した後に、標準のBoxであれば内容の変更を察知してRefreshボタンのポップアップが表示されるが、UI Elementではこの動作をしない。このため手動でリロードする必要があるが、ここはなんとかならないものか・・

【2020-05-16 追記】
@daichiiiiiii さんからいただいたコメントをもとに、[続編](https://qiita.com/kobay/items/685a0cdf139ad896d43c)で更新の検知を試しています。