feat: add api for showing basic information

This commit is contained in:
2026-08-19 12:58:46 +07:00
parent ba98f9c2f8
commit 84d8ab4a44
9 changed files with 866 additions and 106 deletions
+127
View File
@@ -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]
+11 -14
View File
@@ -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<string, string> = {}): Response {
export function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
@@ -18,6 +18,14 @@ function jsonResponse(body: unknown, status = 200, headers: Record<string, strin
});
}
export async function verifySessionToken(request: Request, env: Env): Promise<boolean> {
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);
}
+31 -19
View File
@@ -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);
}
+40 -1
View File
@@ -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<DriveAbout> {
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,
},
};
}
+4
View File
@@ -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("/");
+272
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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);
}
+32
View File
@@ -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;
};
}
+277
View File
@@ -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<string> {
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("<Error><Code>");
});
});
+72 -72
View File
@@ -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" } ]
}