From 84d8ab4a441f717c87dcd20c5debc9493763a380 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Wed, 19 Aug 2026 12:58:46 +0700 Subject: [PATCH] feat: add api for showing basic information --- docs/openapi.yaml | 127 ++++++++++++++++++++ src/auth-api.ts | 25 ++-- src/bucket-access.ts | 50 +++++--- src/google-drive.ts | 41 ++++++- src/index.ts | 4 + src/status-api.ts | 272 ++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 32 +++++ test/status.test.ts | 277 +++++++++++++++++++++++++++++++++++++++++++ wrangler.jsonc | 144 +++++++++++----------- 9 files changed, 866 insertions(+), 106 deletions(-) create mode 100644 src/status-api.ts create mode 100644 test/status.test.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c077739..7373b42 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -98,6 +98,58 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/AuthError' } + /api/status: + get: + tags: [Dashboard Status] + operationId: getGatewayStatus + summary: Get gateway and storage health status + security: + - bearerAuth: [] + responses: + '200': + description: Gateway status and Drive account/quota overview. + content: + application/json: + schema: { $ref: '#/components/schemas/GatewayStatus' } + '401': + description: Invalid or expired session. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '503': + description: Dashboard authentication not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + /api/buckets: + get: + tags: [Dashboard Status] + operationId: getBucketStats + summary: Get bucket list and object metrics + security: + - bearerAuth: [] + parameters: + - name: refresh + in: query + description: Set to 1 to bypass KV cache. + required: false + schema: { type: string, example: "1" } + responses: + '200': + description: Bucket statistics and aggregate totals. + content: + application/json: + schema: { $ref: '#/components/schemas/BucketStats' } + '401': + description: Invalid or expired session. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } + '503': + description: Dashboard authentication not configured. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthError' } /{bucket}: parameters: - $ref: '#/components/parameters/Bucket' @@ -402,6 +454,81 @@ components: type: integer description: Token TTL in seconds. example: 43200 + GatewayStatus: + type: object + required: [gateway, drive, checkedAt] + properties: + gateway: + type: object + required: [status, region, multipartEnabled, etagStyle, docsEnabled, buckets, publicReadBuckets, corsOrigins, credentials] + properties: + status: { type: string, enum: [ok, degraded] } + region: { type: string, example: auto } + multipartEnabled: { type: boolean } + etagStyle: { type: string, enum: [md5, multipart] } + docsEnabled: { type: boolean } + buckets: { type: array, items: { type: string } } + publicReadBuckets: { type: array, items: { type: string } } + corsOrigins: { type: array, items: { type: string } } + credentials: + type: object + required: [s3Keys, googleOAuth, dashboardPassword] + properties: + s3Keys: { type: boolean } + googleOAuth: { type: boolean } + dashboardPassword: { type: boolean } + drive: + type: object + required: [connected, account, quota, error] + properties: + connected: { type: boolean } + account: + type: object + nullable: true + properties: + email: { type: string, nullable: true } + displayName: { type: string, nullable: true } + quota: + type: object + nullable: true + properties: + limit: { type: integer, nullable: true } + usage: { type: integer } + usageInDrive: { type: integer } + usageInDriveTrash: { type: integer } + free: { type: integer, nullable: true } + percentUsed: { type: number, nullable: true } + error: { type: string, nullable: true } + checkedAt: + type: string + format: date-time + BucketStats: + type: object + required: [buckets, totals, cachedAt] + properties: + buckets: + type: array + items: + type: object + required: [name, objectCount, totalSize, lastModified, truncated, publicRead, error] + properties: + name: { type: string } + objectCount: { type: integer } + totalSize: { type: integer } + lastModified: { type: string, format: date-time, nullable: true } + truncated: { type: boolean } + publicRead: { type: boolean } + error: { type: string, nullable: true } + totals: + type: object + required: [buckets, objectCount, totalSize] + properties: + buckets: { type: integer } + objectCount: { type: integer } + totalSize: { type: integer } + cachedAt: + type: string + format: date-time AuthError: type: object required: [message] diff --git a/src/auth-api.ts b/src/auth-api.ts index 0ff4912..149fce7 100644 --- a/src/auth-api.ts +++ b/src/auth-api.ts @@ -8,7 +8,7 @@ const SESSION_TTL_SECONDS = 12 * 60 * 60; const MAX_FAILED_ATTEMPTS = 5; const LOCKOUT_SECONDS = 900; -function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { +export function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { return new Response(JSON.stringify(body), { status, headers: { @@ -18,6 +18,14 @@ function jsonResponse(body: unknown, status = 200, headers: Record { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) return false; + const token = authHeader.slice("Bearer ".length).trim(); + if (!token) return false; + return (await env.AUTH_KV.get(`${SESSION_PREFIX}${await sha256(token)}`)) !== null; +} + function generateToken(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); return btoa(String.fromCharCode(...bytes)) @@ -86,19 +94,8 @@ export async function handleAuth(request: Request, env: Env, subPath: string): P return jsonResponse({ message: "Method Not Allowed" }, 405); } - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return jsonResponse({ message: "Session expired or invalid" }, 401); - } - - const token = authHeader.slice("Bearer ".length).trim(); - if (!token) { - return jsonResponse({ message: "Session expired or invalid" }, 401); - } - - const tokenHash = await sha256(token); - const session = await env.AUTH_KV.get(`${SESSION_PREFIX}${tokenHash}`); - if (!session) { + const isValid = await verifySessionToken(request, env); + if (!isValid) { return jsonResponse({ message: "Session expired or invalid" }, 401); } diff --git a/src/bucket-access.ts b/src/bucket-access.ts index 9fc0f98..c6466a6 100644 --- a/src/bucket-access.ts +++ b/src/bucket-access.ts @@ -1,34 +1,46 @@ import type { Env } from "./types"; +/** + * Returns the list of configured allowed buckets. + */ +export function allowedBuckets(env: Env): string[] { + if (!env.ALLOWED_BUCKETS) { + return []; + } + + return env.ALLOWED_BUCKETS.split(",") + .map((b) => b.trim()) + .filter((b) => b.length > 0); +} + +/** + * Returns the list of configured public read buckets. + */ +export function publicReadBuckets(env: Env): string[] { + if (!env.PUBLIC_READ_BUCKETS) { + return []; + } + + return env.PUBLIC_READ_BUCKETS.split(",") + .map((b) => b.trim()) + .filter((b) => b.length > 0); +} + /** * Checks whether the bucket is present in the ALLOWED_BUCKETS allowlist. * Access is denied by default when the allowlist is missing or empty. */ export function isAllowedBucket(bucket: string, env: Env): boolean { - if (!env.ALLOWED_BUCKETS) { + const buckets = allowedBuckets(env); + if (buckets.length === 0) { return false; } - const allowedBuckets = env.ALLOWED_BUCKETS.split(",") - .map((b) => b.trim()) - .filter((b) => b); - - if (allowedBuckets.length === 0) { - return false; - } - - return allowedBuckets.includes(bucket); + return buckets.includes(bucket); } /** Checks whether the bucket allows unauthenticated read access. */ export function isPublicReadBucket(bucket: string, env: Env): boolean { - if (!env.PUBLIC_READ_BUCKETS) { - return false; - } - - const publicReadBuckets = env.PUBLIC_READ_BUCKETS.split(",") - .map((b) => b.trim()) - .filter((b) => b); - - return publicReadBuckets.includes(bucket); + const buckets = publicReadBuckets(env); + return buckets.includes(bucket); } diff --git a/src/google-drive.ts b/src/google-drive.ts index 33c07f6..a1bdc15 100644 --- a/src/google-drive.ts +++ b/src/google-drive.ts @@ -1,5 +1,5 @@ import { decodedContentLength, isAwsChunked, pumpBody } from "./aws-chunked"; -import type { DriveDownloadResult, DriveFileMetadata, DriveUploadResult, Env, GoogleDriveFile, GoogleDriveSearchResponse } from "./types"; +import type { DriveAbout, DriveDownloadResult, DriveFileMetadata, DriveUploadResult, Env, GoogleDriveAboutResponse, GoogleDriveFile, GoogleDriveSearchResponse } from "./types"; interface GoogleTokenResponse { access_token: string; @@ -337,3 +337,42 @@ export async function listObjects(accessToken: string, bucket: string, prefix: s await walk(folderId, dirPrefix, true); return { contents, commonPrefixes: [...commonPrefixes].sort(), truncated }; } + +/** Fetches Google Drive account and storage quota details. */ +export async function getDriveAbout(accessToken: string): Promise { + const url = new URL("https://www.googleapis.com/drive/v3/about"); + url.searchParams.set("fields", "user(displayName,emailAddress),storageQuota(limit,usage,usageInDrive,usageInDriveTrash)"); + + const response = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new Error(`Drive about request failed: ${await response.text()}`); + } + + const data: GoogleDriveAboutResponse = await response.json(); + const quota = data.storageQuota; + const limit = quota?.limit !== undefined && quota?.limit !== null ? parseInt(quota.limit, 10) : null; + const usage = quota?.usage ? parseInt(quota.usage, 10) : 0; + const usageInDrive = quota?.usageInDrive ? parseInt(quota.usageInDrive, 10) : 0; + const usageInDriveTrash = quota?.usageInDriveTrash ? parseInt(quota.usageInDriveTrash, 10) : 0; + + const free = limit !== null ? Math.max(0, limit - usage) : null; + const percentUsed = limit !== null && limit > 0 ? Math.round((usage / limit) * 1000) / 10 : null; + + return { + user: { + emailAddress: data.user?.emailAddress ?? null, + displayName: data.user?.displayName ?? null, + }, + storageQuota: { + limit, + usage, + usageInDrive, + usageInDriveTrash, + free, + percentUsed, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index d5fcd52..d7a8e2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { getAccessToken } from "./google-drive"; import { MultipartUploadDO } from "./multipart-do"; import { dispatch } from "./router"; import { S3Exception, s3Error } from "./s3-errors"; +import { API_PATH_PREFIX, handleApi } from "./status-api"; import type { Env } from "./types"; export { MultipartUploadDO }; @@ -25,6 +26,9 @@ export default { if (pathParts[0] === AUTH_PATH_PREFIX && !isAllowedBucket(AUTH_PATH_PREFIX, env)) { return withCors(await handleAuth(request, env, pathParts.slice(1).join("/")), request, env); } + if (pathParts[0] === API_PATH_PREFIX && !isAllowedBucket(API_PATH_PREFIX, env)) { + return withCors(await handleApi(request, env, pathParts.slice(1).join("/")), request, env); + } const bucket = pathParts[0] || ""; const objectKey = pathParts.slice(1).join("/"); diff --git a/src/status-api.ts b/src/status-api.ts new file mode 100644 index 0000000..d4089f7 --- /dev/null +++ b/src/status-api.ts @@ -0,0 +1,272 @@ +import { jsonResponse, verifySessionToken } from "./auth-api"; +import { allowedBuckets, publicReadBuckets } from "./bucket-access"; +import { getAccessToken, getDriveAbout, listObjects } from "./google-drive"; +import type { DriveAbout, Env } from "./types"; + +export const API_PATH_PREFIX = "api"; +const DRIVE_ABOUT_CACHE_KEY = "drive-about"; +const DRIVE_ABOUT_CACHE_TTL = 60; // 60s +const BUCKET_STATS_CACHE_TTL = 300; // 300s + +export interface GatewayStatusResponse { + gateway: { + status: "ok" | "degraded"; + region: string; + multipartEnabled: boolean; + etagStyle: "md5" | "multipart"; + docsEnabled: boolean; + buckets: string[]; + publicReadBuckets: string[]; + corsOrigins: string[]; + credentials: { + s3Keys: boolean; + googleOAuth: boolean; + dashboardPassword: boolean; + }; + }; + drive: { + connected: boolean; + account: { + email: string | null; + displayName: string | null; + } | null; + quota: { + limit: number | null; + usage: number; + usageInDrive: number; + usageInDriveTrash: number; + free: number | null; + percentUsed: number | null; + } | null; + error: string | null; + }; + checkedAt: string; +} + +export interface BucketStatItem { + name: string; + objectCount: number; + totalSize: number; + lastModified: string | null; + truncated: boolean; + publicRead: boolean; + error: string | null; +} + +export interface BucketStatsResponse { + buckets: BucketStatItem[]; + totals: { + buckets: number; + objectCount: number; + totalSize: number; + }; + cachedAt: string; +} + +export async function handleApi(request: Request, env: Env, subPath: string): Promise { + try { + if (!env.DASHBOARD_PASSWORD) { + return jsonResponse({ message: "Dashboard authentication is not configured" }, 503); + } + + const isValid = await verifySessionToken(request, env); + if (!isValid) { + return jsonResponse({ message: "Session expired or invalid" }, 401); + } + + if (request.method !== "GET") { + return jsonResponse({ message: "Method Not Allowed" }, 405); + } + + const url = new URL(request.url); + + if (subPath === "status") { + return await handleStatus(env); + } + + if (subPath === "buckets") { + const forceRefresh = url.searchParams.get("refresh") === "1"; + return await handleBuckets(env, forceRefresh); + } + + return jsonResponse({ message: "Not Found" }, 404); + } catch (error) { + console.error( + JSON.stringify({ + message: "api handler error", + error: error instanceof Error ? error.message : String(error), + method: request.method, + subPath, + }), + ); + return jsonResponse({ message: "Internal server error" }, 500); + } +} + +async function handleStatus(env: Env): Promise { + const buckets = allowedBuckets(env); + const pubBuckets = publicReadBuckets(env); + const corsOrigins = env.CORS_ALLOWED_ORIGINS + ? env.CORS_ALLOWED_ORIGINS.split(",") + .map((o) => o.trim()) + .filter(Boolean) + : []; + + let driveAbout: DriveAbout | null = null; + let driveError: string | null = null; + + try { + const cached = await env.AUTH_KV.get(DRIVE_ABOUT_CACHE_KEY); + if (cached) { + driveAbout = JSON.parse(cached) as DriveAbout; + } else { + const accessToken = await getAccessToken(env); + driveAbout = await getDriveAbout(accessToken); + await env.AUTH_KV.put(DRIVE_ABOUT_CACHE_KEY, JSON.stringify(driveAbout), { + expirationTtl: DRIVE_ABOUT_CACHE_TTL, + }); + } + } catch (err) { + driveError = err instanceof Error ? err.message : String(err); + } + + const driveConnected = driveAbout !== null && driveError === null; + + const payload: GatewayStatusResponse = { + gateway: { + status: driveConnected ? "ok" : "degraded", + region: env.REGION || "auto", + multipartEnabled: env.ALLOW_MULTIPART === "true", + etagStyle: env.ETAG_STYLE === "multipart" ? "multipart" : "md5", + docsEnabled: env.ENABLE_DOCS !== "false", + buckets, + publicReadBuckets: pubBuckets, + corsOrigins, + credentials: { + s3Keys: Boolean(env.ACCESS_KEY && env.SECRET_KEY), + googleOAuth: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET && env.GOOGLE_REFRESH_TOKEN), + dashboardPassword: Boolean(env.DASHBOARD_PASSWORD), + }, + }, + drive: { + connected: driveConnected, + account: driveAbout + ? { + email: driveAbout.user.emailAddress, + displayName: driveAbout.user.displayName, + } + : null, + quota: driveAbout ? driveAbout.storageQuota : null, + error: driveError, + }, + checkedAt: new Date().toISOString(), + }; + + return jsonResponse(payload, 200); +} + +async function handleBuckets(env: Env, forceRefresh: boolean): Promise { + const buckets = allowedBuckets(env); + const pubBuckets = new Set(publicReadBuckets(env)); + + let accessToken: string | null = null; + try { + accessToken = await getAccessToken(env); + } catch (err) { + console.error("Failed to acquire access token for bucket stats", err); + } + + const bucketStats: BucketStatItem[] = []; + + for (const bucket of buckets) { + const cacheKey = `bucket-stats:${bucket}`; + if (!forceRefresh) { + const cached = await env.FOLDER_CACHE.get(cacheKey); + if (cached) { + try { + bucketStats.push(JSON.parse(cached) as BucketStatItem); + continue; + } catch { + // if corrupted cache, proceed to fetch + } + } + } + + if (!accessToken) { + bucketStats.push({ + name: bucket, + objectCount: 0, + totalSize: 0, + lastModified: null, + truncated: false, + publicRead: pubBuckets.has(bucket), + error: "Google Drive access unavailable", + }); + continue; + } + + try { + const { contents, truncated } = await listObjects(accessToken, bucket, ""); + let totalSize = 0; + let latestModified: number | null = null; + + for (const item of contents) { + const sz = parseInt(item.size, 10); + if (!Number.isNaN(sz)) totalSize += sz; + if (item.modifiedTime) { + const t = new Date(item.modifiedTime).getTime(); + if (!Number.isNaN(t) && (latestModified === null || t > latestModified)) { + latestModified = t; + } + } + } + + const stat: BucketStatItem = { + name: bucket, + objectCount: contents.length, + totalSize, + lastModified: latestModified ? new Date(latestModified).toISOString() : null, + truncated, + publicRead: pubBuckets.has(bucket), + error: null, + }; + + await env.FOLDER_CACHE.put(cacheKey, JSON.stringify(stat), { + expirationTtl: BUCKET_STATS_CACHE_TTL, + }); + + bucketStats.push(stat); + } catch (err) { + bucketStats.push({ + name: bucket, + objectCount: 0, + totalSize: 0, + lastModified: null, + truncated: false, + publicRead: pubBuckets.has(bucket), + error: err instanceof Error ? err.message : String(err), + }); + } + } + + let totalCount = 0; + let totalSize = 0; + for (const b of bucketStats) { + if (!b.error) { + totalCount += b.objectCount; + totalSize += b.totalSize; + } + } + + const response: BucketStatsResponse = { + buckets: bucketStats, + totals: { + buckets: bucketStats.length, + objectCount: totalCount, + totalSize: totalSize, + }, + cachedAt: new Date().toISOString(), + }; + + return jsonResponse(response, 200); +} diff --git a/src/types.ts b/src/types.ts index f35d89f..7a87547 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,3 +57,35 @@ export interface DriveFileMetadata { md5Checksum?: string; modifiedTime?: string; } + +export interface DriveAboutUser { + displayName?: string; + emailAddress?: string; +} + +export interface DriveAboutStorageQuota { + limit?: string; + usage?: string; + usageInDrive?: string; + usageInDriveTrash?: string; +} + +export interface GoogleDriveAboutResponse { + user?: DriveAboutUser; + storageQuota?: DriveAboutStorageQuota; +} + +export interface DriveAbout { + user: { + emailAddress: string | null; + displayName: string | null; + }; + storageQuota: { + limit: number | null; + usage: number; + usageInDrive: number; + usageInDriveTrash: number; + free: number | null; + percentUsed: number | null; + }; +} diff --git a/test/status.test.ts b/test/status.test.ts new file mode 100644 index 0000000..d84dd83 --- /dev/null +++ b/test/status.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from "vitest"; + +import { sha256 } from "../src/aws-signature"; +import worker from "../src/index"; +import type { BucketStatItem, BucketStatsResponse, GatewayStatusResponse } from "../src/status-api"; +import type { Env } from "../src/types"; + +import { env } from "cloudflare:test"; + +const ENV = env as unknown as Env; +const ENDPOINT = "https://s3-api.example.com"; +const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext; + +async function getValidToken(): Promise { + const correctHash = await sha256("test-dashboard-password"); + const loginRes = await worker.fetch( + new Request(`${ENDPOINT}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": "127.0.0.1" }, + body: JSON.stringify({ passwordHash: correctHash }), + }), + ENV, + CTX, + ); + expect(loginRes.status).toBe(200); + const data = (await loginRes.json()) as { token: string }; + return data.token; +} + +describe("Dashboard status API routes (/api/*)", () => { + it("returns 401 when Authorization header is missing or invalid", async () => { + const res = await worker.fetch(new Request(`${ENDPOINT}/api/status`), ENV, CTX); + expect(res.status).toBe(401); + + const invalidRes = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + headers: { Authorization: "Bearer invalid-token" }, + }), + ENV, + CTX, + ); + expect(invalidRes.status).toBe(401); + }); + + it("returns 503 when DASHBOARD_PASSWORD is not configured", async () => { + const withoutPassword = { ...ENV, DASHBOARD_PASSWORD: undefined }; + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + headers: { Authorization: "Bearer any-token" }, + }), + withoutPassword, + CTX, + ); + expect(res.status).toBe(503); + }); + + it("returns 405 for non-GET methods", async () => { + const token = await getValidToken(); + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + expect(res.status).toBe(405); + }); + + it("returns 200 with valid token and healthy drive response on /api/status", async () => { + const token = await getValidToken(); + + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString()); + + if (url.origin === "https://oauth2.googleapis.com") { + return Response.json({ access_token: "mock-access-token", expires_in: 3600 }); + } + + if (url.pathname === "/drive/v3/about") { + return Response.json({ + user: { + emailAddress: "test@example.com", + displayName: "Test User", + }, + storageQuota: { + limit: "15000000000", + usage: "5000000000", + usageInDrive: "4500000000", + usageInDriveTrash: "500000000", + }, + }); + } + + return new Response("Not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fakeFetch); + + try { + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as GatewayStatusResponse; + + expect(data.gateway.status).toBe("ok"); + expect(data.gateway.region).toBe("auto"); + expect(data.gateway.multipartEnabled).toBe(true); + expect(data.gateway.buckets).toEqual(["test-bucket", "empty-bucket", "my-bucket"]); + expect(data.gateway.credentials).toEqual({ + s3Keys: true, + googleOAuth: true, + dashboardPassword: true, + }); + + expect(data.drive.connected).toBe(true); + expect(data.drive.account?.email).toBe("test@example.com"); + expect(data.drive.quota?.limit).toBe(15000000000); + expect(data.drive.quota?.usage).toBe(5000000000); + expect(data.drive.quota?.free).toBe(10000000000); + expect(data.drive.quota?.percentUsed).toBe(33.3); + expect(data.drive.error).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not leak secrets in /api/status response", async () => { + const token = await getValidToken(); + + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString()); + if (url.origin === "https://oauth2.googleapis.com") { + return Response.json({ access_token: "mock-access-token", expires_in: 3600 }); + } + if (url.pathname === "/drive/v3/about") { + return Response.json({ + user: { emailAddress: "test@example.com", displayName: "Test User" }, + storageQuota: { limit: "1000", usage: "500" }, + }); + } + return new Response("Not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fakeFetch); + + try { + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + + const text = await res.text(); + expect(text).not.toContain("test-secret-key"); + expect(text).not.toContain("test-refresh-token"); + expect(text).not.toContain("test-dashboard-password"); + expect(text).not.toContain("test-client-secret"); + expect(text).not.toContain("test-access-key"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("handles Drive errors gracefully with degraded status on /api/status", async () => { + await (ENV.AUTH_KV as KVNamespace).delete("drive-about"); + const token = await getValidToken(); + + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString()); + if (url.origin === "https://oauth2.googleapis.com") { + return Response.json({ access_token: "mock-access-token", expires_in: 3600 }); + } + if (url.pathname === "/drive/v3/about") { + return new Response("Internal Server Error", { status: 500 }); + } + return new Response("Not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fakeFetch); + + try { + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/status`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as GatewayStatusResponse; + expect(data.gateway.status).toBe("degraded"); + expect(data.drive.connected).toBe(false); + expect(data.drive.account).toBeNull(); + expect(data.drive.quota).toBeNull(); + expect(data.drive.error).toContain("Drive about request failed"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("returns bucket statistics on /api/buckets", async () => { + await (ENV.AUTH_KV as KVNamespace).delete("drive-about"); + const token = await getValidToken(); + + const fakeFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(typeof input === "string" ? input : input instanceof Request ? input.url : input.toString()); + if (url.origin === "https://oauth2.googleapis.com") { + return Response.json({ access_token: "mock-access-token", expires_in: 3600 }); + } + if (url.pathname === "/drive/v3/files") { + const q = url.searchParams.get("q") ?? ""; + if (q.includes("name='test-bucket'")) { + return Response.json({ files: [{ id: "folder-test-bucket", name: "test-bucket" }] }); + } + if (q.includes("'folder-test-bucket' in parents")) { + return Response.json({ + files: [ + { + id: "file-1", + name: "hello.txt", + mimeType: "text/plain", + size: "1024", + modifiedTime: "2026-08-19T00:00:00.000Z", + }, + ], + }); + } + return Response.json({ files: [] }); + } + return new Response("Not found", { status: 404 }); + }); + + vi.stubGlobal("fetch", fakeFetch); + + try { + const res = await worker.fetch( + new Request(`${ENDPOINT}/api/buckets?refresh=1`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ENV, + CTX, + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as BucketStatsResponse; + expect(data.buckets).toHaveLength(3); + const testBucket = data.buckets.find((b: BucketStatItem) => b.name === "test-bucket"); + expect(testBucket).toBeTruthy(); + expect(testBucket?.objectCount).toBe(1); + expect(testBucket?.totalSize).toBe(1024); + expect(testBucket?.lastModified).toBe("2026-08-19T00:00:00.000Z"); + expect(data.totals.objectCount).toBe(1); + expect(data.totals.totalSize).toBe(1024); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("routes /api to S3 handler when 'api' is configured as an allowed bucket", async () => { + const customEnv = { ...ENV, ALLOWED_BUCKETS: "api,test-bucket" }; + const res = await worker.fetch(new Request(`${ENDPOINT}/api/status`), customEnv, CTX); + // S3 router checks signature or returns SignatureDoesNotMatch / AccessDenied etc. + expect(res.status).toBe(403); + const text = await res.text(); + expect(text).toContain(""); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 9fa22e0..2c708da 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,76 +3,76 @@ * https://developers.cloudflare.com/workers/wrangler/configuration/ */ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "s3-google-drive", - "main": "src/index.ts", - "compatibility_date": "2025-09-27", - "vars": { - "ALLOW_MULTIPART": "true", - "ETAG_STYLE": "md5", - "CORS_ALLOWED_ORIGINS": "https://s3-drive-storage-manage.nezumi.workers.dev", - "ENABLE_DOCS": "true", - }, - "rules": [ - { - "type": "Text", - "globs": ["**/*.yaml"], - "fallthrough": false, - }, - ], - "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" } - /** - * 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" } - /** - * Static Assets - * https://developers.cloudflare.com/workers/static-assets/binding/ - */ - // "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" } ] + "$schema": "node_modules/wrangler/config-schema.json", + "name": "s3-google-drive", + "main": "src/index.ts", + "compatibility_date": "2025-09-27", + "vars": { + "ALLOW_MULTIPART": "true", + "ETAG_STYLE": "md5", + "CORS_ALLOWED_ORIGINS": "https://s3-drive-storage-manage.nezumi.workers.dev", + "ENABLE_DOCS": "true" + }, + "rules": [ + { + "type": "Text", + "globs": ["**/*.yaml"], + "fallthrough": false + } + ], + "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" } + /** + * 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" } + /** + * Static Assets + * https://developers.cloudflare.com/workers/static-assets/binding/ + */ + // "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" } ] }