Files
xget/src/index.js
T
xixu-me 6d30820daa Prioritize specific platform paths in detection logic
Platforms are now sorted by path length in descending order before matching, ensuring that more specific paths (e.g., conda/community) are matched before general ones (e.g., conda). This prevents incorrect platform detection when similar prefixes exist.
2025-07-24 20:04:21 +08:00

443 lines
13 KiB
JavaScript

import { CONFIG } from "./config/index.js";
/**
* Monitors performance metrics during request processing
*/
class PerformanceMonitor {
/**
* Initializes a new performance monitor
*/
constructor() {
this.startTime = Date.now();
this.marks = new Map();
}
/**
* Marks a timing point with the given name
* @param {string} name - The name of the timing mark
*/
mark(name) {
if (this.marks.has(name)) {
console.warn(`Mark with name ${name} already exists.`);
}
this.marks.set(name, Date.now() - this.startTime);
}
/**
* Returns all collected metrics
* @returns {Object.<string, number>} Object containing name-timestamp pairs
*/
getMetrics() {
return Object.fromEntries(this.marks.entries());
}
}
/**
* Detects if a request is a Git operation
* @param {Request} request - The incoming request object
* @param {URL} url - Parsed URL object
* @returns {boolean} True if this is a Git operation
*/
function isGitRequest(request, url) {
// Check for Git-specific endpoints
if (url.pathname.endsWith("/info/refs")) {
return true;
}
if (
url.pathname.endsWith("/git-upload-pack") ||
url.pathname.endsWith("/git-receive-pack")
) {
return true;
}
// Check for Git user agents (more comprehensive check)
const userAgent = request.headers.get("User-Agent") || "";
if (userAgent.includes("git/") || userAgent.startsWith("git/")) {
return true;
}
// Check for Git-specific query parameters
if (url.searchParams.has("service")) {
const service = url.searchParams.get("service");
return service === "git-upload-pack" || service === "git-receive-pack";
}
// Check for Git-specific content types
const contentType = request.headers.get("Content-Type") || "";
if (
contentType.includes("git-upload-pack") ||
contentType.includes("git-receive-pack")
) {
return true;
}
return false;
}
/**
* Validates incoming requests against security rules
* @param {Request} request - The incoming request object
* @param {URL} url - Parsed URL object
* @returns {{valid: boolean, error?: string, status?: number}} Validation result
*/
function validateRequest(request, url) {
// Allow POST method for Git operations
const allowedMethods = isGitRequest(request, url)
? ["GET", "HEAD", "POST"]
: CONFIG.SECURITY.ALLOWED_METHODS;
if (!allowedMethods.includes(request.method)) {
return { valid: false, error: "Method not allowed", status: 405 };
}
if (url.pathname.length > CONFIG.SECURITY.MAX_PATH_LENGTH) {
return { valid: false, error: "Path too long", status: 414 };
}
return { valid: true };
}
/**
* Adds security headers to the response
* @param {Headers} headers - Headers object to modify
* @returns {Headers} Modified headers object
*/
function addSecurityHeaders(headers) {
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
headers.set("X-Frame-Options", "DENY");
headers.set("X-XSS-Protection", "1; mode=block");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set(
"Content-Security-Policy",
"default-src 'none'; img-src 'self'; script-src 'none'"
);
headers.set("Permissions-Policy", "interest-cohort=()");
return headers;
}
/**
* Handles incoming requests with caching, retries, and security measures
* @param {Request} request - The incoming request
* @param {Object} env - Environment variables
* @param {ExecutionContext} ctx - Cloudflare Workers execution context
* @returns {Promise<Response>} The response object
*/
async function handleRequest(request, env, ctx) {
try {
const url = new URL(request.url);
const monitor = new PerformanceMonitor();
// Redirect root path or invalid platforms to GitHub repository
if (url.pathname === "/" || url.pathname === "") {
const HOME_PAGE_URL = "https://github.com/xixu-me/Xget";
return Response.redirect(HOME_PAGE_URL, 302);
}
const validation = validateRequest(request, url);
if (!validation.valid) {
return new Response(validation.error, {
status: validation.status,
headers: addSecurityHeaders(new Headers()),
});
}
// Parse platform and path
let platform;
// Platform detection using transform patterns
// Sort platforms by path length (descending) to prioritize more specific paths
// e.g., conda/community should match before conda, pypi/files before pypi
const sortedPlatforms = Object.keys(CONFIG.PLATFORMS).sort((a, b) => {
const pathA = `/${a.replace("-", "/")}/`;
const pathB = `/${b.replace("-", "/")}/`;
return pathB.length - pathA.length;
});
platform =
sortedPlatforms.find((key) => {
const expectedPrefix = `/${key.replace("-", "/")}/`;
return url.pathname.startsWith(expectedPrefix);
}) || url.pathname.split("/")[1];
if (!platform || !CONFIG.PLATFORMS[platform]) {
const HOME_PAGE_URL = "https://github.com/xixu-me/Xget";
return Response.redirect(HOME_PAGE_URL, 302);
}
// Transform URL based on platform
const targetPath = CONFIG.PLATFORMS[platform].transform(url.pathname);
const targetUrl = `${CONFIG.PLATFORMS[platform].base}${targetPath}${url.search}`;
// Check if this is a Git operation
const isGit = isGitRequest(request, url);
// Check cache first (skip cache for Git operations)
const cache = caches.default;
const cacheKey = new Request(targetUrl, request);
let response;
if (!isGit) {
response = await cache.match(cacheKey);
if (response) {
monitor.mark("cache_hit");
return response;
}
}
const fetchOptions = {
method: request.method,
headers: new Headers(),
};
// Add body for POST requests (Git operations)
if (request.method === "POST" && isGit) {
// For Git operations, we need to preserve the original body stream
fetchOptions.body = request.body;
}
// Set appropriate headers for Git vs regular requests
if (isGit) {
// For Git operations, copy all headers from the original request
// This ensures Git protocol compliance
for (const [key, value] of request.headers.entries()) {
// Skip headers that might cause issues with proxying
if (
!["host", "connection", "upgrade", "proxy-connection"].includes(
key.toLowerCase()
)
) {
fetchOptions.headers.set(key, value);
}
}
// Set Git-specific headers if not present
if (!fetchOptions.headers.has("User-Agent")) {
fetchOptions.headers.set("User-Agent", "git/2.34.1");
}
// For Git upload-pack requests, ensure proper content type
if (
request.method === "POST" &&
url.pathname.endsWith("/git-upload-pack")
) {
if (!fetchOptions.headers.has("Content-Type")) {
fetchOptions.headers.set(
"Content-Type",
"application/x-git-upload-pack-request"
);
}
}
// For Git receive-pack requests, ensure proper content type
if (
request.method === "POST" &&
url.pathname.endsWith("/git-receive-pack")
) {
if (!fetchOptions.headers.has("Content-Type")) {
fetchOptions.headers.set(
"Content-Type",
"application/x-git-receive-pack-request"
);
}
}
} else {
// Regular file download headers
Object.assign(fetchOptions, {
cf: {
http3: true,
cacheTtl: CONFIG.CACHE_DURATION,
cacheEverything: true,
minify: {
javascript: true,
css: true,
html: true,
},
preconnect: true,
},
});
fetchOptions.headers.set("Accept-Encoding", "gzip, deflate, br");
fetchOptions.headers.set("Connection", "keep-alive");
fetchOptions.headers.set("User-Agent", "Wget/1.21.3");
fetchOptions.headers.set("Origin", request.headers.get("Origin") || "*");
// Handle range requests
const rangeHeader = request.headers.get("Range");
if (rangeHeader) {
fetchOptions.headers.set("Range", rangeHeader);
}
}
// Implement retry mechanism
let attempts = 0;
while (attempts < CONFIG.MAX_RETRIES) {
try {
monitor.mark("attempt_" + attempts);
// Fetch with timeout
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
CONFIG.TIMEOUT_SECONDS * 1000
);
// For Git operations, don't use Cloudflare-specific options
const finalFetchOptions = isGit
? { ...fetchOptions, signal: controller.signal }
: { ...fetchOptions, signal: controller.signal };
response = await fetch(targetUrl, finalFetchOptions);
clearTimeout(timeoutId);
if (response.ok || response.status === 206) {
monitor.mark("success");
break;
}
// Don't retry on client errors (4xx) - these won't improve with retries
if (response.status >= 400 && response.status < 500) {
monitor.mark("client_error");
break;
}
attempts++;
if (attempts < CONFIG.MAX_RETRIES) {
await new Promise((resolve) =>
setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)
);
}
} catch (error) {
attempts++;
if (error.name === "AbortError") {
return new Response("Request timeout", {
status: 408,
headers: addSecurityHeaders(new Headers()),
});
}
if (attempts >= CONFIG.MAX_RETRIES) {
return new Response(
`Failed after ${CONFIG.MAX_RETRIES} attempts: ${error.message}`,
{
status: 500,
headers: addSecurityHeaders(new Headers()),
}
);
}
// Wait before retrying
await new Promise((resolve) =>
setTimeout(resolve, CONFIG.RETRY_DELAY_MS * attempts)
);
}
}
// Check if we have a valid response after all attempts
if (!response) {
return new Response("No response received after all retry attempts", {
status: 500,
headers: addSecurityHeaders(new Headers()),
});
}
// If response is still not ok after all retries, return the error
if (!response.ok && response.status !== 206) {
const errorText = await response.text().catch(() => "Unknown error");
return new Response(
`Upstream server error (${response.status}): ${errorText}`,
{
status: response.status,
headers: addSecurityHeaders(new Headers()),
}
);
}
// Handle PyPI simple index URL rewriting
let responseBody = response.body;
if (
platform === "pypi" &&
response.headers.get("content-type")?.includes("text/html")
) {
const originalText = await response.text();
// Rewrite URLs in the response body to go through the Cloudflare Worker
// files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint
const rewrittenText = originalText.replace(
/https:\/\/files\.pythonhosted\.org/g,
`${url.origin}/pypi/files`
);
responseBody = rewrittenText;
}
// Prepare response headers
const headers = new Headers(response.headers);
if (isGit) {
// For Git operations, preserve all headers from the upstream response
// Git protocol is very sensitive to header changes
// Don't add any additional headers that might interfere with Git protocol
// The response headers from GitHub/GitLab should be passed through as-is
} else {
// Regular file download headers
headers.set("Cache-Control", `public, max-age=${CONFIG.CACHE_DURATION}`);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Accept-Ranges", "bytes");
addSecurityHeaders(headers);
}
// Create final response
const finalResponse = new Response(responseBody, {
status: response.status,
headers: headers,
});
// Cache successful responses (skip caching for Git operations)
if (!isGit && (response.ok || response.status === 206)) {
ctx.waitUntil(cache.put(cacheKey, finalResponse.clone()));
}
monitor.mark("complete");
return isGit
? finalResponse
: addPerformanceHeaders(finalResponse, monitor);
} catch (error) {
console.error("Error handling request:", error);
return new Response(`Internal Server Error: ${error.message}`, {
status: 500,
headers: addSecurityHeaders(new Headers()),
});
}
}
/**
* Adds performance metrics to response headers
* @param {Response} response - The response object
* @param {PerformanceMonitor} monitor - Performance monitor instance
* @returns {Response} New response with performance headers
*/
function addPerformanceHeaders(response, monitor) {
const headers = new Headers(response.headers);
headers.set("X-Performance-Metrics", JSON.stringify(monitor.getMetrics()));
addSecurityHeaders(headers);
return new Response(response.body, {
status: response.status,
headers: headers,
});
}
export default {
/**
* Main entry point for the Cloudflare Worker
* @param {Request} request - The incoming request
* @param {Object} env - Environment variables
* @param {ExecutionContext} ctx - Cloudflare Workers execution context
* @returns {Promise<Response>} The response object
*/
fetch(request, env, ctx) {
return handleRequest(request, env, ctx);
},
};