0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターに添付ファイル項目っぽいBox連携フィールドを作ってみる

0
Posted at

はじめに

プリザンターには標準の添付ファイル項目がありますが、ファイルはプリザンターのサーバに保存されます。「ファイルはBoxで一元管理したい」「Box上のファイルをプリザンターのレコードと紐付けたい」というケースでは、標準の添付ファイル項目では対応できません。

今回は、プリザンターの編集画面に添付ファイル項目のような見た目と操作感を持つBox連携フィールドを、拡張機能だけで作ってみます。ファイルのアップロード・一覧表示・ダウンロード・削除はすべてBox APIを通じて行い、プリザンター側にはBoxのフォルダIDだけを保持する構成です。

Box APIの準備

カスタムアプリの作成

Box Developer Console でカスタムアプリを作成します。

  1. 「マイアプリ」→「アプリの新規作成」をクリック
  2. 「カスタムアプリ」を選択
  3. 認証方法で「サーバー認証(クライアント資格情報許可)」を選択
  4. アプリ名を入力して作成

アプリの設定

作成したアプリの「構成」タブで以下を確認・設定します。

設定項目
クライアントID (自動生成・後で使用)
クライアントシークレット (自動生成・後で使用)
エンタープライズID 「一般設定」タブに記載

アプリの承認

「承認」タブで管理者にアプリの承認を依頼します。サーバー認証を使用するため、Box管理者による承認が必要です。

CORSの設定

「構成」タブの「CORSドメイン」にプリザンターのURLを追加します。

https://your-pleasanter-domain.example.com

CORSの設定がないと、ブラウザからBox APIへの直接アクセスが拒否されます。プリザンターのURLを正確に入力してください。

親フォルダの準備

Box上にプリザンターのテーブル用の親フォルダを作成しておきます。レコードごとのサブフォルダはスクリプトが自動作成します。フォルダのURLからフォルダIDを確認してください。

https://app.box.com/folder/{フォルダID}

全体の流れ

レコードの作成から編集画面でのファイル操作までの流れを見てみましょう。

ポイントは、Box APIの認証情報(クライアントID・シークレット)はサーバスクリプト側にのみ保持し、ブラウザには露出しない構成です。ブラウザがBox APIを呼び出す必要があるとき(アップロード・ダウンロード・削除・一覧更新)は、その都度 $p.apiGet でプリザンターAPIを呼び出し、サーバスクリプト経由で都度発行されたアクセストークンを受け取ります。トークンはDOMに保持しないため、有効期限切れの心配がありません。

テーブル設定

分類項目の追加

「テーブルの管理」→「エディタ」で、分類A・分類Bの設定を変更します。

分類項目 用途 エディタの列に追加
分類A BoxフォルダID(自動設定) しない
分類B トークン受け渡し用(API応答のみ) しない

分類AはBoxフォルダのIDを保持します。分類Bはサーバスクリプトが $p.apiGet の応答に含めるトークンの受け渡しに使います。どちらもエディタの列に追加する必要はありません。

分類A・Bをエディタの列に追加しなくても、サーバスクリプトから model.ClassAmodel.ClassB で読み書きできます。$p.apiGet で取得した場合、サーバスクリプトが設定した値はAPI応答に含まれますが、データベースには保存されません。

実装してみた

拡張サーバスクリプト① Boxフォルダの自動作成

レコードの新規作成時に、Box上にサブフォルダを自動作成し、そのフォルダIDを分類Aに保存します。

ExtendedServerScripts/BoxFolderSetup.json
{
    "AfterCreate": true,
    "AfterUpdate": true,
    "Actions": ["create", "update"],
    "TryCatch": true,
    "Body": "-- loaded from .json.js"
}
ExtendedServerScripts/BoxFolderSetup.json.js
// 分類AにフォルダIDが設定済みなら何もしない
if (model.ClassA) return;

