Last active
January 3, 2025 10:24
-
-
Save prashant1k99/d1da73e4cd8911f5d27678196240c11e to your computer and use it in GitHub Desktop.
This Deno server uploads file to github repo using github app and also can delete for Delete endpoint
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createAppAuth } from "npm:@octokit/auth-app"; | |
import { Octokit } from "npm:@octokit/rest"; | |
import { serve } from "https://deno.land/std/http/server.ts"; | |
import { ContentType } from "npm:@octokit/types"; | |
// Configuration | |
const APP_ID = Deno.env.get("GITHUB_APP_ID") || ""; | |
const PRIVATE_KEY = Deno.env.get("GITHUB_PRIVATE_KEY") || ""; | |
const INSTALLATION_ID = Deno.env.get("GITHUB_INSTALLATION_ID") || ""; | |
const OWNER = Deno.env.get("REPO_OWNER") || ""; | |
const REPO = Deno.env.get("REPO_NAME") || ""; | |
const ALLOWED_DOMAINS = Deno.env.get("ALLOWED_DOMAINS") || "*"; | |
const ALLOWED_CONTENT_TYPES = new Set([ | |
'text/plain', | |
'text/markdown', | |
'text/html', | |
'application/json', | |
'image/png', | |
'image/jpeg', | |
'image/gif', | |
'image/svg+xml', | |
'application/pdf', | |
'application/xml', | |
'text/css', | |
'application/javascript' | |
]); | |
const MAX_FILE_SIZE = 2 * 1024 * 1024; | |
interface UploadRequest { | |
content: string | Uint8Array; | |
path: string; | |
message?: string; | |
contentType: string; | |
} | |
function validateContentType(contentType: string): boolean { | |
return ALLOWED_CONTENT_TYPES.has(contentType.toLowerCase()); | |
} | |
function validateDomain(origin: string | null): boolean { | |
// Allow all domains if ALLOWED_DOMAINS is "*" | |
if (ALLOWED_DOMAINS === "*") return true; | |
if (!origin) return false; | |
// Split domains and check each | |
const allowedDomainsList = ALLOWED_DOMAINS.split(",").map(d => d.trim()); | |
return allowedDomainsList.some(domain => | |
origin === domain || origin.endsWith(`.${domain}`) | |
); | |
} | |
function getCorsHeaders(origin: string | null): HeadersInit { | |
// If allowing all domains, return "*" or the specific origin | |
if (ALLOWED_DOMAINS === "*") { | |
return { | |
"Access-Control-Allow-Origin": "*", | |
"Access-Control-Allow-Methods": "POST, OPTIONS", | |
"Access-Control-Allow-Headers": "Content-Type, Authorization" | |
}; | |
} | |
// Otherwise, return validated origin | |
return { | |
"Access-Control-Allow-Origin": validateDomain(origin) ? origin! : "", | |
"Access-Control-Allow-Methods": "POST, OPTIONS", | |
"Access-Control-Allow-Headers": "Content-Type, Authorization", | |
"Vary": "Origin" | |
}; | |
} | |
async function processContent(content: string | Uint8Array, contentType: string): Promise<string> { | |
// If content is already a string and not binary, return base64 | |
if (typeof content === 'string') { | |
const encoder = new TextEncoder(); | |
return btoa(String.fromCharCode(...encoder.encode(content))); | |
} | |
// Handle binary data | |
if (content instanceof Uint8Array) { | |
// Process the Uint8Array in chunks to avoid exceeding the call stack limit | |
let binaryString = ""; | |
const chunkSize = 8192; // Process 8 KB at a time | |
for (let i = 0; i < content.length; i += chunkSize) { | |
binaryString += String.fromCharCode(...content.slice(i, i + chunkSize)); | |
} | |
return btoa(binaryString); | |
} | |
throw new Error("Unsupported content format"); | |
} | |
async function initializeOctokit() { | |
const auth = createAppAuth({ | |
appId: APP_ID, | |
privateKey: PRIVATE_KEY, | |
installationId: INSTALLATION_ID, | |
}); | |
const installationAuthentication = await auth({ type: "installation" }); | |
return new Octokit({ | |
auth: installationAuthentication.token, | |
}); | |
} | |
async function uploadToGithub({ content, path, message = "File upload via API", contentType }: UploadRequest) { | |
try { | |
const octokit = await initializeOctokit(); | |
// Check if file exists | |
let sha: string | undefined; | |
try { | |
const { data: existingFile } = await octokit.rest.repos.getContent({ | |
owner: OWNER, | |
repo: REPO, | |
path, | |
}); | |
if (!Array.isArray(existingFile)) { | |
sha = existingFile.sha; | |
} | |
} catch (error) { | |
console.log("File doesn't exist yet, creating new file"); | |
} | |
// Process content based on content type | |
const contentEncoded = await processContent(content, contentType); | |
// Create or update file | |
const response = await octokit.rest.repos.createOrUpdateFileContents({ | |
owner: OWNER, | |
repo: REPO, | |
path, | |
message, | |
content: contentEncoded, | |
...(sha && { sha }), | |
}); | |
return { | |
success: true, | |
data: response.data, | |
}; | |
} catch (error) { | |
console.error("Upload error:", error); | |
return { | |
success: false, | |
error: error.message, | |
details: error.response?.data || "No additional details available" | |
}; | |
} | |
} | |
async function deleteFile(path: string, message = "File deleted via API") { | |
try { | |
const octokit = await initializeOctokit(); | |
// Get the file's SHA | |
const { data: existingFile } = await octokit.rest.repos.getContent({ | |
owner: OWNER, | |
repo: REPO, | |
path, | |
}); | |
if (Array.isArray(existingFile)) { | |
throw new Error("Path points to a directory, not a file"); | |
} | |
// Delete the file | |
const response = await octokit.rest.repos.deleteFile({ | |
owner: OWNER, | |
repo: REPO, | |
path, | |
message, | |
sha: existingFile.sha, | |
}); | |
return { | |
success: true, | |
data: response.data | |
}; | |
} catch (error) { | |
console.error("Delete error:", error); | |
return { | |
success: false, | |
error: error.message, | |
details: error.response?.data || "No additional details available" | |
}; | |
} | |
} | |
serve(async (req: Request) => { | |
const origin = req.headers.get("origin"); | |
// CORS preflight | |
if (req.method === "OPTIONS") { | |
return new Response(null, { | |
headers: { | |
...getCorsHeaders(origin), | |
"Access-Control-Allow-Methods": "POST, DELETE, OPTIONS" | |
} | |
}); | |
} | |
// Validate domain (skip validation if ALLOWED_DOMAINS is "*") | |
if (ALLOWED_DOMAINS !== "*" && !validateDomain(origin)) { | |
return new Response(JSON.stringify({ | |
error: "Unauthorized domain" | |
}), { | |
status: 403, | |
headers: { | |
"Content-Type": "application/json" | |
} | |
}); | |
} | |
// Handle DELETE request | |
if (req.method === "DELETE") { | |
const url = new URL(req.url); | |
const path = url.searchParams.get("path"); | |
const message = url.searchParams.get("message") || "File deleted via API"; | |
if (!path) { | |
return new Response(JSON.stringify({ error: "Path parameter is required" }), { | |
status: 400, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
} | |
}); | |
} | |
const result = await deleteFile(path, message); | |
return new Response(JSON.stringify(result), { | |
status: result.success ? 200 : 500, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
} | |
}); | |
} | |
if (req.method !== "POST") { | |
return new Response(JSON.stringify({ error: "Method not allowed" }), { | |
status: 405, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
}, | |
}); | |
} | |
try { | |
const formData = await req.formData(); | |
const file = formData.get("file") as File; | |
const path = formData.get("path") as string; | |
const message = formData.get("message") as string; | |
if (!file || !path) { | |
return new Response( | |
JSON.stringify({ error: "File and path are required" }), { | |
status: 400, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
}, | |
} | |
); | |
} | |
// Validate file size | |
if (file.size > MAX_FILE_SIZE) { | |
return new Response( | |
JSON.stringify({ error: "File exceeds the maximum allowed size of 2 MB" }), | |
{ | |
status: 413, // HTTP 413 Payload Too Large | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin), | |
}, | |
} | |
); | |
} | |
// Read file content | |
const content = await file.arrayBuffer(); | |
const contentType = file.type || 'application/octet-stream'; | |
const result = await uploadToGithub({ | |
content: new Uint8Array(content), | |
path, | |
message, | |
contentType | |
}); | |
return new Response(JSON.stringify(result), { | |
status: result.success ? 200 : 500, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
}, | |
}); | |
} catch (error) { | |
return new Response( | |
JSON.stringify({ | |
error: "Request processing failed", | |
details: error.message | |
}), { | |
status: 400, | |
headers: { | |
"Content-Type": "application/json", | |
...getCorsHeaders(origin) | |
}, | |
} | |
); | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment