life-seedロゴ

NEWS

Googleドライブの”リンクを知っている全員”の共有リンクを削除するプログラム

Googleドライブの”リンクを知っている全員”の共有リンクを削除するプログラム

Googleドライブを利用して、PPAP対策や大きなファイルを送る際に”リンクを知っている人全員”という共有設定を使うことがあるかと思います。

その設定をしたままだと、誰にでも見られてしまう可能性があります。

それで、期限を設定してリンク設定を解除するGoogleAppscriptプログラムを作りました。

 

まずはGoogleスプレッドシートに公開リンクになっているファイルIDをリスト化する。

ファイルID,解除期限日(検出日+デフォルト7日),処理状況,権限

findPubliclySharedFiles

で、解除期限日到来したファイルIDの公開リンクを解除する。

checkAndRemovePublicAccess

これをAppscriptのトリガーで起動させることで、公開リンクの解除を自動化できます。

これで、外部へのファイルの送信にGoogleドライブを活用できるようになりますが、なりすましアクセスポイントでこのURLにアクセスしてしまった場合、期限日以内であれば、なりすましアクセスポイントを設置した悪意のある人にファイルが閲覧されてしまいます。

そのリスクに対応するには下記のような、公開リンクにパスワードのかけられるオンライストレージの仕組みが必要です。

BitoB Cloud サービス

ドメイン全体の委任 設定手順

ステップ 1: スクリプトのプロジェクト番号(Client ID)を取得する


まず、Google Admin Consoleで委任設定を行うために、GASスクリプトを一意に識別するID(クライアントID)が必要です。
  1. Apps Scriptエディタを開きます。
  2. 左側のメニューから 「⚙️ プロジェクトの設定」 (Settings) をクリックします。
  3. **「Google Cloud Platform(GCP)プロジェクト」**のセクションを探します。
  4. GCPプロジェクト番号のリンクをクリックし、Google Cloud Consoleに移動します。
    • (もしGASとGCPプロジェクトの連携がまだの場合、ここで「プロジェクトの変更」または「既存のプロジェクトにリンク」を選択する必要があります。)
  5. GCP Consoleの左側メニューから 「APIとサービス」 > 「認証情報」 を選択します。
  6. 「OAuth 2.0 クライアント ID」 のリストにある、GASプロジェクトに対応するクライアントID(長い文字列)をコピーします。これが委任設定に必要なIDです。

  7. Google Drive APIを有効化

    1. GCP Consoleの左側のナビゲーションメニューで 「APIとサービス」 > 「ライブラリ」 をクリックします。
    2. 検索バーで 「Google Drive API」 を検索します。
    3. 検索結果から「Google Drive API」を選択し、ページ内の 「有効にする」 ボタンをクリックします。
  8.  

ステップ 2: Google Workspace 管理コンソールで委任を設定する


次に、コピーしたクライアントIDと、必要な権限スコープを管理コンソールに登録します。
  1. Google Workspace 管理コンソールに特権管理者アカウントでログインします。
  2. 左側メニューから 「セキュリティ」 > 「アクセスとデータ管理」 > 「APIの制御」 へ進みます。
  3. ページの下部にある 「ドメイン全体の委任」 のセクションを探し、「新しいクライアント ID を追加」 をクリックします。
  4. 以下の情報を入力します。
  5. **「承認」**をクリックして保存します。
これで、スクリプトが組織全体にわたるファイル検索と共有解除を行うための最高レベルの権限が確立されました。この設定が完了すれば、GASのトリガーが組織全体のファイルを管理できるようになります。
 

Google Appscriptエディタにて

 

A. Advanced Drive Service の有効化

スクリプトが組織のファイルにアクセスするために、より強力な Google Drive API を利用できるように設定します。
  1. Google Apps Scriptエディタを開きます。
  2. 左側メニューの 「サービス」 (Services / + アイコンがあるかもしれません) をクリックします。
  3. 一覧から 「Drive API」 を探し、クリックして有効化します。
  4. 識別子(Identifier)が Drive となっていることを確認してください。
  5.  

スクリプトエディタの「⏰ トリガー」メニューから、以下の設定で2つのトリガーを作成してください。

 

1. 検索トリガーの設定

設定項目
設定値
実行する関数を選択
findPubliclySharedFiles
イベントのソースを選択
時間主導型
時間ベースのトリガーのタイプを選択
日タイマー
時刻を選択
毎日午前 6時〜7時 (推奨)

 