// === Box API設定 ===
var BOX_CLIENT_ID     = 'YOUR_CLIENT_ID';
var BOX_CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
var BOX_ENTERPRISE_ID = 'YOUR_ENTERPRISE_ID';
var BOX_PARENT_FOLDER = 'YOUR_PARENT_FOLDER_ID';

// アクセストークンの取得
httpClient.ResponseHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/oauth2/token';
httpClient.Content = 'grant_type=client_credentials'
    + '&client_id=' + BOX_CLIENT_ID
    + '&client_secret=' + BOX_CLIENT_SECRET
    + '&box_subject_type=enterprise'
    + '&box_subject_id=' + BOX_ENTERPRISE_ID;
httpClient.MediaType = 'application/x-www-form-urlencoded';
var tokenRes = httpClient.Post();
if (!httpClient.IsSuccess) return;
var token = JSON.parse(tokenRes).access_token;

// Boxフォルダを作成(フォルダ名はレコードID)
httpClient.ResponseHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/2.0/folders';
httpClient.Content = JSON.stringify({
    name: String(context.Id),
    parent: { id: BOX_PARENT_FOLDER }
});
httpClient.MediaType = 'application/json';
httpClient.RequestHeaders['Authorization'] = 'Bearer ' + token;
var folderRes = httpClient.Post();
if (!httpClient.IsSuccess) return;

// フォルダIDを分類Aに保存
model.ClassA = JSON.parse(folderRes).id;

AfterCreateAfterUpdate の両方を有効にしているのは、拡張機能の導入前に作成された既存レコードにも対応するためです。既存レコードを編集して保存すると、そのタイミングでBoxフォルダが作成されます。

httpClient.ResponseHeaders.Clear() はリクエストの前に必ず呼び出してください。詳しくは「プリザンターのサーバスクリプトでhttpClientを使うときのお約束」を参照してください。

拡張スタイル(Box連携フィールドのCSS)

拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

ExtendedStyles/BoxField.css
#bf-box-panel {
    margin: 10px 0;
    padding: 12px;
    border: 1px solid var(--control-border, #cecece);
    border-radius: 4px;
    background: var(--base-bg, #fff);
}
#bf-box-panel .bf-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
    font-weight: bold;
}
#bf-box-panel .bf-file-list { list-style: none; margin: 0; padding: 0; }
#bf-box-panel .bf-file-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 4px;
    border-bottom: 1px solid var(--base-border, #eee);
}
#bf-box-panel .bf-file-item:last-child { border-bottom: none; }
#bf-box-panel .bf-file-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
#bf-box-panel .bf-file-size { color: #888; font-size: 0.85em; white-space: nowrap; }
#bf-box-panel .bf-btn {
    padding: 2px 8px;
    border: 1px solid var(--control-border, #cecece);
    border-radius: 3px;
    background: var(--base-bg, #fff);
    cursor: pointer;
    font-size: 0.85em;
}
#bf-box-panel .bf-btn:hover { background: #f0f0f0; }
#bf-box-panel .bf-btn-delete { color: #c00; }
#bf-box-panel .bf-upload-area {
    margin-top: 8px;
    padding: 16px;
    border: 2px dashed var(--control-border, #cecece);
    border-radius: 4px;
    text-align: center;
    color: #888;
    cursor: pointer;
}
#bf-box-panel .bf-upload-area:hover {
    border-color: var(--primaryColor, #106ebe);
    color: var(--primaryColor, #106ebe);
}
#bf-box-panel .bf-upload-area.bf-dragover {
    border-color: var(--primaryColor, #106ebe);
    background: rgba(16, 110, 190, 0.05);
}
#bf-box-panel .bf-empty { padding: 12px; text-align: center; color: #888; }
#bf-box-panel .bf-progress {
    margin-top: 8px;
    padding: 8px;
    border: 1px solid var(--base-border, #eee);
    border-radius: 4px;
    background: var(--base-bg, #fff);
}
#bf-box-panel .bf-progress-info {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 4px;
    font-size: 0.85em;
}
#bf-box-panel .bf-progress-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
}
#bf-box-panel .bf-progress-pct { margin-left: 8px; white-space: nowrap; color: #888; }
#bf-box-panel .bf-progress-track {
    height: 6px;
    background: #e0e0e0;
    border-radius: 3px;
    overflow: hidden;
}
#bf-box-panel .bf-progress-bar {
    height: 100%;
    background: var(--primaryColor, #106ebe);
    transition: width 0.3s;
}

