GAS経由でGoogle Driveへのファイルアップロードを行う
フロントエンドのサーバーからファイルをGASに送り、GASがGoogle Driveに保存する、のパターンの検証を行った。
こちらのパターンで問題になったのは、コードの内容というよりデプロイ時の権限周りについてで、色々と勉強になった。
GASプロジェクトのオーナーアカウントの所属ドメイン以外のアカウントは、GASコードのpushやバージョン作成はできてもdeployができない/そういう仕様っぽい(「Only users in the same domain as the script owner may deploy this script」と言われる)
code:gasのコードサンプル.js
/**
* @param {PostEvent} e
* @returns {GoogleAppsScript.Content.TextOutput}
*/
function doPost(e) {
const { fileName, mimeType, childFolderName } = e.parameter
if (!(fileName && mimeType && childFolderName)) {
return ContentService.createTextOutput(
JSON.stringify({
status: 400,
message: "Missing parameters",
}),
)
}
// e.postData.contentsはBase64エンコードされたファイルである必要あり
const fileAsBase64 = e.postData.contents
if (!fileAsBase64) {
return ContentService.createTextOutput(
JSON.stringify({
status: 400,
message: "Missing file data",
}),
)
}
// Utilities.base64Decode()にbase64以外の文字列を渡すとエラーになるので、try-catchでエラーハンドリングする
let fileAsByteArray
try {
fileAsByteArray = Utilities.base64Decode(fileAsBase64)
} catch (error) {
Logger.log(Invalid base64 string: ${error})
return ContentService.createTextOutput(
JSON.stringify({
status: 400,
message: "Invalid base64 string",
error: error.message,
}),
)
}
// e.postData.contentsはBlob型であるはずだが、そのままGoogleDriveに渡しても動かないので、一度GASのBlob型に変換する
const fileBlob = Utilities.newBlob(
fileAsByteArray,
e.parameter.mimeType,
e.parameter.fileName,
)
// フォルダを作成or取得するためのtryブロック
let childFolder
let lock
try {
const folderId =
PropertiesService.getScriptProperties().getProperty("parentFolderId")
const parentFolder = DriveApp.getFolderById(folderId)
const childFolderSearchResult =
parentFolder.getFoldersByName(childFolderName)
// 同時に複数のリクエストで同じフォルダの作成を行うと、重複してフォルダが作成される可能性があるので、ロックをかけて排他制御を行う
lock = LockService.getScriptLock()
lock.waitLock(5000)
// childFolderが既に存在する場合は取得して、なければ新規作成
childFolder = childFolderSearchResult.hasNext()
? childFolderSearchResult.next()
: parentFolder.createFolder(childFolderName)
} catch (error) {
Logger.log(Error on creating or retrieving folder: ${error.message})
return ContentService.createTextOutput(
JSON.stringify({
status: 500,
message: Error on creating or retrieving folder: ${error.message},
}),
)
} finally {
// ロックを解放(これを以降の処理の前に実行したいので、tryブロックを2つに分けている)
// ※finallyはtry/catch内でreturn/throwしても実行されることを確認済み
lock?.releaseLock()
}
// フォルダにファイルをアップロードするためのtryブロック
try {
childFolder.createFile(fileBlob)
return ContentService.createTextOutput(
JSON.stringify({
status: 200,
message: "File uploaded successfully",
}),
)
} catch (error) {
Logger.log(Error uploading file: ${error.message})
return ContentService.createTextOutput(
JSON.stringify({
status: 500,
message: "Error uploading file",
error: error.message,
}),
)
}
}
/**
* @typedef Param
* @property {string} fileName
* @property {string} mimeType
* @property {string} childFolderName
*/
/**
* @typedef PostData
* @property {string} contents
*/
/**
* @typedef PostEvent
* @property {string} contextPath
* @property {number} contentLength,
* @property {PostData} postData
* @property {Param} parameter
* @property {Object.<string, string[]>} parameters
* @property {string} queryString
}
*/
code:クライアントのReactRouter/Remixコードサンプル.tsx
import { useEffect, useState } from "react"
import { useFetcher } from "react-router"
export const action = async ({ request }: { request: Request }) => {
const deployId = import.meta.env.VITE_GAS_DEPLOY_ID
const gasEndpointUrl = https://script.google.com/macros/s/${deployId}/exec
// Fileオブジェクトをbase64に変換する
const formData = await request.formData()
const file = formData.get("file") as File
const fileBlob = new Blob(file, { type: file.type }) const fileBuffer = Buffer.from(await fileBlob.arrayBuffer())
const fileBase64 = fileBuffer.toString("base64")
const params = new URLSearchParams({
fileName: file.name,
mimeType: file.type,
childFolderName: "YYYYMMDD-HHmmss_(投稿者名)_(求人IDなど)",
})
const response = await fetch(${gasEndpointUrl}?${params}, {
method: "POST",
body: fileBase64,
})
return response.text()
}
export default function Upload() {
const fetcher = useFetcher()
useEffect(() => {
if (fetcher.data) {
setResult(fetcher.data)
}
})
return (
<>
<fetcher.Form
method="post"
encType="multipart/form-data"
className="flex flex-col gap-4"
<input type="file" name="file" accept=".pdf" />
<button type="submit">アップロード</button>
</fetcher.Form>
{result && (
<div>
<h2>アップロード結果</h2>
<p>{result}</p>
</div>
)}
</>
)
}