2. 解除トリガーの設定

設定項目
設定値
実行する関数を選択
checkAndRemovePublicAccess
イベントのソースを選択
時間主導型
時間ベースのトリガーのタイプを選択
日タイマー
時刻を選択
毎日午前 0時〜1時 (推奨)

※2025/12/16 検索除外フォルダの下層フォルダが除外されない処理を修正しました。

※2025/12/26 共有ドライブ内の、公開リンクが検出されない処理を修正しました。

※2026/1/9  共有ドライブ内の、公開リンクが検出されない処理を修正しました。

				
					// ==========================================================
// 🚨 設定項目: ここを必ず変更してください 🚨
// ==========================================================
// 1. 管理用スプレッドシートのURLを貼り付けてください
const SPREADSHEET_URL = '【ここに管理用スプレッドシートのURLを貼り付けてください】'; 
const SHEET_NAME = 'シート1'; // シート名が異なる場合は変更してください

// 2. 新規発見ファイルにデフォルトで設定する共有解除期限日(例:検索日から7日後)
const DEFAULT_EXPIRATION_DAYS = 7; 

// 3. 検索対象から除外したいGoogleドライブフォルダのIDを配列で指定します。
const EXCLUDED_FOLDER_IDS = []; 

// 4. スクリプトの実行制限時間(秒)。
// 処理を安全に中断して保存するための猶予を持たせます。
const TIME_LIMIT_SECONDS = 240; // 4分

// ==========================================================

// プロパティキーの定数
const PROP = {
  PHASE: 'SCAN_PHASE',             // 現在のフェーズ (USER, DRIVE_LIST, DRIVE_SCAN, COMPLETED)
  FILE_TOKEN: 'FILE_PAGE_TOKEN',   // ファイル一覧のページトークン
  DRIVE_TOKEN: 'DRIVE_PAGE_TOKEN', // ドライブ一覧のページトークン
  PENDING_DRIVES: 'PENDING_DRIVES',// 未処理のドライブリスト(JSON)
  CURRENT_DRIVE: 'CURRENT_DRIVE',  // 現在スキャン中のドライブ情報(JSON)
  TOTAL_COUNT: 'TOTAL_PROCESSED',  // 処理した総数
  NEW_COUNT: 'NEW_FOUND',          // 新規発見数
  TRIGGER_ID: 'CONTINUATION_TRIGGER_ID' // ⏳ 自動継続トリガーのID
};

const FOLDER_EXCLUSION_CACHE = {}; 

/**
 * スプレッドシートの最終行に新しいデータを追記します。
 */
function appendRow(sheet, rowData) {
  sheet.appendRow(rowData);
}

/**
 * 指定されたフォルダIDが除外対象か確認
 */
function isFolderExcludedRecursive(folderId, targetExcludedIds) {
  if (FOLDER_EXCLUSION_CACHE.hasOwnProperty(folderId)) return FOLDER_EXCLUSION_CACHE[folderId];
  if (targetExcludedIds.includes(folderId)) return (FOLDER_EXCLUSION_CACHE[folderId] = true);
  try {
    const folder = Drive.Files.get(folderId, { fields: 'id, parents', supportsAllDrives: true });
    if (!folder.parents || folder.parents.length === 0) return (FOLDER_EXCLUSION_CACHE[folderId] = false);
    for (const parent of folder.parents) {
      const parentId = parent.id || parent;
      if (isFolderExcludedRecursive(parentId, targetExcludedIds)) return (FOLDER_EXCLUSION_CACHE[folderId] = true);
    }
    return (FOLDER_EXCLUSION_CACHE[folderId] = false);
  } catch (e) {
    return false;
  }
}

/**
 * ファイルが除外対象かどうかを判定します。
 */
function isFileExcluded(file, excludedIds) {
  if (!excludedIds || excludedIds.length === 0) return false;
  if (file.driveId && excludedIds.includes(file.driveId)) return true;
  if (!file.parents || file.parents.length === 0) return false;
  for (const parent of file.parents) {
    const parentId = parent.id || parent;
    if (isFolderExcludedRecursive(parentId, excludedIds)) return true;
  }
  return false;
}

/**
 * ファイルの権限を個別に再確認する関数
 */
function getAnyonePermissionExplicitly(fileId) {
  try {
    const response = Drive.Permissions.list(fileId, { supportsAllDrives: true });
    const perms = response.permissions || response.items;
    if (perms && perms.length > 0) {
      return perms.find(p => p.type === 'anyone');
    }
  } catch (e) {
    // エラーは無視
  }
  return null;
}