CSS変数(var(--control-border) など)を使うことで、プリザンターのテーマに合わせた見た目になります。

拡張サーバスクリプト② ファイル一覧の表示

編集画面を開いたとき、Boxのファイル一覧を取得してUIを注入します。

ExtendedServerScripts/BoxFieldView.json
{
    "BeforeOpeningPage": true,
    "Actions": ["edit"],
    "TryCatch": true,
    "Body": "-- loaded from .json.js"
}
ExtendedServerScripts/BoxFieldView.json.js
// 分類AにフォルダIDがなければ何もしない
var folderId = model.ClassA;
if (!folderId) return;

// === Box API設定 ===
var BOX_CLIENT_ID     = 'YOUR_CLIENT_ID';
var BOX_CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
var BOX_ENTERPRISE_ID = 'YOUR_ENTERPRISE_ID';

// アクセストークンの取得
httpClient.ResponseHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/oauth2/token';
httpClient.Content = 'grant_type=client_credentials'
    + '&client_id=' + BOX_CLIENT_ID
    + '&client_secret=' + BOX_CLIENT_SECRET
    + '&box_subject_type=enterprise'
    + '&box_subject_id=' + BOX_ENTERPRISE_ID;
httpClient.MediaType = 'application/x-www-form-urlencoded';
var tokenRes = httpClient.Post();
if (!httpClient.IsSuccess) return;
var token = JSON.parse(tokenRes).access_token;

// Boxフォルダ内のファイル一覧を取得
httpClient.ResponseHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/2.0/folders/' + folderId
    + '/items?fields=id,name,size,modified_at&limit=100';
httpClient.RequestHeaders['Authorization'] = 'Bearer ' + token;
var listRes = httpClient.Get();
if (!httpClient.IsSuccess) return;
var files = JSON.parse(listRes).entries || [];

// データとUIの注入(トークンは含めない)
var configJson = JSON.stringify({ folderId: folderId, files: files })
    .replace(/</g, '\\u003c').replace(/>/g, '\\u003e');

context.AddResponse('Append', '#FieldSetGeneral',
    '<div id="bf-box-panel"'
    + ' data-config=\'' + configJson + '\'>'
    + '<div class="bf-header">'
    + '<span>Boxファイル</span>'
    + '</div>'
    + '<ul class="bf-file-list"></ul>'
    + '<div class="bf-upload-area">ここにファイルをドロップ、またはクリックして選択</div>'
    + '</div>'
);

ファイル一覧の初期データは data-config 属性にJSON形式で埋め込みます。アクセストークンはDOMに含めず、ブラウザがBox APIを呼び出す際は後述のトークンAPI経由で都度取得します。

拡張サーバスクリプト③ トークンAPI

ブラウザからの $p.apiGet 呼び出し時に、Boxのアクセストークンを取得して分類Bに設定します。API応答にのみ含まれ、データベースには保存されません。

ExtendedServerScripts/BoxTokenApi.json
{
    "BeforeOpeningPage": true,
    "Api": true,
    "TryCatch": true,
    "Body": "-- loaded from .json.js"
}
ExtendedServerScripts/BoxTokenApi.json.js
// 分類AにフォルダIDがなければ何もしない
var folderId = model.ClassA;
if (!folderId) return;

// === Box API設定 ===
var BOX_CLIENT_ID     = 'YOUR_CLIENT_ID';
var BOX_CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
var BOX_ENTERPRISE_ID = 'YOUR_ENTERPRISE_ID';

