Skip to content

Box 連携フィールド ​

第4版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

標準の添付ファイル項目はファイルをプリザンターのサーバに保存します。ファイルを Box で一元管理したい場合は、拡張機能だけで編集画面に添付ファイル項目のような見た目と操作感の Box 連携フィールドを作れます。 プリザンター側にはレコードごとの Box フォルダ ID だけを持ち、ファイル操作はすべて Box API で行います。Box の認証情報(クライアント ID・シークレット)はサーバースクリプトにだけ置き、ブラウザには操作の都度発行したアクセストークンだけを渡します。

全体の構成 ​

部品種類役割
BoxFolderSetup拡張サーバースクリプト(AfterCreate / AfterUpdate)レコードごとの Box サブフォルダを作成し、フォルダ ID を分類A に保存
BoxFieldView拡張サーバースクリプト(BeforeOpeningPage、編集画面)Box からファイル一覧を取得し、context.AddResponse でパネルを注入
BoxTokenApi拡張サーバースクリプト(WhenloadingRecord、API の get)$p.apiGet の応答の分類B にアクセストークンを載せる
BoxField拡張スクリプト(編集画面)アップロード・ダウンロード・削除・一覧更新をブラウザから Box API へ直接実行
BoxField.css拡張スタイルパネルの見た目

処理の流れは次のとおりです。

図を読み込み中…

  • レコード新規作成時: 保存後にサーバースクリプトが Box にフォルダを作成し、items.Update でフォルダ ID を分類A に保存する
  • 編集画面を開いたとき: サーバースクリプトがアクセストークンを取得してファイル一覧を取得し、パネルを画面に注入する(トークンは含めない)
  • ファイル操作時: ブラウザが $p.apiGet でトークンを要求 → サーバースクリプトが取得したトークンを分類B に入れて返す → ブラウザが Box API を直接呼ぶ

Box API の準備 ​

カスタムアプリの作成 ​

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

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

「構成」タブでクライアント ID とクライアントシークレット、「一般設定」タブでエンタープライズ ID を確認します。サーバー認証を使うため、「承認」タブから Box 管理者にアプリの承認を依頼します。

CORS の設定 ​

「構成」タブの「CORS ドメイン」にプリザンターの URL(例: https://your-pleasanter-domain.example.com)を追加します。

WARNING

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

親フォルダの準備 ​

Box 上にテーブル用の親フォルダを作成し、フォルダの URL(https://app.box.com/folder/{フォルダID})からフォルダ ID を確認します。レコードごとのサブフォルダはスクリプトが自動作成します。

テーブル設定 ​

「テーブルの管理」→「エディタ」で分類A・分類B を使います。どちらもエディタの列に追加する必要はありません。

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

INFO

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

拡張サーバースクリプト ​

① Box フォルダの自動作成 ​

json
{
    "AfterCreate": true,
    "AfterUpdate": true,
    "Actions": ["create", "update"],
    "Functionalize": true,
    "TryCatch": true
}
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.RequestHeaders.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.RequestHeaders.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.Add('Authorization', 'Bearer ' + token);
var folderRes = httpClient.Post();
if (!httpClient.IsSuccess) return;

// フォルダIDを分類Aに保存
items.Update(context.Id, JSON.stringify({
    ClassA: JSON.parse(folderRes).id
}));

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

AfterCreate / AfterUpdate はレコードを保存した後に動くため、ここで model.ClassA に代入してもデータベースには保存されません(作成後のサーバースクリプトの後に保存処理が無い。IssueModel.cs)。そのため items.Update で保存する形に修正しています。items.Update から呼ばれる更新後のスクリプトでは model.ClassA が入っているので、先頭の判定で処理を終えます。

スクリプトの先頭で return を使うため、.json に "Functionalize": true を付けています。サーバースクリプトの本文はそのまま V8 で実行され、Functionalize が無いと関数の外の return は構文エラーになります(ServerScriptUtilities.cs)。本文は .json.js から読み込まれるので、.json に Body は要りません。

httpClient.RequestHeaders は送信後も残るため、各リクエストの前に RequestHeaders.Clear() を呼んでから必要なヘッダを Add します。ResponseHeaders は送信のたびに内部でクリアされます(ServerScriptModelHttpClient.cs)。

② ファイル一覧の表示 ​

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

json
{
    "BeforeOpeningPage": true,
    "Actions": ["edit"],
    "Functionalize": true,
    "TryCatch": true
}
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.RequestHeaders.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.RequestHeaders.Clear();
httpClient.RequestUri = 'https://api.box.com/2.0/folders/' + folderId
    + '/items?fields=id,name,size,modified_at&limit=100';
httpClient.RequestHeaders.Add('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 に含めません。

③ トークン API ​

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

API のレコード取得では「画面表示の前」(BeforeOpeningPage)は動かず、レコードを読み込むときの「レコード読み込み時」(WhenloadingRecord)が動きます(IssueModel.cs)。また拡張サーバースクリプトに Api という設定項目はありません(ExtendedServerScript.cs)。そのため WhenloadingRecord を API の get アクションに限定する形に修正しています。WhenloadingRecord は更新処理でレコードを読み込むときにも動くので、Actions を外すと分類B にトークンが入ったまま保存されるおそれがあります。

json
{
    "WhenloadingRecord": true,
    "Actions": ["get"],
    "Functionalize": true,
    "TryCatch": true
}
js
// 分類AにフォルダIDがなければ何もしない
var folderId = model.ClassA;
if (!folderId) return;

// …(アクセストークンの取得は①と同じ)…

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

INFO

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

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

ExtendedScripts/BoxField.json("Actions": ["edit"])と BoxField.json.js を拡張スクリプトとして配置します。ここでは要となる部分を示します。全文はこの節の最後に載せています。

トークンの取得 ​

すべての Box API 呼び出しの直前に $p.apiGet でトークンを取得するため、有効期限切れを気にする必要がありません。

js
// === トークンの取得($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'));
        }
    });
}

