diff --git a/README.md b/README.md index b17c51e..2fe24f9 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,15 @@ For the sake of your Google account, we strongly recommend using this in an envi ## How to Deploy ### 1. Prepare a Google Drive API Refresh Token -You will need to use rclone to obtain your Google API credentials and a Google Drive API refresh token. +You need Google API credentials and a Google Drive API refresh token. Either method below requires you to first create your own OAuth client (Google requires this per-app; a shared client cannot be scripted around it) — see the steps in either option. + +**Option A: rclone** Follow the rclone documentation to configure the client. https://rclone.org/drive/#making-your-own-client-id +**Option B: local script** +Run `pnpm get-refresh-token -- --client-id --client-secret ` (see `scripts/get-google-refresh-token.mjs` for the Google Cloud Console setup steps — enabling the Drive API and creating a "Desktop app" OAuth client). It opens the consent screen, catches the redirect locally, and prints the values below directly. + > [!NOTE] > When your Google API client is in "Testing" mode, the refresh token will expire after a certain period of time, so if you need to use it for a long period of time, be sure to switch the mode before authenticating with rclone. > diff --git a/package.json b/package.json index a6a9365..5d59636 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "pnpx wrangler dev", "test": "vitest --run", "cf-typegen": "pnpx wrangler types", + "get-refresh-token": "node scripts/get-google-refresh-token.mjs", "lint": "biome check", "format": "biome format --write" }, diff --git a/scripts/get-google-refresh-token.mjs b/scripts/get-google-refresh-token.mjs new file mode 100644 index 0000000..7e531d3 --- /dev/null +++ b/scripts/get-google-refresh-token.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Local alternative to `rclone config` for obtaining a Google Drive OAuth +// refresh token. Runs the standard "installed app" / loopback OAuth flow +// (RFC 8252): opens the consent screen, catches the redirect on a local +// HTTP server, and exchanges the code for tokens. +// +// You still need your own OAuth client (Google requires this per-app, it +// cannot be generated by a script): +// 1. https://console.cloud.google.com/ -> create/select a project. +// 2. APIs & Services > Library -> enable "Google Drive API". +// 3. APIs & Services > OAuth consent screen -> External, Testing is fine; +// add your own Google account under "Test users". +// 4. APIs & Services > Credentials -> Create credentials > OAuth client ID +// -> Application type: "Desktop app". Copy the Client ID and Client secret. +// +// Usage: +// node scripts/get-google-refresh-token.mjs --client-id ID --client-secret SECRET +// (or set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET env vars instead of flags) +// +// Optional: --port 8976 (must not be in use; Desktop-app clients accept any +// loopback port automatically, no need to register it in Google Cloud Console). + +import { randomBytes } from "node:crypto"; +import http from "node:http"; +import { exec } from "node:child_process"; +import { URL } from "node:url"; + +const SCOPE = "https://www.googleapis.com/auth/drive"; +const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { port: 8976 }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--client-id") opts.clientId = args[++i]; + else if (a === "--client-secret") opts.clientSecret = args[++i]; + else if (a === "--port") opts.port = Number(args[++i]); + else if (a === "--help" || a === "-h") opts.help = true; + } + opts.clientId ??= process.env.GOOGLE_CLIENT_ID; + opts.clientSecret ??= process.env.GOOGLE_CLIENT_SECRET; + return opts; +} + +function printHelp() { + console.log(` +Usage: node scripts/get-google-refresh-token.mjs --client-id --client-secret [--port 8976] + +Before running this script, create a Desktop-app OAuth client in the Google +Cloud Console (see the comment block at the top of this file for the exact +steps) and enable the Google Drive API for that project. + +Env vars GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are accepted instead of flags. +`); +} + +function openBrowser(url) { + const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`; + exec(cmd, () => {}); +} + +async function main() { + const opts = parseArgs(); + if (opts.help || !opts.clientId || !opts.clientSecret) { + printHelp(); + process.exit(opts.help ? 0 : 1); + } + + const redirectUri = `http://127.0.0.1:${opts.port}/`; + const state = randomBytes(16).toString("hex"); + + const authUrl = new URL(AUTH_ENDPOINT); + authUrl.searchParams.set("client_id", opts.clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", SCOPE); + authUrl.searchParams.set("access_type", "offline"); + authUrl.searchParams.set("prompt", "consent"); // force refresh_token even on repeat auth + authUrl.searchParams.set("state", state); + + const code = await new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const reqUrl = new URL(req.url, redirectUri); + if (reqUrl.pathname !== "/") { + res.writeHead(404).end(); + return; + } + + const returnedState = reqUrl.searchParams.get("state"); + const err = reqUrl.searchParams.get("error"); + const authCode = reqUrl.searchParams.get("code"); + + if (err) { + res.writeHead(400, { "Content-Type": "text/plain" }).end(`Authorization failed: ${err}`); + server.close(); + reject(new Error(`Google returned error: ${err}`)); + return; + } + if (returnedState !== state || !authCode) { + res.writeHead(400, { "Content-Type": "text/plain" }).end("Invalid state or missing code."); + server.close(); + reject(new Error("State mismatch or missing authorization code.")); + return; + } + + res.writeHead(200, { "Content-Type": "text/plain" }).end("Authorization complete. You can close this tab and return to the terminal."); + server.close(); + resolve(authCode); + }); + + server.listen(opts.port, "127.0.0.1", () => { + console.log(`\nOpen this URL in your browser if it doesn't open automatically:\n\n${authUrl.toString()}\n`); + openBrowser(authUrl.toString()); + console.log("Waiting for authorization..."); + }); + + server.on("error", reject); + }); + + const tokenRes = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: opts.clientId, + client_secret: opts.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + const tokenData = await tokenRes.json(); + if (!tokenRes.ok) { + console.error("Token exchange failed:", tokenData); + process.exit(1); + } + if (!tokenData.refresh_token) { + console.error( + "No refresh_token was returned. This usually means the account already has an active grant.\n" + + "Revoke it at https://myaccount.google.com/permissions and re-run this script.", + ); + process.exit(1); + } + + console.log("\nSuccess. Set these as your Worker secrets:\n"); + console.log(`GOOGLE_CLIENT_ID=${opts.clientId}`); + console.log(`GOOGLE_CLIENT_SECRET=${opts.clientSecret}`); + console.log(`GOOGLE_REFRESH_TOKEN=${tokenData.refresh_token}`); + console.log("\nExample (from the repo root):\n"); + console.log(` echo -n "${opts.clientId}" | npx wrangler secret put GOOGLE_CLIENT_ID`); + console.log(` echo -n "${opts.clientSecret}" | npx wrangler secret put GOOGLE_CLIENT_SECRET`); + console.log(` echo -n "${tokenData.refresh_token}" | npx wrangler secret put GOOGLE_REFRESH_TOKEN`); +} + +main().catch((err) => { + console.error(err.message ?? err); + process.exit(1); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index ccd4a05..1df92b5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,67 +3,69 @@ * https://developers.cloudflare.com/workers/wrangler/configuration/ */ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "iris", - "main": "src/index.ts", - "compatibility_date": "2025-09-27", - "vars": { - "ALLOW_MULTIPART": "false", - "ETAG_STYLE": "md5" - }, - "observability": { - "enabled": true - }, - "durable_objects": { - "bindings": [ - { - "name": "MPU", - "class_name": "MultipartUploadDO" - } - ] - }, - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["MultipartUploadDO"] - } - ], - "kv_namespaces": [ - { - "binding": "AUTH_KV", - "id": "ba953a1ce2cd41a7ba1d348075d34171" - }, - { - "binding": "FOLDER_CACHE", - "id": "6332b0674a8342e4b270ca3368d3c52a" - } - ] - /** + "$schema": "node_modules/wrangler/config-schema.json", + "name": "iris", + "main": "src/index.ts", + "compatibility_date": "2025-09-27", + "vars": { + "ALLOW_MULTIPART": "true", + "ETAG_STYLE": "md5" + }, + "observability": { + "enabled": true + }, + "durable_objects": { + "bindings": [ + { + "name": "MPU", + "class_name": "MultipartUploadDO" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "MultipartUploadDO" + ] + } + ], + "kv_namespaces": [ + { + "binding": "AUTH_KV", + "id": "2113be46ce514e088572d0ef7459c1bf" + }, + { + "binding": "FOLDER_CACHE", + "id": "6d2f9904a3ff4471b0f04025c1ae87cd" + } + ] + /** * Smart Placement * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement */ - // "placement": { "mode": "smart" } - /** + // "placement": { "mode": "smart" } + /** * Bindings * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including * databases, object storage, AI inference, real-time communication and more. * https://developers.cloudflare.com/workers/runtime-apis/bindings/ */ - /** + /** * Environment Variables * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables * Note: Use secrets to store sensitive data. * https://developers.cloudflare.com/workers/configuration/secrets/ */ - // "vars": { "MY_VARIABLE": "production_value" } - /** + // "vars": { "MY_VARIABLE": "production_value" } + /** * Static Assets * https://developers.cloudflare.com/workers/static-assets/binding/ */ - // "assets": { "directory": "./public/", "binding": "ASSETS" } - /** + // "assets": { "directory": "./public/", "binding": "ASSETS" } + /** * Service Bindings (communicate between multiple Workers) * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings */ - // "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ] -} + // "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ] +} \ No newline at end of file