// アクセストークンの取得
httpClient.ResponseHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/oauth2/token';
httpClient.Content = 'grant_type=client_credentials'
    + '&client_id=' + BOX_CLIENT_ID
    + '&client_secret=' + BOX_CLIENT_SECRET
    + '&box_subject_type=enterprise'
    + '&box_subject_id=' + BOX_ENTERPRISE_ID;
httpClient.MediaType = 'application/x-www-form-urlencoded';
var tokenRes = httpClient.Post();
if (!httpClient.IsSuccess) return;
var token = JSON.parse(tokenRes).access_token;

// 分類Bにトークンを設定(API応答のみ、DBには保存されない)
model.ClassB = token;

$p.apiGet はセッション認証(Cookie)で動作するため、APIキーの設定は不要です。サーバスクリプトが model.ClassB に設定した値はAPI応答に含まれますが、読み取り専用の操作なのでデータベースには保存されません。

拡張スクリプト(ファイル操作UI)

クライアント側のスクリプトで、ファイルのアップロード・ダウンロード・削除を処理します。拡張スクリプトとして配置します。

ExtendedScripts/BoxField.json
{
    "Actions": ["edit"],
    "Body": "-- loaded from .json.js"
}
ExtendedScripts/BoxField.json.js
(function () {
    'use strict';

    var bf_config = null;
    var bf_uploadInput = null;

    // === 初期化 ===
    function bf_init() {
        var panel = document.getElementById('bf-box-panel');
        if (!panel || bf_config) return;
        try {
            bf_config = JSON.parse(panel.dataset.config);
        } catch (e) {
            return;
        }
        bf_renderFiles();
        bf_setupUpload(panel);
    }

    // === トークンの取得($p.apiGet経由) ===
    function bf_getToken(callback) {
        $p.apiGet({
            id: parseInt($('#Id').val(), 10),
            done: function (data) {
                var d = data.Response && data.Response.Data;
                if (d && d[0] && d[0].ClassB) {
                    callback(null, d[0].ClassB);
                } else {
                    callback(new Error('Token not available'));
                }
            },
            fail: function () {
                callback(new Error('API call failed'));
            }
        });
    }

    // === ファイルサイズのフォーマット ===
    function bf_formatSize(bytes) {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    }

    // === ファイル一覧の描画 ===
    function bf_renderFiles() {
        var panel = document.getElementById('bf-box-panel');
        if (!panel || !bf_config) return;
        var list = panel.querySelector('.bf-file-list');
        if (!list) return;
        var files = bf_config.files || [];
        if (files.length === 0) {
            list.innerHTML = '<li class="bf-empty">ファイルはありません</li>';
            return;
        }
        var html = '';
        files.forEach(function (file) {
            html += '<li class="bf-file-item" data-id="' + file.id + '">'
                + '<span class="bf-file-name" title="' + file.name + '">'
                + file.name + '</span>'
                + '<span class="bf-file-size">' + bf_formatSize(file.size) + '</span>'
                + '<button class="bf-btn bf-btn-download" data-id="'
                + file.id + '" data-name="' + file.name + '">'
                + 'DL</button>'
                + '<button class="bf-btn bf-btn-delete" data-id="'
                + file.id + '" data-name="' + file.name + '">'
                + '削除</button>'
                + '</li>';
        });
        list.innerHTML = html;
    }

    // === Box APIリクエスト(都度トークン取得) ===
    function bf_boxFetch(url, options) {
        bf_getToken(function (err, token) {
            if (err) {
                alert('トークンの取得に失敗しました');
                return;
            }
            options = options || {};
            options.headers = options.headers || {};
            options.headers['Authorization'] = 'Bearer ' + token;
            fetch(url, options)
                .then(function (res) {
                    if (options._onResponse) options._onResponse(res);
                })
                .catch(function () {
                    if (options._onError) options._onError();
                });
        });
    }

    // === ファイル一覧の再取得 ===
    function bf_refreshFiles() {
        bf_getToken(function (err, token) {
            if (err) return;
            fetch(
                'https://api.box.com/2.0/folders/' + bf_config.folderId
                + '/items?fields=id,name,size,modified_at&limit=100',
                { headers: { 'Authorization': 'Bearer ' + token } }
            )
            .then(function (res) { return res.json(); })
            .then(function (data) {
                bf_config.files = data.entries || [];
                bf_renderFiles();
            })
            .catch(function () {});
        });
    }

    // === ファイルのダウンロード ===
    function bf_downloadFile(fileId, fileName) {
        bf_getToken(function (err, token) {
            if (err) {
                alert('トークンの取得に失敗しました');
                return;
            }
            fetch('https://api.box.com/2.0/files/' + fileId + '/content', {
                headers: { 'Authorization': 'Bearer ' + token }
            })
            .then(function (res) { return res.blob(); })
            .then(function (blob) {
                var a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                URL.revokeObjectURL(a.href);
                document.body.removeChild(a);
            })
            .catch(function () {
                alert('ダウンロードに失敗しました');
            });
        });
    }

    // === ファイルの削除 ===
    function bf_deleteFile(fileId, fileName) {
        if (!confirm(fileName + ' を削除しますか?')) return;
        bf_getToken(function (err, token) {
            if (err) {
                alert('トークンの取得に失敗しました');
                return;
            }
            fetch('https://api.box.com/2.0/files/' + fileId, {
                method: 'DELETE',
                headers: { 'Authorization': 'Bearer ' + token }
            })
            .then(function (res) {
                if (res.status === 204) {
                    bf_refreshFiles();
                } else {
                    alert('削除に失敗しました');
                }
            })
            .catch(function () {
                alert('削除に失敗しました');
            });
        });
    }

    // === ファイルのアップロード ===
    function bf_uploadFile(file) {
        var panel = document.getElementById('bf-box-panel');
        if (!panel) return;

        // プログレスバーの表示
        var progressWrap = document.createElement('div');
        progressWrap.className = 'bf-progress';
        var progressInfo = document.createElement('div');
        progressInfo.className = 'bf-progress-info';
        var progressName = document.createElement('span');
        progressName.className = 'bf-progress-name';
        progressName.textContent = file.name;
        var progressPct = document.createElement('span');
        progressPct.className = 'bf-progress-pct';
        progressPct.textContent = '0%';
        progressInfo.appendChild(progressName);
        progressInfo.appendChild(progressPct);
        var progressTrack = document.createElement('div');
        progressTrack.className = 'bf-progress-track';
        var progressBar = document.createElement('div');
        progressBar.className = 'bf-progress-bar';
        progressBar.style.width = '0%';
        progressTrack.appendChild(progressBar);
        progressWrap.appendChild(progressInfo);
        progressWrap.appendChild(progressTrack);
        panel.appendChild(progressWrap);

        bf_getToken(function (err, token) {
            if (err) {
                progressWrap.remove();
                alert('トークンの取得に失敗しました');
                return;
            }

            var formData = new FormData();
            formData.append('attributes', JSON.stringify({
                name: file.name,
                parent: { id: bf_config.folderId }
            }));
            formData.append('file', file);

            var xhr = new XMLHttpRequest();
            xhr.open('POST', 'https://upload.box.com/api/2.0/files/content');
            xhr.setRequestHeader('Authorization', 'Bearer ' + token);

            xhr.upload.addEventListener('progress', function (e) {
                if (e.lengthComputable) {
                    var pct = Math.round(e.loaded / e.total * 100);
                    progressBar.style.width = pct + '%';
                    progressPct.textContent = pct + '%';
                }
            });

            xhr.addEventListener('load', function () {
                progressWrap.remove();
                if (xhr.status === 201) {
                    bf_refreshFiles();
                } else if (xhr.status === 409) {
                    alert(file.name + ' は既に存在します');
                } else {
                    alert('アップロードに失敗しました(ステータス: '
                        + xhr.status + '');
                }
            });

            xhr.addEventListener('error', function () {
                progressWrap.remove();
                alert('アップロードに失敗しました');
            });

            xhr.send(formData);
        });
    }

    // === アップロードエリアのセットアップ ===
    function bf_setupUpload(panel) {
        var area = panel.querySelector('.bf-upload-area');
        if (!area) return;

        // 非表示のfile inputを作成
        bf_uploadInput = document.createElement('input');
        bf_uploadInput.type = 'file';
        bf_uploadInput.multiple = true;
        bf_uploadInput.style.display = 'none';
        panel.appendChild(bf_uploadInput);

        // クリックでファイル選択
        area.addEventListener('click', function () {
            bf_uploadInput.click();
        });

        bf_uploadInput.addEventListener('change', function () {
            Array.prototype.forEach.call(bf_uploadInput.files, bf_uploadFile);
            bf_uploadInput.value = '';
        });

        // ドラッグ&ドロップ
        area.addEventListener('dragover', function (e) {
            e.preventDefault();
            area.classList.add('bf-dragover');
        });
        area.addEventListener('dragleave', function () {
            area.classList.remove('bf-dragover');
        });
        area.addEventListener('drop', function (e) {
            e.preventDefault();
            area.classList.remove('bf-dragover');
            Array.prototype.forEach.call(e.dataTransfer.files, bf_uploadFile);
        });

        // ファイル一覧のボタンイベント(イベント委譲)
        var list = panel.querySelector('.bf-file-list');
        if (list) {
            list.addEventListener('click', function (e) {
                var btn = e.target.closest('.bf-btn');
                if (!btn) return;
                var fileId = btn.dataset.id;
                var fileName = btn.dataset.name;
                if (btn.classList.contains('bf-btn-download')) {
                    bf_downloadFile(fileId, fileName);
                } else if (btn.classList.contains('bf-btn-delete')) {
                    bf_deleteFile(fileId, fileName);
                }
            });
        }
    }

    // === DOM読み込み後に初期化 ===
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', bf_init);
    } else {
        bf_init();
    }

    // === Ajax再描画後に再初期化 ===
    var bf_ajaxTimer = null;
    $(document).ajaxComplete(function () {
        clearTimeout(bf_ajaxTimer);
        bf_ajaxTimer = setTimeout(function () {
            var panel = document.getElementById('bf-box-panel');
            if (!panel && bf_config) {
                return;
            }
            if (panel && !bf_config) {
                bf_init();
            }
        }, 200);
    });
})();

