mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-13 04:03:42 +08:00
1100 lines
39 KiB
JavaScript
1100 lines
39 KiB
JavaScript
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
import { fetchSecurityConfig } from "../utils/sysConfig";
|
|
import { TelegramAPI } from "../utils/storage/telegramAPI";
|
|
import { DiscordAPI } from "../utils/storage/discordAPI";
|
|
import { HuggingFaceAPI } from "../utils/storage/huggingfaceAPI";
|
|
import { buildWebDAVUrl, WebDAVAPI } from "../utils/storage/webdavAPI";
|
|
import {
|
|
setCommonHeaders, setRangeHeaders, handleHeadRequest, getFileContent, isTgChannel,
|
|
returnWithCheck, return404, returnBlockImg, isDomainAllowed, FILE_CACHE_CONTROL
|
|
} from './fileTools';
|
|
import { getDatabase } from '../utils/databaseAdapter.js';
|
|
import { authenticate, AUTH_SCOPE } from '../utils/auth/authCore.js';
|
|
import {
|
|
resolveDiscordCredentials,
|
|
resolveHuggingFaceCredentials,
|
|
resolveS3Credentials,
|
|
resolveTelegramCredentials,
|
|
resolveWebDAVCredentials,
|
|
} from '../utils/metadata/channelCredentials.js';
|
|
import { buildCdnFileUrl } from '../utils/metadata/metadataView.js';
|
|
import {
|
|
parseImageTransform,
|
|
transformImageResponse,
|
|
validateImageTransformRequest,
|
|
} from './imageTransform.js';
|
|
|
|
|
|
export async function onRequest(context) { // Contents of context object
|
|
const {
|
|
request, // same as existing Worker API
|
|
env, // same as existing Worker API
|
|
params, // if filename includes [id] or [[path]]
|
|
waitUntil, // same as ctx.waitUntil in existing Worker API
|
|
next, // used for middleware or to fetch assets
|
|
data, // arbitrary space for passing data between middlewares
|
|
} = context;
|
|
|
|
// 解码文件ID
|
|
let fileId = '';
|
|
try {
|
|
params.path = decodeURIComponent(params.path);
|
|
fileId = params.path.split(',').join('/');
|
|
} catch (e) {
|
|
return new Response('Error: Decode Image ID Failed', { status: 400 });
|
|
}
|
|
|
|
// 读取安全配置,解析必要参数
|
|
const securityConfig = await fetchSecurityConfig(env);
|
|
context.securityConfig = securityConfig;
|
|
|
|
const url = new URL(request.url);
|
|
context.url = url;
|
|
|
|
context.imageTransform = parseImageTransform(url, securityConfig.access);
|
|
const imageTransformError = validateImageTransformRequest(request, context.imageTransform);
|
|
if (imageTransformError) {
|
|
return imageTransformError;
|
|
}
|
|
|
|
const Referer = request.headers.get('Referer')
|
|
context.Referer = Referer;
|
|
|
|
context.fileAccess = await buildFileAccessContext(context);
|
|
|
|
// 检查引用域名是否被允许
|
|
if (!isDomainAllowed(context)) {
|
|
return await returnBlockImg(url);
|
|
}
|
|
|
|
// 从数据库中获取图片记录
|
|
const db = getDatabase(env);
|
|
const imgRecord = await db.getWithMetadata(fileId);
|
|
if (!imgRecord) {
|
|
return new Response('Error: Image Not Found', { status: 404 });
|
|
}
|
|
|
|
// 如果metadata不存在,只可能是之前未设置KV,且存储在Telegraph上的图片
|
|
if (!imgRecord.metadata) {
|
|
imgRecord.metadata = {};
|
|
}
|
|
|
|
const fileName = imgRecord.metadata?.FileName || fileId;
|
|
const encodedFileName = encodeURIComponent(fileName);
|
|
const fileType = imgRecord.metadata?.FileType || null;
|
|
|
|
// 检查文件可访问状态
|
|
let accessRes = await returnWithCheck(context, imgRecord);
|
|
if (accessRes.status !== 200) {
|
|
return accessRes; // 如果不可访问,直接返回
|
|
}
|
|
|
|
/* Cloudflare R2渠道 */
|
|
if (imgRecord.metadata?.Channel === 'CloudflareR2') {
|
|
const response = await handleR2File(context, fileId, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
/* S3渠道 */
|
|
if (imgRecord.metadata?.Channel === "S3") {
|
|
const response = await handleS3File(context, imgRecord.metadata, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
/* Discord 渠道 */
|
|
if (imgRecord.metadata?.Channel === 'Discord') {
|
|
// 检查是否为分片文件
|
|
if (imgRecord.metadata?.IsChunked === true) {
|
|
const response = await handleDiscordChunkedFile(context, imgRecord, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
const response = await handleDiscordFile(context, imgRecord.metadata, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
/* HuggingFace 渠道 */
|
|
if (imgRecord.metadata?.Channel === 'HuggingFace') {
|
|
const response = await handleHuggingFaceFile(context, imgRecord.metadata, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
/* WebDAV 渠道 */
|
|
if (imgRecord.metadata?.Channel === 'WebDAV') {
|
|
const response = await handleWebDAVFile(context, imgRecord.metadata, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
/* 外链渠道 */
|
|
if (imgRecord.metadata?.Channel === 'External') {
|
|
if (!context.imageTransform.requested) {
|
|
// 未请求图片处理时维持原有的外链重定向逻辑
|
|
return Response.redirect(imgRecord.metadata?.ExternalLink, 302);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(imgRecord.metadata?.ExternalLink);
|
|
if (!response.ok) return response;
|
|
|
|
const headers = new Headers(response.headers);
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
return await transformImageResponse(context, new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
}));
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch external image - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/* Telegram及Telegraph渠道 */
|
|
|
|
// 构建目标 URL
|
|
let targetUrl = '';
|
|
|
|
if (isTgChannel(imgRecord)) {
|
|
let TgFileID = ''; // Tg的file_id
|
|
|
|
if (imgRecord.metadata?.Channel === 'Telegram') {
|
|
TgFileID = fileId.split('.')[0]; // id为file_id + ext
|
|
} else if (imgRecord.metadata?.Channel === 'TelegramNew') {
|
|
// 检查是否为分片文件
|
|
if (imgRecord.metadata?.IsChunked === true) {
|
|
const response = await handleTelegramChunkedFile(context, imgRecord, encodedFileName, fileType);
|
|
return await transformImageResponse(context, response);
|
|
}
|
|
|
|
TgFileID = imgRecord.metadata?.TgFileId;
|
|
|
|
if (TgFileID === null) {
|
|
return new Response('Error: Failed to fetch image', { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 获取TG图片真实地址(支持代理域名)
|
|
const tgCredentials = await resolveTelegramCredentials(db, env, imgRecord.metadata);
|
|
const TgBotToken = tgCredentials.botToken;
|
|
const TgProxyUrl = tgCredentials.proxyUrl || '';
|
|
const tgApi = new TelegramAPI(TgBotToken, TgProxyUrl);
|
|
const filePath = await tgApi.getFilePath(TgFileID);
|
|
if (filePath === null) {
|
|
return new Response('Error: Failed to fetch image path', { status: 500 });
|
|
}
|
|
// 使用代理域名或官方域名
|
|
const fileDomain = TgProxyUrl ? `https://${TgProxyUrl}` : 'https://api.telegram.org';
|
|
targetUrl = `${fileDomain}/file/bot${TgBotToken}/${filePath}`;
|
|
} else {
|
|
targetUrl = 'https://telegra.ph/' + url.pathname + url.search;
|
|
}
|
|
|
|
try {
|
|
const response = await getFileContent(request, targetUrl);
|
|
|
|
if (response === null) {
|
|
return new Response('Error: Failed to fetch image', { status: 500 });
|
|
} else if (response.status === 404) {
|
|
return await return404(url);
|
|
}
|
|
|
|
const headers = new Headers(response.headers);
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
const newRes = new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
|
|
return await transformImageResponse(context, newRes);
|
|
} catch (error) {
|
|
return new Response('Error: ' + error, { status: 500 });
|
|
}
|
|
}
|
|
|
|
async function buildFileAccessContext(context) {
|
|
const { request, env, url } = context;
|
|
const fromAdmin = url.searchParams.get('from') === 'admin';
|
|
const fileAccess = {
|
|
isAdminPreview: fromAdmin,
|
|
adminAuthResult: { authorized: false, authType: null },
|
|
cacheControl: undefined,
|
|
};
|
|
|
|
if (fileAccess.isAdminPreview) {
|
|
fileAccess.adminAuthResult = await authenticate({
|
|
env,
|
|
request,
|
|
requiredPermission: 'manage',
|
|
authScope: AUTH_SCOPE.ADMIN,
|
|
});
|
|
}
|
|
|
|
return fileAccess;
|
|
}
|
|
|
|
function getFileCacheControl(context) {
|
|
return context.fileAccess?.cacheControl;
|
|
}
|
|
|
|
function getChunkedFileCacheControl(context) {
|
|
return getFileCacheControl(context) === FILE_CACHE_CONTROL.NO_STORE
|
|
? FILE_CACHE_CONTROL.NO_STORE
|
|
: FILE_CACHE_CONTROL.PRIVATE;
|
|
}
|
|
|
|
|
|
// 处理 Telegram 渠道分片文件读取
|
|
async function handleTelegramChunkedFile(context, imgRecord, encodedFileName, fileType) {
|
|
const { env, request, url, Referer } = context;
|
|
|
|
const metadata = imgRecord.metadata;
|
|
const db = getDatabase(env);
|
|
const tgCredentials = await resolveTelegramCredentials(db, env, metadata);
|
|
const TgBotToken = tgCredentials.botToken;
|
|
const TgProxyUrl = tgCredentials.proxyUrl || '';
|
|
|
|
// 从KV的value中读取分片信息
|
|
let chunks = [];
|
|
try {
|
|
if (imgRecord.value) {
|
|
chunks = JSON.parse(imgRecord.value);
|
|
// 确保分片按索引排序
|
|
chunks.sort((a, b) => a.index - b.index);
|
|
}
|
|
} catch (parseError) {
|
|
console.error('Failed to parse chunks data:', parseError);
|
|
return new Response('Error: Invalid chunks data', { status: 500 });
|
|
}
|
|
|
|
if (chunks.length === 0) {
|
|
return new Response('Error: No chunks found for this file', { status: 500 });
|
|
}
|
|
|
|
// 验证分片完整性
|
|
const expectedChunks = metadata.TotalChunks || chunks.length;
|
|
if (chunks.length !== expectedChunks) {
|
|
return new Response(`Error: Missing chunks, expected ${expectedChunks}, got ${chunks.length}`, { status: 500 });
|
|
}
|
|
|
|
// 计算文件总大小
|
|
const totalSize = chunks.reduce((total, chunk) => total + (chunk.size || 0), 0);
|
|
|
|
// 构建响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getChunkedFileCacheControl(context));
|
|
headers.set('Content-Length', totalSize.toString());
|
|
|
|
// 添加ETag支持
|
|
const etag = `"${metadata.TimeStamp || Date.now()}-${totalSize}"`;
|
|
headers.set('ETag', etag);
|
|
|
|
// 检查If-None-Match头(304缓存)
|
|
const ifNoneMatch = request.headers.get('If-None-Match');
|
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
return new Response(null, {
|
|
status: 304,
|
|
headers: {
|
|
'ETag': etag,
|
|
'Cache-Control': headers.get('Cache-Control'),
|
|
'Accept-Ranges': 'bytes'
|
|
}
|
|
});
|
|
}
|
|
|
|
// 检查Range请求头
|
|
const range = request.headers.get('Range');
|
|
let rangeStart = 0;
|
|
let rangeEnd = totalSize - 1;
|
|
let isRangeRequest = false;
|
|
|
|
if (range) {
|
|
const matches = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (matches) {
|
|
rangeStart = parseInt(matches[1]);
|
|
rangeEnd = matches[2] ? parseInt(matches[2]) : totalSize - 1;
|
|
isRangeRequest = true;
|
|
|
|
// 验证范围有效性
|
|
if (rangeStart >= totalSize || rangeEnd >= totalSize || rangeStart > rangeEnd) {
|
|
return new Response('Range Not Satisfiable', { status: 416 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理HEAD请求
|
|
if (request.method === 'HEAD') {
|
|
return handleHeadRequest(headers, etag);
|
|
}
|
|
|
|
try {
|
|
// 创建支持Range请求的流
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
let currentPosition = 0;
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const chunk = chunks[i];
|
|
const chunkSize = chunk.size || 0;
|
|
|
|
// 如果当前分片完全在请求范围之前,跳过
|
|
if (currentPosition + chunkSize <= rangeStart) {
|
|
currentPosition += chunkSize;
|
|
continue;
|
|
}
|
|
|
|
// 如果当前分片完全在请求范围之后,结束
|
|
if (currentPosition > rangeEnd) {
|
|
break;
|
|
}
|
|
|
|
// 获取分片数据(支持代理域名)
|
|
const chunkData = await fetchTelegramChunkWithRetry(TgBotToken, chunk, TgProxyUrl, 3);
|
|
if (!chunkData) {
|
|
throw new Error(`Failed to fetch chunk ${chunk.index} after retries`);
|
|
}
|
|
|
|
// 计算在当前分片中的起始和结束位置
|
|
const chunkStart = Math.max(0, rangeStart - currentPosition);
|
|
const chunkEnd = Math.min(chunkSize, rangeEnd - currentPosition + 1);
|
|
|
|
// 如果需要部分分片数据
|
|
if (chunkStart > 0 || chunkEnd < chunkSize) {
|
|
const partialData = chunkData.slice(chunkStart, chunkEnd);
|
|
controller.enqueue(partialData);
|
|
} else {
|
|
controller.enqueue(chunkData);
|
|
}
|
|
|
|
currentPosition += chunkSize;
|
|
}
|
|
|
|
controller.close();
|
|
} catch (error) {
|
|
controller.error(error);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 设置Range相关头部
|
|
if (isRangeRequest) {
|
|
setRangeHeaders(headers, rangeStart, rangeEnd, totalSize);
|
|
|
|
return new Response(stream, {
|
|
status: 206, // Partial Content
|
|
headers,
|
|
});
|
|
} else {
|
|
headers.set('Cache-Control', getChunkedFileCacheControl(context)); // CDN 不缓存完整文件,避免 CDN 不支持 Range 请求
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to reconstruct chunked file - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 带重试机制的Telegram分片获取函数(支持代理域名)
|
|
async function fetchTelegramChunkWithRetry(botToken, chunk, proxyUrl = '', maxRetries = 3) {
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
const tgApi = new TelegramAPI(botToken, proxyUrl);
|
|
|
|
const response = await tgApi.getFileContent(chunk.fileId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
// 验证分片大小是否匹配
|
|
const chunkData = await response.arrayBuffer();
|
|
const actualSize = chunkData.byteLength;
|
|
|
|
// 如果有期望大小且不匹配,抛出错误
|
|
if (chunk.size && actualSize !== chunk.size) {
|
|
console.warn(`Chunk ${chunk.index} size mismatch: expected ${chunk.size}, got ${actualSize}`);
|
|
}
|
|
|
|
return new Uint8Array(chunkData);
|
|
|
|
} catch (error) {
|
|
console.warn(`Chunk ${chunk.index} fetch attempt ${attempt + 1} failed:`, error.message);
|
|
|
|
if (attempt === maxRetries - 1) {
|
|
return null; // 最后一次尝试也失败了
|
|
}
|
|
|
|
// 重试前等待一段时间
|
|
await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 处理 Discord 渠道分片文件读取
|
|
async function handleDiscordChunkedFile(context, imgRecord, encodedFileName, fileType) {
|
|
const { env, request, url, Referer } = context;
|
|
|
|
const metadata = imgRecord.metadata;
|
|
const db = getDatabase(env);
|
|
const discordCredentials = await resolveDiscordCredentials(db, env, metadata);
|
|
const botToken = discordCredentials.botToken;
|
|
const channelId = discordCredentials.channelId;
|
|
const proxyUrl = discordCredentials.proxyUrl;
|
|
|
|
// 从KV的value中读取分片信息
|
|
let chunks = [];
|
|
try {
|
|
if (imgRecord.value) {
|
|
chunks = JSON.parse(imgRecord.value);
|
|
// 确保分片按索引排序
|
|
chunks.sort((a, b) => a.index - b.index);
|
|
}
|
|
} catch (parseError) {
|
|
console.error('Failed to parse Discord chunks data:', parseError);
|
|
return new Response('Error: Invalid chunks data', { status: 500 });
|
|
}
|
|
|
|
if (chunks.length === 0) {
|
|
return new Response('Error: No chunks found for this file', { status: 500 });
|
|
}
|
|
|
|
// 验证分片完整性
|
|
const expectedChunks = metadata.TotalChunks || chunks.length;
|
|
if (chunks.length !== expectedChunks) {
|
|
return new Response(`Error: Missing chunks, expected ${expectedChunks}, got ${chunks.length}`, { status: 500 });
|
|
}
|
|
|
|
// 计算文件总大小
|
|
const totalSize = chunks.reduce((total, chunk) => total + (chunk.size || 0), 0);
|
|
|
|
// 构建响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getChunkedFileCacheControl(context));
|
|
headers.set('Content-Length', totalSize.toString());
|
|
|
|
// 添加ETag支持
|
|
const etag = `"${metadata.TimeStamp || Date.now()}-${totalSize}"`;
|
|
headers.set('ETag', etag);
|
|
|
|
// 检查If-None-Match头(304缓存)
|
|
const ifNoneMatch = request.headers.get('If-None-Match');
|
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
return new Response(null, {
|
|
status: 304,
|
|
headers: {
|
|
'ETag': etag,
|
|
'Cache-Control': headers.get('Cache-Control'),
|
|
'Accept-Ranges': 'bytes'
|
|
}
|
|
});
|
|
}
|
|
|
|
// 检查Range请求头
|
|
const range = request.headers.get('Range');
|
|
let rangeStart = 0;
|
|
let rangeEnd = totalSize - 1;
|
|
let isRangeRequest = false;
|
|
|
|
if (range) {
|
|
const matches = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (matches) {
|
|
rangeStart = parseInt(matches[1]);
|
|
rangeEnd = matches[2] ? parseInt(matches[2]) : totalSize - 1;
|
|
isRangeRequest = true;
|
|
|
|
// 验证范围有效性
|
|
if (rangeStart >= totalSize || rangeEnd >= totalSize || rangeStart > rangeEnd) {
|
|
return new Response('Range Not Satisfiable', { status: 416 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理HEAD请求
|
|
if (request.method === 'HEAD') {
|
|
return handleHeadRequest(headers, etag);
|
|
}
|
|
|
|
try {
|
|
// 创建支持Range请求的流
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
let currentPosition = 0;
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const chunk = chunks[i];
|
|
const chunkSize = chunk.size || 0;
|
|
|
|
// 如果当前分片完全在请求范围之前,跳过
|
|
if (currentPosition + chunkSize <= rangeStart) {
|
|
currentPosition += chunkSize;
|
|
continue;
|
|
}
|
|
|
|
// 如果当前分片完全在请求范围之后,结束
|
|
if (currentPosition > rangeEnd) {
|
|
break;
|
|
}
|
|
|
|
// 获取分片数据(每次通过 API 获取新的附件 URL)
|
|
const chunkData = await fetchDiscordChunkWithRetry(botToken, channelId, chunk, proxyUrl, 3);
|
|
if (!chunkData) {
|
|
throw new Error(`Failed to fetch Discord chunk ${chunk.index} after retries`);
|
|
}
|
|
|
|
// 计算在当前分片中的起始和结束位置
|
|
const chunkStart = Math.max(0, rangeStart - currentPosition);
|
|
const chunkEnd = Math.min(chunkSize, rangeEnd - currentPosition + 1);
|
|
|
|
// 如果需要部分分片数据
|
|
if (chunkStart > 0 || chunkEnd < chunkSize) {
|
|
const partialData = chunkData.slice(chunkStart, chunkEnd);
|
|
controller.enqueue(partialData);
|
|
} else {
|
|
controller.enqueue(chunkData);
|
|
}
|
|
|
|
currentPosition += chunkSize;
|
|
}
|
|
|
|
controller.close();
|
|
} catch (error) {
|
|
controller.error(error);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 设置Range相关头部
|
|
if (isRangeRequest) {
|
|
setRangeHeaders(headers, rangeStart, rangeEnd, totalSize);
|
|
|
|
return new Response(stream, {
|
|
status: 206, // Partial Content
|
|
headers,
|
|
});
|
|
} else {
|
|
headers.set('Cache-Control', getChunkedFileCacheControl(context));
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to reconstruct Discord chunked file - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 带重试机制的Discord分片获取函数(每次通过 API 获取新的附件 URL)
|
|
async function fetchDiscordChunkWithRetry(botToken, channelId, chunk, proxyUrl, maxRetries = 3) {
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
// 通过 Discord API 获取新的附件 URL(因为 URL 会在约24小时后过期)
|
|
const discordAPI = new DiscordAPI(botToken);
|
|
let fileUrl = await discordAPI.getFileURL(channelId, chunk.messageId);
|
|
|
|
if (!fileUrl) {
|
|
throw new Error('Failed to get attachment URL from Discord API');
|
|
}
|
|
|
|
// 如果配置了代理 URL,替换 Discord CDN 域名
|
|
if (proxyUrl) {
|
|
fileUrl = fileUrl.replace('https://cdn.discordapp.com', `https://${proxyUrl}`);
|
|
}
|
|
|
|
const response = await fetch(fileUrl);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
// 验证分片大小是否匹配
|
|
const chunkData = await response.arrayBuffer();
|
|
const actualSize = chunkData.byteLength;
|
|
|
|
// 如果有期望大小且不匹配,记录警告
|
|
if (chunk.size && actualSize !== chunk.size) {
|
|
console.warn(`Discord chunk ${chunk.index} size mismatch: expected ${chunk.size}, got ${actualSize}`);
|
|
}
|
|
|
|
return new Uint8Array(chunkData);
|
|
|
|
} catch (error) {
|
|
console.warn(`Discord chunk ${chunk.index} fetch attempt ${attempt + 1} failed:`, error.message);
|
|
|
|
if (attempt === maxRetries - 1) {
|
|
return null; // 最后一次尝试也失败了
|
|
}
|
|
|
|
// 重试前等待一段时间
|
|
await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 处理R2文件读取
|
|
async function handleR2File(context, fileId, encodedFileName, fileType) {
|
|
const { env, request, url, Referer } = context;
|
|
|
|
try {
|
|
// 检查是否配置了R2
|
|
if (typeof env.img_r2 == "undefined" || env.img_r2 == null || env.img_r2 == "") {
|
|
return new Response('Error: Please configure R2 database', { status: 500 });
|
|
}
|
|
|
|
const R2DataBase = env.img_r2;
|
|
|
|
// 检查Range请求头
|
|
const range = request.headers.get('Range');
|
|
let object;
|
|
|
|
if (range) {
|
|
// 处理Range请求
|
|
const matches = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (matches) {
|
|
const start = parseInt(matches[1]);
|
|
const end = matches[2] ? parseInt(matches[2]) : undefined;
|
|
|
|
const rangeOptions = {
|
|
range: {
|
|
offset: start
|
|
}
|
|
};
|
|
if (end !== undefined) {
|
|
rangeOptions.range.length = end - start + 1;
|
|
}
|
|
|
|
object = await R2DataBase.get(fileId, rangeOptions);
|
|
} else {
|
|
object = await R2DataBase.get(fileId);
|
|
}
|
|
} else {
|
|
object = await R2DataBase.get(fileId);
|
|
}
|
|
|
|
if (object === null) {
|
|
return new Response('Error: Failed to fetch file', { status: 500 });
|
|
}
|
|
|
|
const headers = new Headers();
|
|
object.writeHttpMetadata(headers);
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
// 处理HEAD请求
|
|
if (request.method === 'HEAD') {
|
|
return handleHeadRequest(headers);
|
|
}
|
|
|
|
// 如果是Range请求,设置相应的状态码和头
|
|
if (range && object.range) {
|
|
headers.set('Content-Range', `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`);
|
|
headers.set('Content-Length', object.range.length.toString());
|
|
|
|
return new Response(object.body, {
|
|
status: 206, // Partial Content
|
|
headers,
|
|
});
|
|
}
|
|
|
|
// 正常请求
|
|
return new Response(object.body, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch from R2 - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// 处理S3文件读取
|
|
async function handleS3File(context, metadata, encodedFileName, fileType) {
|
|
const { Referer, url, request } = context;
|
|
|
|
// 检查是否配置了 CDN 文件完整路径
|
|
const cdnFileUrl = await getS3CdnFileUrl(context.env, metadata);
|
|
|
|
// 如果配置了 CDN 文件路径,通过 CDN 读取文件
|
|
if (cdnFileUrl) {
|
|
try {
|
|
// 处理 HEAD 请求
|
|
if (request.method === 'HEAD') {
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
return handleHeadRequest(headers);
|
|
}
|
|
|
|
// 构建请求头
|
|
const fetchHeaders = {};
|
|
|
|
// 支持 Range 请求
|
|
const range = request.headers.get('Range');
|
|
if (range) {
|
|
fetchHeaders['Range'] = range;
|
|
}
|
|
|
|
// 通过 CDN 获取文件(直接使用完整路径,无需拼接)
|
|
const response = await fetch(cdnFileUrl, {
|
|
method: 'GET',
|
|
headers: fetchHeaders
|
|
});
|
|
|
|
if (!response.ok && response.status !== 206) {
|
|
// CDN 读取失败,回退到 S3 API
|
|
console.warn(`CDN fetch failed (${response.status}), falling back to S3 API`);
|
|
return await handleS3FileViaAPI(context, metadata, encodedFileName, fileType);
|
|
}
|
|
|
|
// 构建响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
// 复制相关头部
|
|
if (response.headers.get('Content-Length')) {
|
|
headers.set('Content-Length', response.headers.get('Content-Length'));
|
|
}
|
|
if (response.headers.get('Content-Range')) {
|
|
headers.set('Content-Range', response.headers.get('Content-Range'));
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers
|
|
});
|
|
|
|
} catch (error) {
|
|
// CDN 读取出错,回退到 S3 API
|
|
console.error(`CDN fetch error: ${error.message}, falling back to S3 API`);
|
|
return await handleS3FileViaAPI(context, metadata, encodedFileName, fileType);
|
|
}
|
|
}
|
|
|
|
// 没有配置 CDN 文件路径,使用 S3 API
|
|
return await handleS3FileViaAPI(context, metadata, encodedFileName, fileType);
|
|
}
|
|
|
|
async function getS3CdnFileUrl(env, metadata) {
|
|
try {
|
|
const db = getDatabase(env);
|
|
const s3Credentials = await resolveS3Credentials(db, env, metadata);
|
|
return buildCdnFileUrl(s3Credentials.cdnDomain, s3Credentials.key);
|
|
} catch (error) {
|
|
console.warn('Failed to build S3 CDN file URL:', error.message);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// 通过 S3 API 读取文件
|
|
async function handleS3FileViaAPI(context, metadata, encodedFileName, fileType) {
|
|
const { Referer, url, request, env } = context;
|
|
const db = getDatabase(env);
|
|
const s3Credentials = await resolveS3Credentials(db, env, metadata);
|
|
|
|
const s3Client = new S3Client({
|
|
region: s3Credentials.region || "auto",
|
|
endpoint: s3Credentials.endpoint,
|
|
credentials: {
|
|
accessKeyId: s3Credentials.accessKeyId,
|
|
secretAccessKey: s3Credentials.secretAccessKey
|
|
},
|
|
forcePathStyle: s3Credentials.pathStyle || false
|
|
});
|
|
|
|
const bucketName = s3Credentials.bucketName;
|
|
const key = s3Credentials.key;
|
|
|
|
try {
|
|
// 检查Range请求头
|
|
const range = request.headers.get('Range');
|
|
const commandParams = {
|
|
Bucket: bucketName,
|
|
Key: key
|
|
};
|
|
|
|
if (range) {
|
|
// 添加Range参数用于部分内容请求
|
|
commandParams.Range = range;
|
|
}
|
|
|
|
const command = new GetObjectCommand(commandParams);
|
|
const response = await s3Client.send(command);
|
|
|
|
// 设置响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
// 设置Content-Length和Content-Range头
|
|
if (response.ContentLength) {
|
|
headers.set('Content-Length', response.ContentLength.toString());
|
|
}
|
|
|
|
if (response.ContentRange) {
|
|
headers.set('Content-Range', response.ContentRange);
|
|
}
|
|
|
|
// 处理HEAD请求
|
|
if (request.method === 'HEAD') {
|
|
return handleHeadRequest(headers);
|
|
}
|
|
|
|
// 返回响应,支持流式传输
|
|
const statusCode = range ? 206 : 200; // Range请求返回206 Partial Content
|
|
return new Response(response.Body, {
|
|
status: statusCode,
|
|
headers
|
|
});
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch from S3 - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
|
|
// 处理 Discord 文件读取
|
|
async function handleDiscordFile(context, metadata, encodedFileName, fileType) {
|
|
const { env, request, url, Referer } = context;
|
|
|
|
try {
|
|
const db = getDatabase(env);
|
|
const discordCredentials = await resolveDiscordCredentials(db, env, metadata);
|
|
// 每次读取都通过 API 获取新的附件 URL(因为 Discord 附件 URL 会在约24小时后过期)
|
|
let fileUrl = null;
|
|
if (discordCredentials.messageId && discordCredentials.channelId && discordCredentials.botToken) {
|
|
const discordAPI = new DiscordAPI(discordCredentials.botToken);
|
|
fileUrl = await discordAPI.getFileURL(discordCredentials.channelId, discordCredentials.messageId);
|
|
}
|
|
|
|
if (!fileUrl) {
|
|
return new Response('Error: Discord file URL not found', { status: 500 });
|
|
}
|
|
|
|
// 如果配置了代理 URL,替换 Discord CDN 域名
|
|
if (discordCredentials.proxyUrl) {
|
|
fileUrl = fileUrl.replace('https://cdn.discordapp.com', `https://${discordCredentials.proxyUrl}`);
|
|
}
|
|
|
|
// 处理 HEAD 请求
|
|
if (request.method === 'HEAD') {
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
return handleHeadRequest(headers);
|
|
}
|
|
|
|
// 获取文件内容(支持 Range 请求)
|
|
const fetchHeaders = {};
|
|
const range = request.headers.get('Range');
|
|
if (range) {
|
|
fetchHeaders['Range'] = range;
|
|
}
|
|
|
|
const response = await fetch(fileUrl, {
|
|
method: 'GET',
|
|
headers: fetchHeaders
|
|
});
|
|
|
|
if (!response.ok && response.status !== 206) {
|
|
return new Response(`Error: Failed to fetch from Discord - ${response.status}`, { status: response.status });
|
|
}
|
|
|
|
// 构建响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
// 复制相关头部
|
|
if (response.headers.get('Content-Length')) {
|
|
headers.set('Content-Length', response.headers.get('Content-Length'));
|
|
}
|
|
if (response.headers.get('Content-Range')) {
|
|
headers.set('Content-Range', response.headers.get('Content-Range'));
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers
|
|
});
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch from Discord - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
|
|
// 处理 HuggingFace 文件读取
|
|
async function handleHuggingFaceFile(context, metadata, encodedFileName, fileType) {
|
|
const { env, request, url, Referer } = context;
|
|
|
|
try {
|
|
const db = getDatabase(env);
|
|
const hfCredentials = await resolveHuggingFaceCredentials(db, env, metadata);
|
|
const hfRepo = hfCredentials.repo;
|
|
const hfFilePath = hfCredentials.filePath;
|
|
const hfToken = hfCredentials.token;
|
|
const hfIsPrivate = hfCredentials.isPrivate || false;
|
|
|
|
if (!hfRepo || !hfFilePath) {
|
|
return new Response('Error: HuggingFace file info not found', { status: 500 });
|
|
}
|
|
|
|
// 构建文件 URL
|
|
const fileUrl = `https://huggingface.co/datasets/${hfRepo}/resolve/main/${hfFilePath}`;
|
|
const fileSize = HuggingFaceAPI.getMetadataFileSize(metadata);
|
|
|
|
// 处理 HEAD 请求
|
|
if (request.method === 'HEAD') {
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
if (fileSize) {
|
|
headers.set('Content-Length', fileSize.toString());
|
|
}
|
|
return handleHeadRequest(headers);
|
|
}
|
|
|
|
// 构建请求头
|
|
const fetchHeaders = {};
|
|
|
|
// 私有仓库需要 Authorization
|
|
if (hfIsPrivate && hfToken) {
|
|
fetchHeaders['Authorization'] = `Bearer ${hfToken}`;
|
|
}
|
|
|
|
// 支持 Range 请求
|
|
const range = request.headers.get('Range');
|
|
if (range) {
|
|
fetchHeaders['Range'] = range;
|
|
}
|
|
|
|
const response = await fetch(fileUrl, {
|
|
method: 'GET',
|
|
headers: fetchHeaders
|
|
});
|
|
|
|
if (!response.ok && response.status !== 206) {
|
|
return new Response(`Error: Failed to fetch from HuggingFace - ${response.status}`, { status: response.status });
|
|
}
|
|
|
|
// 构建响应头
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
// 复制相关头部
|
|
if (response.headers.get('Content-Length')) {
|
|
headers.set('Content-Length', response.headers.get('Content-Length'));
|
|
}
|
|
if (response.headers.get('Content-Range')) {
|
|
headers.set('Content-Range', response.headers.get('Content-Range'));
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers
|
|
});
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch from HuggingFace - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
|
|
// 处理 WebDAV 文件读取
|
|
async function handleWebDAVFile(context, metadata, encodedFileName, fileType) {
|
|
const { request, url, Referer } = context;
|
|
|
|
try {
|
|
const db = getDatabase(context.env);
|
|
const webdavCredentials = await resolveWebDAVCredentials(db, context.env, metadata);
|
|
const filePath = webdavCredentials.filePath;
|
|
const publicUrl = getWebDAVPublicFileUrl(webdavCredentials, filePath);
|
|
|
|
if (!filePath && !publicUrl) {
|
|
return new Response('Error: WebDAV file info not found', { status: 500 });
|
|
}
|
|
|
|
const headers = new Headers();
|
|
setCommonHeaders(headers, encodedFileName, fileType, getFileCacheControl(context));
|
|
|
|
const fetchHeaders = {};
|
|
const range = request.headers.get('Range');
|
|
if (range) {
|
|
fetchHeaders['Range'] = range;
|
|
}
|
|
|
|
let response;
|
|
if (publicUrl) {
|
|
response = await fetch(publicUrl, {
|
|
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
|
|
headers: fetchHeaders,
|
|
});
|
|
|
|
if (!response.ok && response.status !== 206 && response.status !== 304 && webdavCredentials.baseUrl && filePath) {
|
|
try {
|
|
const webdavAPI = new WebDAVAPI(webdavCredentials);
|
|
response = await webdavAPI.getFile(filePath, {
|
|
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
|
|
headers: fetchHeaders,
|
|
});
|
|
} catch (fallbackError) {
|
|
console.warn('WebDAV public URL fallback failed:', fallbackError.message);
|
|
}
|
|
}
|
|
} else {
|
|
if (!webdavCredentials.baseUrl || !filePath) {
|
|
return new Response('Error: WebDAV channel config not found', { status: 500 });
|
|
}
|
|
|
|
const webdavAPI = new WebDAVAPI(webdavCredentials);
|
|
response = await webdavAPI.getFile(filePath, {
|
|
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
|
|
headers: fetchHeaders,
|
|
});
|
|
}
|
|
|
|
if (!response.ok && response.status !== 206 && response.status !== 304) {
|
|
return new Response(`Error: Failed to fetch from WebDAV - ${response.status}`, { status: response.status });
|
|
}
|
|
|
|
if (response.headers.get('Content-Length')) {
|
|
headers.set('Content-Length', response.headers.get('Content-Length'));
|
|
}
|
|
if (response.headers.get('Content-Range')) {
|
|
headers.set('Content-Range', response.headers.get('Content-Range'));
|
|
}
|
|
if (response.headers.get('ETag')) {
|
|
headers.set('ETag', response.headers.get('ETag'));
|
|
}
|
|
if (response.status === 304) {
|
|
return new Response(null, { status: 304, headers });
|
|
}
|
|
if (request.method === 'HEAD') {
|
|
return handleHeadRequest(headers, response.headers.get('ETag'));
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers
|
|
});
|
|
|
|
} catch (error) {
|
|
return new Response(`Error: Failed to fetch from WebDAV - ${error.message}`, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function getWebDAVPublicFileUrl(webdavCredentials, filePath) {
|
|
if (webdavCredentials.publicUrl && filePath) {
|
|
try {
|
|
return buildWebDAVUrl(webdavCredentials.publicUrl, filePath);
|
|
} catch (error) {
|
|
console.warn('Invalid WebDAV public URL config:', error.message);
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|