mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
feat: add api for showing basic information
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" } ]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user