スクリプトの解説

すべての関数名に bf_Box Field)プレフィックスを付けています。拡張スクリプトはグローバルスコープで読み込まれるため、他のスクリプトとの衝突を防いでいます。ただし即時実行関数で囲んでいるため、実際には外部からアクセスされることはありません。

  1. bf_init でサーバスクリプトが注入した data-config 属性からフォルダID・ファイル一覧を読み取ります
  2. bf_getToken$p.apiGet でプリザンターAPIを呼び出し、サーバスクリプト(BoxTokenApi)が分類Bに設定したアクセストークンを取得します。すべてのBox API呼び出しの直前に実行するため、トークンの有効期限切れの心配がありません
  3. bf_renderFiles でファイル一覧をHTML描画します。ファイルサイズは bf_formatSize でKB/MB表記に変換します
  4. bf_uploadFileXMLHttpRequest でBox Upload APIにファイルを送信します。fetch ではなく XMLHttpRequest を使っているのは、アップロード進捗(upload.progress イベント)を取得してファイル名とパーセント表示付きのプログレスバーを表示するためです
  5. bf_setupUpload でアップロードエリアのクリック・ドラッグ&ドロップ・ボタンイベントを設定します。ファイル一覧のボタンはイベント委譲で処理しているため、一覧が再描画されてもイベントハンドラを再登録する必要がありません
  6. $(document).ajaxComplete でプリザンターのAjax再描画に対応しています。自動ポストバックでDOMが更新された場合の再初期化に備えていますが、Box連携パネルは #FieldSetGeneral に追加されるため通常は再描画の影響を受けません