アップロード ​

Box Upload API(https://upload.box.com/api/2.0/files/content。通常の api.box.com とは別)は multipart/form-data でファイルを受け付けます。ブラウザの FormData でバイナリをそのまま送ります。fetch ではなく XMLHttpRequest を使うのは、upload.progress イベントで進捗バーを表示するためです。

js
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.send(formData);
});

なぜアップロードだけブラウザから送るのか

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

その他の操作 ​

関数処理
bf_initパネルの data-config からフォルダ ID・ファイル一覧を読み取り、描画とイベント設定を行う
bf_renderFilesファイル一覧を描画(ファイル名・サイズ・DL ボタン・削除ボタン)
bf_refreshFilesGET https://api.box.com/2.0/folders/{folderId}/items?fields=id,name,size,modified_at&limit=100 で一覧を再取得して再描画
bf_downloadFileGET https://api.box.com/2.0/files/{fileId}/content の結果を Blob にしてダウンロード
bf_deleteFile確認後 DELETE https://api.box.com/2.0/files/{fileId}。204 なら一覧を再取得
bf_setupUploadクリックでのファイル選択、ドラッグ&ドロップ、一覧のボタン(イベント委譲)

実装上の工夫は次のとおりです。

  • 関数名に bf_(Box Field)プレフィックスを付け、全体を即時実行関数で囲んで他のスクリプトとの衝突を防ぐ
  • 一覧のボタンはイベント委譲で処理するため、再描画してもハンドラを再登録する必要がない
  • $(document).ajaxComplete で、Ajax による再描画でパネルが作り直された場合に再初期化する(パネルは #FieldSetGeneral に追加されるため、通常は影響を受けない)
ExtendedScripts/BoxField.json.js(全文)
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);
    });
})();

拡張スタイル ​

パネルの CSS を拡張スタイルとして App_Data/Parameters/ExtendedStyles/BoxField.css に配置します。var(--control-border, #cecece)・var(--base-bg, #fff)・var(--primaryColor, #106ebe) のような CSS 変数を使うと、プリザンターのテーマに合わせた見た目になります。

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-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.bf-dragover {
    border-color: var(--primaryColor, #106ebe);
    background: rgba(16, 110, 190, 0.05);
}
ExtendedStyles/BoxField.css(全文)
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;
}

セキュリティの考慮事項 ​

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

WARNING

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

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

  • サービスアカウント経由の操作はすべてサービスアカウント名義で Box の監査ログに記録されるため、プリザンターのどのユーザーが操作したかは Box 側のログからは判別できません。ユーザー単位の追跡が必要なら、プリザンター側でログを記録する仕組みを別途検討します
  • 導入前に Box 管理者にサービスアカウントの利用範囲と権限を確認し、組織のセキュリティポリシーに沿って運用します

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

関連ページ ​

変更履歴

第4版「外部連携・AI」を 1.5.8.1 のソースで検証して修正
第3版元記事への言及を整理し、必要なコードをページに収録。検索機能に一覧の検索と絞り込みを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「外部連携・AI」セクションの記事を追加