/**
 * 自動継続用のトリガーを作成し、そのIDを保存する関数
 */
function createContinuationTrigger() {
  Logger.log('⏳ 続きを自動実行するためのトリガー(1分後)を作成します...');
  const trigger = ScriptApp.newTrigger('findPubliclySharedFiles')
           .timeBased()
           .after(60 * 1000) // 1分後に実行
           .create();
  
  // 作成したトリガーのIDを保存しておく
  PropertiesService.getScriptProperties().setProperty(PROP.TRIGGER_ID, trigger.getUniqueId());
}

/**
 * 前回作成した自動継続トリガーを削除する関数
 * 定期実行用のトリガーを消さないように、IDで照合して削除します。
 */
function deleteContinuationTrigger() {
  const props = PropertiesService.getScriptProperties();
  const triggerId = props.getProperty(PROP.TRIGGER_ID);
  
  if (!triggerId) return;

  const triggers = ScriptApp.getProjectTriggers();
  for (const trigger of triggers) {
    if (trigger.getUniqueId() === triggerId) {
      ScriptApp.deleteTrigger(trigger);
      Logger.log(`🗑️ 使用済みの自動継続トリガーを削除しました (ID: ${triggerId})`);
      break;
    }
  }
  // プロパティからも削除
  props.deleteProperty(PROP.TRIGGER_ID);
}

/**
 * メイン関数: 公開共有ファイルを検索(自動継続機能付き)
 */