アップロードの仕組み

Box Upload APIはMultipart/form-data形式でファイルを受け付けます。ブラウザの FormData を使うことで、バイナリデータをそのまま送信できます。

POST https://upload.box.com/api/2.0/files/content
Authorization: Bearer {アクセストークン}
Content-Type: multipart/form-data

├─ attributes: {"name":"report.pdf","parent":{"id":"123456"}}
└─ file: (バイナリデータ)

アップロード先のURLは upload.box.com であり、通常のAPI(api.box.com)とは異なります。CORSの設定が正しければ、ブラウザから直接アップロードできます。

なぜクライアント側でアップロードするのか

サーバスクリプトの httpClient はリクエストボディを文字列(httpClient.Content)として送信するため、バイナリデータの送信には向いていません。ファイル一覧の取得やフォルダ作成はJSON形式なので httpClient で問題ありませんが、ファイルアップロードだけはブラウザ側の FormData を使って直接Box APIに送信する構成にしています。

セキュリティの考慮事項

観点 対策
Boxクライアント資格情報 サーバスクリプト内にのみ保持。ブラウザには露出しない
アクセストークン 操作の都度 $p.apiGet 経由でサーバスクリプトが発行。DOMに保持しない
CORSドメイン制限 Box Developer Consoleで許可するドメインを限定
ファイル操作の権限 Boxのサービスアカウントの権限スコープで制御

