mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
};
|
|
|
|
function jsonResponse(payload: unknown, status = 200) {
|
|
return new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function extractUserIdFromAuthHeader(authHeader: string | null) {
|
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
|
|
try {
|
|
const token = authHeader.replace("Bearer ", "");
|
|
const payload = token.split(".")[1];
|
|
if (!payload) return null;
|
|
|
|
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
const decoded = JSON.parse(atob(padded));
|
|
|
|
return typeof decoded?.sub === "string" ? decoded.sub : null;
|
|
} catch (error) {
|
|
console.error("google-drive-proxy token decode failed", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mapGoogleDriveError(error: any) {
|
|
const reason = error?.details?.find?.((detail: any) => detail?.reason)?.reason
|
|
?? error?.errors?.[0]?.reason;
|
|
const message = error?.message || "Google Drive request failed.";
|
|
|
|
if (/oauth client was not found|invalid_client/i.test(message) || reason === "invalid_client") {
|
|
return {
|
|
error: "Google Drive OAuth credentials are invalid or missing. Update the Google client ID/secret in the backend, then reconnect Google Drive.",
|
|
code: "OAUTH_CLIENT_NOT_FOUND",
|
|
reconnectRequired: true,
|
|
};
|
|
}
|
|
|
|
if (reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT" || reason === "insufficientPermissions") {
|
|
return {
|
|
error: "Your Google connection needs to be refreshed for Drive access. Disconnect Google and connect it again.",
|
|
code: "RECONNECT_REQUIRED",
|
|
reconnectRequired: true,
|
|
};
|
|
}
|
|
|
|
if (reason === "SERVICE_DISABLED" || /google drive api has not been used|drive\.googleapis\.com|api.*disabled/i.test(message)) {
|
|
return {
|
|
error: "Google Drive API is not enabled in the connected Google project.",
|
|
code: "API_DISABLED",
|
|
};
|
|
}
|
|
|
|
return {
|
|
error: message,
|
|
code: "GOOGLE_API_ERROR",
|
|
};
|
|
}
|
|
|
|
async function getValidAccessToken(
|
|
serviceClient: any,
|
|
userId: string,
|
|
clientId: string,
|
|
clientSecret: string,
|
|
): Promise<string> {
|
|
const { data: tokenRow, error } = await serviceClient
|
|
.from("google_drive_tokens")
|
|
.select("*")
|
|
.eq("user_id", userId)
|
|
.single();
|
|
|
|
if (error || !tokenRow) throw new Error("NOT_CONNECTED");
|
|
|
|
const expiresAt = new Date(tokenRow.token_expires_at).getTime();
|
|
if (Date.now() < expiresAt - 60000) {
|
|
return tokenRow.access_token;
|
|
}
|
|
|
|
const refreshRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
refresh_token: tokenRow.refresh_token,
|
|
grant_type: "refresh_token",
|
|
}),
|
|
});
|
|
|
|
const refreshData = await refreshRes.json();
|
|
if (refreshData.error) throw new Error(refreshData.error_description || refreshData.error || "REFRESH_FAILED");
|
|
|
|
const newExpiresAt = new Date(Date.now() + refreshData.expires_in * 1000).toISOString();
|
|
|
|
await serviceClient
|
|
.from("google_drive_tokens")
|
|
.update({
|
|
access_token: refreshData.access_token,
|
|
token_expires_at: newExpiresAt,
|
|
})
|
|
.eq("user_id", userId);
|
|
|
|
return refreshData.access_token;
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const GOOGLE_CLIENT_ID = Deno.env.get("GOOGLE_CLIENT_ID")!;
|
|
const GOOGLE_CLIENT_SECRET = Deno.env.get("GOOGLE_CLIENT_SECRET")!;
|
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
|
|
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
|
return jsonResponse({
|
|
error: "Google Drive OAuth credentials are not configured in the backend.",
|
|
code: "OAUTH_CREDENTIALS_MISSING",
|
|
reconnectRequired: true,
|
|
});
|
|
}
|
|
|
|
const authHeader = req.headers.get("Authorization");
|
|
const userId = extractUserIdFromAuthHeader(authHeader);
|
|
if (!userId) {
|
|
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const serviceClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
|
|
|
const { data: roles } = await serviceClient
|
|
.from("user_roles")
|
|
.select("role")
|
|
.eq("user_id", userId);
|
|
const isAdmin = roles?.some((role: any) => role.role === "admin");
|
|
|
|
const body = await req.json();
|
|
const { action, folder_id, file_id } = body;
|
|
|
|
if (action === "list_shared_drives") {
|
|
if (!isAdmin) {
|
|
return jsonResponse({ error: "Only admins can browse Drive" }, 403);
|
|
}
|
|
|
|
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
|
|
const drivesRes = await fetch(
|
|
`https://www.googleapis.com/drive/v3/drives?pageSize=100`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
);
|
|
|
|
const drivesData = await drivesRes.json();
|
|
if (drivesData.error) {
|
|
return jsonResponse(mapGoogleDriveError(drivesData.error));
|
|
}
|
|
|
|
return jsonResponse(drivesData);
|
|
}
|
|
|
|
if (action === "list_files") {
|
|
if (!isAdmin) {
|
|
return jsonResponse({ error: "Only admins can browse Drive" }, 403);
|
|
}
|
|
|
|
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
const parentId = folder_id || "root";
|
|
const query = `'${parentId}' in parents and trashed = false`;
|
|
const fields = "files(id,name,mimeType,iconLink,webViewLink,size,modifiedTime),nextPageToken";
|
|
|
|
const params = new URLSearchParams({
|
|
q: query,
|
|
fields,
|
|
orderBy: "folder,name",
|
|
pageSize: "100",
|
|
supportsAllDrives: "true",
|
|
includeItemsFromAllDrives: "true",
|
|
corpora: "allDrives",
|
|
});
|
|
|
|
const driveRes = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files?${params}`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
);
|
|
|
|
const driveData = await driveRes.json();
|
|
if (driveData.error) {
|
|
return jsonResponse(mapGoogleDriveError(driveData.error));
|
|
}
|
|
|
|
return jsonResponse(driveData);
|
|
}
|
|
|
|
if (action === "download_file") {
|
|
if (!file_id) {
|
|
return jsonResponse({ error: "Missing file_id" }, 400);
|
|
}
|
|
|
|
let tokenUserId = userId;
|
|
if (!isAdmin) {
|
|
const { data: sharedFile } = await serviceClient
|
|
.from("shared_drive_files")
|
|
.select("shared_by")
|
|
.eq("drive_file_id", file_id)
|
|
.maybeSingle();
|
|
|
|
if (!sharedFile?.shared_by) {
|
|
return jsonResponse({ error: "File not found or not shared" }, 404);
|
|
}
|
|
tokenUserId = sharedFile.shared_by;
|
|
}
|
|
|
|
const accessToken = await getValidAccessToken(serviceClient, tokenUserId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
|
|
const metaRes = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files/${file_id}?fields=name,mimeType&supportsAllDrives=true`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
);
|
|
const meta = await metaRes.json();
|
|
if (meta?.error) {
|
|
return jsonResponse(mapGoogleDriveError(meta.error));
|
|
}
|
|
|
|
const downloadRes = await fetch(
|
|
`https://www.googleapis.com/drive/v3/files/${file_id}?alt=media&supportsAllDrives=true`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
);
|
|
|
|
if (!downloadRes.ok) {
|
|
const errText = await downloadRes.text();
|
|
try {
|
|
const parsed = JSON.parse(errText);
|
|
if (parsed?.error) {
|
|
return jsonResponse(mapGoogleDriveError(parsed.error));
|
|
}
|
|
} catch {
|
|
// fall through to generic error response
|
|
}
|
|
|
|
return jsonResponse({ error: `Download failed: ${errText}` });
|
|
}
|
|
|
|
const fileBlob = await downloadRes.blob();
|
|
return new Response(fileBlob, {
|
|
headers: {
|
|
...corsHeaders,
|
|
"Content-Type": meta.mimeType || "application/octet-stream",
|
|
"Content-Disposition": `attachment; filename="${meta.name}"`,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (action === "upload_file") {
|
|
if (!isAdmin) {
|
|
return jsonResponse({ error: "Only admins can upload to Drive" }, 403);
|
|
}
|
|
|
|
const { file_name, file_base64, mime_type, parent_folder_id } = body;
|
|
if (!file_name || !file_base64) {
|
|
return jsonResponse({ error: "Missing file_name or file_base64" }, 400);
|
|
}
|
|
|
|
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
|
|
// Convert base64 to binary
|
|
const binaryStr = atob(file_base64);
|
|
const bytes = new Uint8Array(binaryStr.length);
|
|
for (let i = 0; i < binaryStr.length; i++) {
|
|
bytes[i] = binaryStr.charCodeAt(i);
|
|
}
|
|
|
|
// Create file metadata
|
|
const metadata: any = { name: file_name };
|
|
if (parent_folder_id) {
|
|
metadata.parents = [parent_folder_id];
|
|
}
|
|
|
|
// Multipart upload
|
|
const boundary = "----LovableBoundary" + Date.now();
|
|
const metaPart = JSON.stringify(metadata);
|
|
const contentType = mime_type || "application/octet-stream";
|
|
|
|
const encoder = new TextEncoder();
|
|
const beforeFile = encoder.encode(
|
|
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaPart}\r\n--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`
|
|
);
|
|
const afterFile = encoder.encode(`\r\n--${boundary}--`);
|
|
|
|
const uploadBody = new Uint8Array(beforeFile.length + bytes.length + afterFile.length);
|
|
uploadBody.set(beforeFile, 0);
|
|
uploadBody.set(bytes, beforeFile.length);
|
|
uploadBody.set(afterFile, beforeFile.length + bytes.length);
|
|
|
|
const uploadRes = await fetch(
|
|
"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
},
|
|
body: uploadBody,
|
|
}
|
|
);
|
|
|
|
const uploadData = await uploadRes.json();
|
|
if (uploadData.error) {
|
|
return jsonResponse(mapGoogleDriveError(uploadData.error));
|
|
}
|
|
|
|
return jsonResponse({ success: true, file: uploadData });
|
|
}
|
|
|
|
if (action === "create_folder") {
|
|
if (!isAdmin) {
|
|
return jsonResponse({ error: "Only admins can create Drive folders" }, 403);
|
|
}
|
|
|
|
const { folder_name, parent_folder_id: parentId } = body;
|
|
if (!folder_name) {
|
|
return jsonResponse({ error: "Missing folder_name" }, 400);
|
|
}
|
|
|
|
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
|
|
const metadata: any = {
|
|
name: folder_name,
|
|
mimeType: "application/vnd.google-apps.folder",
|
|
};
|
|
if (parentId) metadata.parents = [parentId];
|
|
|
|
const createRes = await fetch(
|
|
"https://www.googleapis.com/drive/v3/files?supportsAllDrives=true",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(metadata),
|
|
}
|
|
);
|
|
|
|
const createData = await createRes.json();
|
|
if (createData.error) {
|
|
return jsonResponse(mapGoogleDriveError(createData.error));
|
|
}
|
|
|
|
return jsonResponse({ success: true, folder: createData });
|
|
}
|
|
|
|
return jsonResponse({ error: "Unknown action" }, 400);
|
|
} catch (err: any) {
|
|
console.error("google-drive-proxy error:", err);
|
|
|
|
if (err?.message === "NOT_CONNECTED") {
|
|
return jsonResponse({
|
|
error: "Google account not connected. Connect Google first.",
|
|
code: "NOT_CONNECTED",
|
|
});
|
|
}
|
|
|
|
if (/invalid_grant|refresh token|REFRESH_FAILED/i.test(err?.message || "")) {
|
|
return jsonResponse({
|
|
error: "Your Google connection expired. Disconnect Google and connect it again.",
|
|
code: "RECONNECT_REQUIRED",
|
|
reconnectRequired: true,
|
|
});
|
|
}
|
|
|
|
if (/oauth client was not found|invalid_client/i.test(err?.message || "")) {
|
|
return jsonResponse({
|
|
error: "Google Drive OAuth credentials are invalid or missing. Update the Google client ID/secret in the backend, then reconnect Google Drive.",
|
|
code: "OAUTH_CLIENT_NOT_FOUND",
|
|
reconnectRequired: true,
|
|
});
|
|
}
|
|
|
|
return jsonResponse({ error: err.message }, 500);
|
|
}
|
|
});
|