function findPubliclySharedFiles() {
  // 実行開始時に、もし自分自身を呼び出した古い継続トリガーがあれば削除する
  deleteContinuationTrigger();

  const startTime = new Date().getTime(); 
  const props = PropertiesService.getScriptProperties();
  
  if (SPREADSHEET_URL.includes('【')) {
    Logger.log('エラー: スプレッドシートURLが未設定です。');
    return;
  }
  
  let ss, sheet;
  try {
    ss = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
    sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) { Logger.log(`エラー: シート「${SHEET_NAME}」が見つかりません。`); return; }
  } catch (e) {
    Logger.log(`エラー: スプレッドシート接続失敗: ${e.message}`);
    return;
  }

  const existingIds = new Set();
  const values = sheet.getDataRange().getValues(); 
  for (let i = 1; i < values.length; i++) {
    if (values[i][0]) existingIds.add(values[i][0]);
  }

  let phase = props.getProperty(PROP.PHASE) || 'USER';
  let filePageToken = props.getProperty(PROP.FILE_TOKEN);
  let drivePageToken = props.getProperty(PROP.DRIVE_TOKEN);
  let pendingDrives = JSON.parse(props.getProperty(PROP.PENDING_DRIVES) || '[]');
  let currentDrive = JSON.parse(props.getProperty(PROP.CURRENT_DRIVE) || 'null');
  let totalProcessed = parseInt(props.getProperty(PROP.TOTAL_COUNT) || '0');
  let newFilesFound = parseInt(props.getProperty(PROP.NEW_COUNT) || '0');

  // 前回完了していたらリセットして新規スタート
  if (phase === 'COMPLETED') {
    Logger.log('前回のスキャンは完了しています。新しいスキャンを開始します。');
    phase = 'USER';
    filePageToken = null;
    drivePageToken = null;
    pendingDrives = [];
    currentDrive = null;
    totalProcessed = 0;
    newFilesFound = 0;
    props.deleteAllProperties();
  }

  Logger.log(`🔄 処理開始: フェーズ=${phase}, 処理済み=${totalProcessed}件`);

  try {
    while (true) {
      // タイムアウトチェック
      if ((new Date().getTime() - startTime) / 1000 > TIME_LIMIT_SECONDS) {
        Logger.log('⏳ 時間制限に達しました。状態を保存して中断します。');
        saveState(props, phase, filePageToken, drivePageToken, pendingDrives, currentDrive, totalProcessed, newFilesFound);
        createContinuationTrigger(); // 🚨 自動継続トリガー作成
        return;
      }

      // --- フェーズ1: マイドライブのスキャン ---
      if (phase === 'USER') {
        Logger.log(`📂 マイドライブをスキャン中... (Token: ${filePageToken ? 'あり' : 'なし'})`);
        const result = processFileList(
          { q: "trashed = false", corpora: 'user', fields: 'nextPageToken, files(id, name, mimeType, trashed, parents, driveId)', pageSize: 1000 },
          filePageToken, sheet, existingIds, startTime, totalProcessed, newFilesFound
        );
        
        filePageToken = result.nextPageToken;
        totalProcessed = result.totalProcessed;
        newFilesFound = result.newFilesFound;

        if (result.timeOut) {
          saveState(props, phase, filePageToken, drivePageToken, pendingDrives, currentDrive, totalProcessed, newFilesFound);
          createContinuationTrigger(); // 🚨 自動継続トリガー作成
          return;
        }

        if (!filePageToken) {
          Logger.log('✅ マイドライブのスキャン完了。次は共有ドライブ一覧を取得します。');
          phase = 'DRIVE_LIST';
        }
      }

      // --- フェーズ2: 共有ドライブ一覧の取得 ---
      else if (phase === 'DRIVE_LIST') {
        if (pendingDrives.length === 0) {
          Logger.log(`🔍 共有ドライブの一覧を取得中...`);
          const drivesRes = Drive.Drives.list({
            pageSize: 50,
            pageToken: drivePageToken,
            useDomainAdminAccess: true
          });
          
          const fetchedDrives = drivesRes.drives || drivesRes.items || [];
          if (fetchedDrives.length > 0) {
            const validDrives = fetchedDrives.filter(d => !EXCLUDED_FOLDER_IDS.includes(d.id))
                                             .map(d => ({id: d.id, name: d.name}));
            pendingDrives = pendingDrives.concat(validDrives);
            Logger.log(`➕ ${validDrives.length} 個の共有ドライブをキューに追加しました。`);
          }
          
          drivePageToken = drivesRes.nextPageToken;
        }

        if (pendingDrives.length > 0) {
          phase = 'DRIVE_SCAN';
        } else if (!drivePageToken) {
          Logger.log('✅ すべての共有ドライブ一覧を取得しました。');
          phase = 'COMPLETED'; 
        }
      }

      // --- フェーズ3: 共有ドライブのスキャン ---
      else if (phase === 'DRIVE_SCAN') {
        if (!currentDrive) {
          if (pendingDrives.length === 0) {
            phase = drivePageToken ? 'DRIVE_LIST' : 'COMPLETED';
            continue;
          }
          currentDrive = pendingDrives.shift();
          filePageToken = null; 
          Logger.log(`▶ 共有ドライブ "${currentDrive.name}" のスキャンを開始します。`);
        }

        Logger.log(`📂 スキャン中: "${currentDrive.name}" (ID: ${currentDrive.id})`);
        
        const result = processFileList(
          { 
            q: "trashed = false", 
            corpora: 'drive', 
            driveId: currentDrive.id,
            includeItemsFromAllDrives: true,
            supportsAllDrives: true,
            fields: 'nextPageToken, files(id, name, mimeType, trashed, parents, driveId)',
            pageSize: 1000 
          },
          filePageToken, sheet, existingIds, startTime, totalProcessed, newFilesFound
        );

        filePageToken = result.nextPageToken;
        totalProcessed = result.totalProcessed;
        newFilesFound = result.newFilesFound;

        if (result.timeOut) {
          saveState(props, phase, filePageToken, drivePageToken, pendingDrives, currentDrive, totalProcessed, newFilesFound);
          createContinuationTrigger(); // 🚨 自動継続トリガー作成
          return;
        }

        if (!filePageToken) {
          Logger.log(`✅ 共有ドライブ "${currentDrive.name}" のスキャン完了。`);
          currentDrive = null; 
        }
      }

      // --- 完了 ---
      else if (phase === 'COMPLETED') {
        Logger.log('🎉 すべてのスキャンが完了しました!');
        Logger.log(`結果: 総スキャン数: ${totalProcessed}, 新規追加: ${newFilesFound}`);
        props.deleteAllProperties(); 
        return;
      }
    }

  } catch (e) {
    Logger.log(`❌ エラー発生: ${e.message} \n ${e.stack}`);
    saveState(props, phase, filePageToken, drivePageToken, pendingDrives, currentDrive, totalProcessed, newFilesFound);
  }
}

/**
 * ファイルリスト処理の共通関数
 */