アクセストークンは $p.apiGet のAPI応答経由でブラウザに渡されます。プリザンターにログインできるユーザーであればトークンを取得可能なため、Boxアプリの権限スコープを必要最小限に設定し、サービスアカウントには対象フォルダへのアクセスのみを許可してください。

Boxサービスアカウントの利用について

本記事のClient Credentials Grantでは、アプリ作成時にBox側で自動生成されるサービスアカウントを使用します。これはBoxが公式に提供するサーバ間連携用のアカウントであり、ユーザーの共有アカウントとは異なります。

ただし、以下の点に注意してください。

  • サービスアカウント経由のファイル操作はすべてサービスアカウント名義でBox監査ログに記録されるため、プリザンターのどのユーザーが操作したかはBox側のログからは判別できません。操作履歴をユーザー単位で追跡する必要がある場合は、プリザンター側でログを記録する仕組みを別途検討してください
  • サービスアカウントの権限スコープは必要最小限に設定し、対象フォルダへのアクセスのみを許可してください
  • 導入前にBox管理者にサービスアカウントの利用範囲と権限を確認し、組織のセキュリティポリシーに沿った運用を行ってください

詳しくはBox公式ドキュメント(Client Credentials Grantのセットアップ)を参照してください。

まとめ

プリザンターの拡張機能だけで、添付ファイル項目のような操作感を持つBox連携フィールドを作成しました。

  • 拡張サーバスクリプトでBox APIの認証とフォルダ管理を行い、認証情報をサーバ側に保持する
  • レコード作成時にBoxにサブフォルダを自動作成し、フォルダIDを分類Aに保存する
  • 編集画面を開いたとき、サーバスクリプトがBoxからファイル一覧を取得し、UIを context.AddResponse で注入する
  • ファイル操作時は $p.apiGet 経由でアクセストークンを都度取得する(トークンをDOMに保持しない)
  • ファイルのアップロードはブラウザから直接Box Upload APIに送信する(httpClient がバイナリ非対応のため)
  • ダウンロード・削除もブラウザからBox APIを直接呼び出す
  • ドラッグ&ドロップによるアップロードとプログレスバーで直感的な操作感を実現する
  • 拡張スタイルとCSSカスタムプロパティでプリザンターのテーマに自動的に合わせる

同じアプローチを応用すれば、Google Drive、SharePoint、Amazon S3など他のクラウドストレージとの連携も実現できます。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?