DriveAPIManager v3
Google Drive appdata スコープ管理ライブラリ — 全メソッドリファレンス
モード比較
client(デフォルト) | server | |
|---|---|---|
| サーバー | 不要(GitHub Pages 可) | 必要 |
| CLIENT_SECRET | コードに含める | サーバー側で管理 |
| PKCE | ✅ | ❌(不要) |
| refresh_token 保存先 | AES-GCM 暗号化 → localStorage | サーバー側セッション |
| access_token 保存先 | メモリ(#プライベートフィールド)共通 | |
セットアップ — client モード
GitHub Pages など、サーバーなしで動作します。clientSecret は Google Cloud Console の「ウェブアプリケーション」タイプで発行したものを指定してください。
clientSecret はソースコードに含まれるため、公開リポジトリでは誰でも読めます。
drive.appdata スコープ限定なのでそのアプリのデータしか操作できませんが、
より高いセキュリティが必要な場合は server モードを使用してください。
import DriveAPIManager from './DriveAPIManager.js';
const drive = new DriveAPIManager({
clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
clientSecret: 'YOUR_CLIENT_SECRET', // ウェブアプリタイプに必要
redirectUri: 'https://yourname.github.io/yourrepo/oauth2callback',
// mode: 'client' はデフォルトなので省略可
progress: (phase, detail) => console.log(`[${phase}]`, detail),
});
セットアップ — server モード
CLIENT_SECRET をサーバー側で管理します。クライアントには access_token だけ渡し、refresh_token はサーバーセッションに保持します。
const drive = new DriveAPIManager({
clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
redirectUri: 'https://yourapp.com/oauth2callback',
mode: 'server',
tokenUrl: '/auth/token', // POST code → {access_token, expires_in}
refreshUrl: '/auth/refresh', // POST → {access_token, expires_in}
revokeUrl: '/auth/revoke', // POST → 200
});
サーバー側 Express 実装例:
// POST /auth/token — code を受け取り、refresh_token をセッションに保持
app.post('/auth/token', async (req, res) => {
const { code, redirect_uri } = req.body;
const r = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
grant_type: 'authorization_code', code, redirect_uri,
}),
});
const data = await r.json();
req.session.refresh_token = data.refresh_token; // サーバー側で保持
res.json({ access_token: data.access_token, expires_in: data.expires_in });
});
// POST /auth/refresh
app.post('/auth/refresh', async (req, res) => {
const rt = req.session.refresh_token;
if (!rt) return res.status(401).json({ error: 'not_authenticated' });
const r = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
grant_type: 'refresh_token', refresh_token: rt,
}),
});
const data = await r.json();
res.json({ access_token: data.access_token, expires_in: data.expires_in });
});
// POST /auth/revoke
app.post('/auth/revoke', async (req, res) => {
const rt = req.session.refresh_token;
if (rt) await fetch(`https://oauth2.googleapis.com/revoke?token=${rt}`, { method: 'POST' });
req.session.destroy();
res.json({ ok: true });
});
oauth2callback.html
Google がリダイレクトしてくる着地ページ。これをリポジトリに置いて、Google Cloud Console の「承認済みリダイレクト URI」に登録してください。
<!-- yourrepo/oauth2callback.html -->
<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body><script>
const p = new URLSearchParams(location.search);
window.opener?.postMessage({
type: 'oauth_callback',
code: p.get('code'),
state: p.get('state'),
error: p.get('error'),
}, location.origin);
window.close();
</script></body>
</html>
AUTH
auth(silent?) 両モード
メインの認証エントリ。内部で以下の順に試みます:
- メモリに有効な access_token がある → そのまま返す
- client: 暗号化 RT を復号してリフレッシュ / server:
/auth/refreshを叩く silent=falseのときのみ → ポップアップを開く
| エラーコード | 意味 |
|---|---|
NOT_AUTHENTICATED | トークンなし(silent=true 時) |
REFRESH_FAILED | リフレッシュ失敗 |
POPUP_BLOCKED | ポップアップがブロックされた |
POPUP_CLOSED | ユーザーが閉じた |
AUTH_DENIED | アクセス拒否 |
AUTH_TIMEOUT | 5 分タイムアウト |
STATE_MISMATCH | CSRF 検知 |
DECRYPT_FAILED | 保存済み RT の復号失敗(client のみ) |
SERVER_TOKEN_FAILED | サーバー側コード交換失敗(server のみ) |
SERVER_REFRESH_FAILED | サーバー側リフレッシュ失敗(server のみ) |
// 初回ログイン(ポップアップを許可)
await drive.auth(false);
// ページ読み込み時のサイレント復元
try {
await drive.auth(true);
showApp();
} catch (e) {
if (e.code === 'NOT_AUTHENTICATED') showLoginButton();
}
// エラーハンドリング例
try {
await drive.auth(false);
} catch (e) {
switch (e.code) {
case 'POPUP_BLOCKED': alert('ポップアップを許可してください'); break;
case 'AUTH_DENIED': alert('アクセスを許可してください'); break;
default: console.error(e);
}
}
checker() 両モード
同期。API 呼び出しなし。ページ読み込み時の UI 出し分けに使います。
const status = drive.checker();
// {
// loggedIn: true, // トークンあり(有効期限問わず)
// expired: false, // access_token が期限切れか
// hasRefreshToken: true, // RT あり
// mode: 'client' // 'client' | 'server'
// }
// 典型的な使い方
if (drive.checker().hasRefreshToken) {
await drive.auth(true); // サイレントで復元
showApp();
} else {
showLoginButton();
}
getEmail() 両モード
const email = await drive.getEmail();
console.log(email); // 'user@gmail.com'
document.getElementById('user-label').textContent = await drive.getEmail();
signOut() 両モード
client: Google サーバーに revoke → localStorage 消去
server: /auth/revoke に POST → メモリ消去
document.getElementById('logout').addEventListener('click', async () => {
await drive.signOut();
location.href = '/';
});
パス解決
パスは / 区切り。先頭の / は任意('/saves/slot1.json' と 'saves/slot1.json' は同じ)。
getFileId(path)
パス → Drive ファイル ID。なければ null。内部キャッシュ付きで同じパスの 2 回目以降は API なし。
const id = await drive.getFileId('/saves/slot1.json');
// 'aBcDeFgHiJkLmNoPqRsTuV' または null
// 存在チェック
const exists = (await drive.getFileId('/config/settings.json')) !== null;
getPath(fileId)
const path = await drive.getPath('aBcDeFgHiJkLmNoPqRsTuV');
// '/saves/slot1.json'
// search() の結果からパスを逆引き
const results = await drive.search('slot');
for (const f of results) {
console.log(await drive.getPath(f.id), f.size);
}
フォルダ
createFolder(path)
フォルダを冪等に作成(中間フォルダも自動作成、既存はスキップ)。返り値は末端フォルダの ID。
const folderId = await drive.createFolder('/saves/2024/april');
// '/saves', '/saves/2024', '/saves/2024/april' を順に作成(存在すればスキップ)
ファイル CRUD
getFile(pathOrId, as?)
as | 戻り値 | 自動選択される拡張子 |
|---|---|---|
'auto'(デフォルト) | 拡張子で自動判定 | — |
'json' | object | .json |
'text' | string | .txt .md .csv .tsv .html .xml |
'arraybuffer' | ArrayBuffer | .png .jpg .jpeg .gif .webp .pdf .zip .bin |
'blob' | Blob | — |
// JSON(拡張子から自動で object)
const config = await drive.getFile('/config/settings.json');
// テキスト
const readme = await drive.getFile('/docs/readme.md', 'text');
// 画像 → <img> に表示
const buf = await drive.getFile('/assets/icon.png', 'arraybuffer');
const url = URL.createObjectURL(new Blob([buf], { type: 'image/png' }));
document.getElementById('icon').src = url;
// ID で取得
const data = await drive.getFile('aBcDeFgHiJkLmNoPqRsTuV', 'json');
// ファイルが存在しない場合のハンドリング
try {
const save = await drive.getFile('/saves/slot3.json');
} catch (e) {
if (e.code === 'FILE_NOT_FOUND') console.log('セーブデータなし');
}
saveFile(path, data, opts?)
作成 or 上書き保存。親フォルダは自動作成。
// JSON
const file = await drive.saveFile('/config/settings.json', {
theme: 'dark', volume: 0.8, lang: 'ja',
});
console.log(file.id, file.modifiedTime);
// テキスト
await drive.saveFile('/logs/today.txt', '起動: 09:00\n終了: 17:30');
// ArrayBuffer(画像)
const buf = await (await fetch('/local/avatar.png')).arrayBuffer();
await drive.saveFile('/profile/avatar.png', buf, { mimeType: 'image/png' });
// canvas → PNG
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
await drive.saveFile('/screenshots/shot1.png', blob, { mimeType: 'image/png' });
// appProperties(Drive の独自 KV)を付与
await drive.saveFile('/saves/slot1.json', saveData, {
meta: { appProperties: { version: '2', stage: '5' } },
});
copyFile(srcPathOrId, destPath)
// バックアップ
await drive.copyFile('/saves/slot1.json', '/backups/slot1_20240401.json');
// ID から
await drive.copyFile('aBcDeFgHiJkLmNoPqRsTuV', '/saves/slot2.json');
moveFile(pathOrId, newPath)
// リネーム
await drive.moveFile('/saves/draft.json', '/saves/final.json');
// 別フォルダへ移動(フォルダは自動作成)
await drive.moveFile('/tmp/data.json', '/saves/2024/data.json');
// 移動 + リネーム同時
await drive.moveFile('/inbox/report.json', '/archive/2024/report_done.json');
一覧 / 構造
listFiles(pathOrId?)
フォルダ直下の一覧。引数なし・''・'/' で appDataFolder 直下。
// ルート直下
const root = await drive.listFiles();
// フォルダか判定しながら表示
for (const f of await drive.listFiles('/saves')) {
const isFolder = f.mimeType === 'application/vnd.google-apps.folder';
const size = f.size ? `${(f.size/1024).toFixed(1)} KB` : '-';
const date = new Date(f.modifiedTime).toLocaleString('ja-JP');
console.log(`${isFolder ? '📁' : '📄'} ${f.name} ${size} ${date}`);
}
getStructure()
appDataFolder 全体のツリーを 1 回の API で取得。DriveNode = { id, name, type:'folder'|'file', size?, modifiedTime?, children? }
const tree = await drive.getStructure();
// 全ファイルをフラットに列挙
function flatten(node, path = '') {
const cur = path + '/' + node.name;
if (node.type === 'file') return [{ path: cur, size: node.size, id: node.id }];
return (node.children ?? []).flatMap(c => flatten(c, cur));
}
const all = flatten(tree);
// ツリーを <ul> でレンダリング
function renderTree(node, ul = document.createElement('ul')) {
const li = document.createElement('li');
li.textContent = (node.type === 'folder' ? '📁 ' : '📄 ') + node.name;
ul.appendChild(li);
if (node.children) {
const sub = document.createElement('ul');
node.children.forEach(c => renderTree(c, sub));
li.appendChild(sub);
}
return ul;
}
document.body.appendChild(renderTree(tree));
検索 / メタデータ
search(query, opts?)
// 名前に 'slot' を含む
const results = await drive.search('slot');
// JSON ファイルのみ
const jsonFiles = await drive.search('', { mimeType: 'application/json' });
// 件数制限(デフォルト 50)
const top5 = await drive.search('save', { limit: 5 });
getMeta(pathOrId, fields?)
// デフォルト(id, name, mimeType, size, modifiedTime, parents)
const meta = await drive.getMeta('/saves/slot1.json');
// appProperties も取得
const m = await drive.getMeta('/saves/slot1.json', 'id,name,appProperties');
console.log(m.appProperties); // { version: '2', stage: '5' }
updateMeta(pathOrId, meta)
// リネーム
await drive.updateMeta('/saves/slot1.json', { name: 'slot1_cleared.json' });
// appProperties 更新
await drive.updateMeta('/saves/slot1.json', {
appProperties: { version: '3', savedAt: new Date().toISOString() },
});
削除
remove(pathOrId)
await drive.remove('/tmp/draft.json');
await drive.remove('/tmp'); // フォルダごと
await drive.remove('aBcDeFgHiJkLmNoPqRsTuV'); // ID でも可
removeAll()
if (confirm('全データを削除しますか?')) {
await drive.removeAll();
}
実装パターン
ゲームのセーブ・ロード
const drive = new DriveAPIManager({ clientId: '...', clientSecret: '...' });
// 起動時
async function init() {
if (drive.checker().hasRefreshToken) {
await drive.auth(true);
loadGame();
} else {
document.getElementById('login').style.display = 'block';
}
}
document.getElementById('login').addEventListener('click', async () => {
await drive.auth(false);
loadGame();
});
// セーブ
async function save(slot) {
const data = { stage: currentStage, score, timestamp: Date.now() };
await drive.saveFile(`/saves/slot${slot}.json`, data, {
meta: { appProperties: { stage: String(currentStage) } },
});
}
// ロード
async function load(slot) {
try {
return await drive.getFile(`/saves/slot${slot}.json`);
} catch (e) {
if (e.code === 'FILE_NOT_FOUND') return null;
throw e;
}
}
// セーブ一覧
async function getSaveList() {
const files = await drive.listFiles('/saves');
return files.map(f => ({
slot: f.name.replace('slot', '').replace('.json', ''),
date: new Date(f.modifiedTime).toLocaleString('ja-JP'),
size: f.size,
}));
}
設定の同期
const DEFAULTS = { theme: 'light', lang: 'ja', volume: 1.0 };
async function loadPrefs() {
try {
return { ...DEFAULTS, ...await drive.getFile('/config/prefs.json') };
} catch (e) {
if (e.code === 'FILE_NOT_FOUND') return DEFAULTS;
throw e;
}
}
async function savePrefs(prefs) {
await drive.saveFile('/config/prefs.json', prefs);
}
画像アップロード+ギャラリー表示
// input[type=file] からアップロード
document.getElementById('upload').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
await drive.saveFile(`/images/${file.name}`, file, { mimeType: file.type });
});
// ギャラリー表示
async function showGallery() {
const images = await drive.search('', { mimeType: 'image/png', limit: 50 });
for (const f of images) {
const buf = await drive.getFile(f.id, 'arraybuffer');
const img = document.createElement('img');
img.src = URL.createObjectURL(new Blob([buf], { type: 'image/png' }));
document.getElementById('gallery').appendChild(img);
}
}
全データのエクスポート(ダウンロード)
async function exportAll() {
const tree = await drive.getStructure();
const allFiles = [];
async function walk(node, path) {
if (node.type === 'file') {
const content = await drive.getFile(node.id, 'text');
allFiles.push({ path: path + '/' + node.name, content });
} else {
for (const child of node.children ?? [])
await walk(child, path + '/' + node.name);
}
}
await walk(tree, '');
const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'export.json';
a.click();
}
progress コールバックで進捗表示
const drive = new DriveAPIManager({
clientId: '...',
clientSecret: '...',
progress: (phase, detail) => {
const map = {
'auth:start': '認証中…',
'auth:memory_hit': '✅ 認証済み',
'auth:refreshing': '🔄 トークン更新中…',
'auth:done': '✅ ログイン完了',
'auth:signed_out': 'サインアウトしました',
};
const key = `${phase}:${detail}`;
const prefix = {
saveFile: '💾 保存中: ',
createFolder: '📁 フォルダ作成: ',
remove: '🗑 削除: ',
removeAll: '🗑 全消去: ',
};
const msg = map[key] ?? (prefix[phase] ? prefix[phase] + detail : null);
if (msg) document.getElementById('status').textContent = msg;
},
});