function processFileList(requestOptions, pageToken, sheet, existingIds, startTime, totalProcessed, newFilesFound) {
  requestOptions.pageToken = pageToken;
  let timeOut = false;
  
  try {
    const response = Drive.Files.list(requestOptions);
    const files = response.files || response.items || [];
    
    for (const file of files) {
      totalProcessed++;
      
      if ((new Date().getTime() - startTime) / 1000 > TIME_LIMIT_SECONDS) {
        timeOut = true;
        break;
      }

      if (file.trashed || file.mimeType === 'application/vnd.google-apps.folder') continue;
      if (existingIds.has(file.id)) continue;
      if (EXCLUDED_FOLDER_IDS.length > 0 && isFileExcluded(file, EXCLUDED_FOLDER_IDS)) continue;

      const anyonePermission = getAnyonePermissionExplicitly(file.id);
      
      if (anyonePermission) {
        const fileName = file.name || file.title || "Unknown";
        Logger.log(`🔎 発見: ${fileName} (${file.id})`);
        
        const defaultExpirationDate = new Date();
        defaultExpirationDate.setDate(new Date().getDate() + DEFAULT_EXPIRATION_DAYS);
        
        appendRow(sheet, [
          file.id,
          Utilities.formatDate(defaultExpirationDate, Session.getScriptTimeZone(), 'yyyy/MM/dd'),
          '期限日設定待ち', 
          anyonePermission.role.toUpperCase()
        ]);
        newFilesFound++;
        existingIds.add(file.id);
      }
    }
    
    return {
      nextPageToken: timeOut ? pageToken : response.nextPageToken, 
      totalProcessed: totalProcessed,
      newFilesFound: newFilesFound,
      timeOut: timeOut
    };

  } catch (e) {
    Logger.log(`APIリクエストエラー: ${e.message}`);
    throw e;
  }
}

/**
 * 状態をプロパティに保存
 */
function saveState(props, phase, filePageToken, drivePageToken, pendingDrives, currentDrive, totalProcessed, newFilesFound) {
  const state = {};
  state[PROP.PHASE] = phase;
  if (filePageToken) state[PROP.FILE_TOKEN] = filePageToken;
  if (drivePageToken) state[PROP.DRIVE_TOKEN] = drivePageToken;
  state[PROP.PENDING_DRIVES] = JSON.stringify(pendingDrives);
  state[PROP.CURRENT_DRIVE] = JSON.stringify(currentDrive);
  state[PROP.TOTAL_COUNT] = totalProcessed.toString();
  state[PROP.NEW_COUNT] = newFilesFound.toString();
  
  props.setProperties(state);
  Logger.log(`💾 状態を保存しました。次回はここから再開します。(フェーズ: ${phase})`);
}

/**
 * 期限切れファイルの共有解除
 */
function checkAndRemovePublicAccess() {
  if (SPREADSHEET_URL.includes('【')) return;
  let ss;
  try { ss = SpreadsheetApp.openByUrl(SPREADSHEET_URL); } catch (e) { return; }
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) return;
  
  const values = sheet.getDataRange().getValues();
  const today = new Date();
  today.setHours(0, 0, 0, 0); 
  
  Logger.log('✅ 解除処理開始');
  let removedCount = 0;

  for (let i = 1; i < values.length; i++) {
    const fileId = values[i][0]; 
    const expirationDate = values[i][1]; 
    let status = values[i][2]; 
    const row = i + 1; 

    if (status.toString().includes('処理完了') || !fileId || !(expirationDate instanceof Date)) continue;

    if (expirationDate.getTime() <= today.getTime()) {
      try {
        const anyonePermission = getAnyonePermissionExplicitly(fileId);
        if (anyonePermission) {
          Drive.Permissions.remove(fileId, anyonePermission.id, { supportsAllDrives: true });
          removedCount++;
          sheet.getRange(row, 3).setValue('処理完了');
          Logger.log(`解除成功: ${fileId}`);
        } else {
          sheet.getRange(row, 3).setValue('処理完了 (既に解除済)');
        }
      } catch (e) {
        sheet.getRange(row, 3).setValue(`エラー: ${e.toString()}`);
      }
    }
  }
  Logger.log(`解除完了: ${removedCount} 件`);
}

function getFileId() {
  const ui = SpreadsheetApp.getUi();
  const result = ui.prompt('ID取得', 'URLを入力:', ui.ButtonSet.OK_CANCEL);
  if (result.getSelectedButton() === ui.Button.OK) {
    const match = result.getResponseText().match(/id=([^&]+)/) || result.getResponseText().match(/\/d\/([^/]+)/);
    ui.alert(match && match[1] ? `ID: ${match[1]}` : 'エラー');
  }
}