feat: add HuggingFace storage channel support

- Add HuggingFace API wrapper class (huggingfaceAPI.js)
- Support upload, download, delete operations via HuggingFace Hub API
- Support public repos (unlimited storage) and private repos (100GB limit)
- Private repos: server proxies requests with Authorization header
- Auto-create repo if not exists (with write token)
- Add HuggingFace to auto-retry channel list
- Environment variables: HF_TOKEN, HF_REPO, HF_PRIVATE
- Support load balancing for multiple HuggingFace channels
This commit is contained in:
axibayuit
2025-12-30 17:46:11 +08:00
parent 5393e04631
commit 7ef6fd48ac
6 changed files with 474 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ import { purgeCFCache } from "../../../utils/purgeCache";
import { removeFileFromIndex, batchRemoveFilesFromIndex } from "../../../utils/indexManager.js";
import { getDatabase } from '../../../utils/databaseAdapter.js';
import { DiscordAPI } from '../../../utils/discordAPI.js';
import { HuggingFaceAPI } from '../../../utils/huggingfaceAPI.js';
// CORS 跨域响应头
const corsHeaders = {
@@ -148,6 +149,11 @@ async function deleteFile(env, fileId, cdnUrl, url) {
await deleteDiscordFile(img);
}
// HuggingFace 渠道的图片,需要删除 HuggingFace 中对应的文件
if (img.metadata?.Channel === 'HuggingFace') {
await deleteHuggingFaceFile(img);
}
// 删除数据库中的记录
// 注意:容量统计现在由索引自动维护,删除文件后索引更新时会自动重新计算
await db.delete(fileId);
@@ -224,4 +230,30 @@ async function deleteDiscordFile(img) {
console.error("Discord Delete Failed:", error);
return false;
}
}
}
// 删除 HuggingFace 渠道的图片
async function deleteHuggingFaceFile(img) {
const token = img.metadata?.HfToken;
const repo = img.metadata?.HfRepo;
const filePath = img.metadata?.HfFilePath;
const isPrivate = img.metadata?.HfIsPrivate || false;
if (!token || !repo || !filePath) {
console.warn('HuggingFace file missing required metadata for deletion');
return false;
}
try {
const huggingfaceAPI = new HuggingFaceAPI(token, repo, isPrivate);
const success = await huggingfaceAPI.deleteFile(filePath, `Delete ${filePath}`);
if (!success) {
console.error('HuggingFace Delete Failed: API returned false');
}
return success;
} catch (error) {
console.error("HuggingFace Delete Failed:", error);
return false;
}
}

View File

@@ -217,10 +217,54 @@ export async function getUploadConfig(db, env) {
discord.loadBalance = discordLoadBalance
// =====================读取 HuggingFace 渠道配置=====================
const huggingface = {}
const huggingfaceChannels = []
huggingface.channels = huggingfaceChannels
// 从环境变量读取 HuggingFace 配置
if (env.HF_TOKEN) {
huggingfaceChannels.push({
id: 1,
name: 'HuggingFace_env',
type: 'huggingface',
savePath: 'environment variable',
token: env.HF_TOKEN,
repo: env.HF_REPO,
isPrivate: env.HF_PRIVATE === 'true',
enabled: true,
fixed: true,
})
}
for (const hf of settingsKV.huggingface?.channels || []) {
// 如果 savePath 是 environment variable修改可变参数
if (hf.savePath === 'environment variable') {
// 如果环境变量未删除,进行覆盖操作
if (huggingfaceChannels[0]) {
huggingfaceChannels[0].enabled = hf.enabled
huggingfaceChannels[0].isPrivate = hf.isPrivate
}
continue
}
// id 自增
hf.id = huggingfaceChannels.length + 1
huggingfaceChannels.push(hf)
}
// 负载均衡
const huggingfaceLoadBalance = settingsKV.huggingface?.loadBalance || {
enabled: false,
channels: [],
}
huggingface.loadBalance = huggingfaceLoadBalance
settings.telegram = telegram
settings.cfr2 = cfr2
settings.s3 = s3
settings.discord = discord
settings.huggingface = huggingface
return settings;
}

View File

@@ -2,6 +2,7 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { fetchSecurityConfig } from "../utils/sysConfig";
import { TelegramAPI } from "../utils/telegramAPI";
import { DiscordAPI } from "../utils/discordAPI";
import { HuggingFaceAPI } from "../utils/huggingfaceAPI";
import { setCommonHeaders, setRangeHeaders, handleHeadRequest, getFileContent, isTgChannel,
returnWithCheck, return404, isDomainAllowed } from './fileTools';
import { getDatabase } from '../utils/databaseAdapter.js';
@@ -78,6 +79,11 @@ export async function onRequest(context) { // Contents of context object
return await handleDiscordFile(context, imgRecord.metadata, encodedFileName, fileType);
}
/* HuggingFace 渠道 */
if (imgRecord.metadata?.Channel === 'HuggingFace') {
return await handleHuggingFaceFile(context, imgRecord.metadata, encodedFileName, fileType);
}
/* 外链渠道 */
if (imgRecord.metadata?.Channel === 'External') {
// 直接重定向到外链
@@ -539,3 +545,73 @@ async function handleDiscordFile(context, metadata, encodedFileName, fileType) {
return new Response(`Error: Failed to fetch from Discord - ${error.message}`, { status: 500 });
}
}
// 处理 HuggingFace 文件读取
async function handleHuggingFaceFile(context, metadata, encodedFileName, fileType) {
const { request, url, Referer } = context;
try {
const hfRepo = metadata.HfRepo;
const hfFilePath = metadata.HfFilePath;
const hfToken = metadata.HfToken;
const hfIsPrivate = metadata.HfIsPrivate || false;
if (!hfRepo || !hfFilePath) {
return new Response('Error: HuggingFace file info not found', { status: 500 });
}
// 构建文件 URL
const fileUrl = metadata.HfFileUrl || `https://huggingface.co/datasets/${hfRepo}/resolve/main/${hfFilePath}`;
// 处理 HEAD 请求
if (request.method === 'HEAD') {
const headers = new Headers();
setCommonHeaders(headers, encodedFileName, fileType, Referer, url);
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, Referer, url);
// 复制相关头部
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 });
}
}

View File

@@ -8,6 +8,7 @@ import { initializeChunkedUpload, handleChunkUpload, uploadLargeFileToTelegram,
import { handleChunkMerge } from "./chunkMerge";
import { TelegramAPI } from "../utils/telegramAPI";
import { DiscordAPI } from "../utils/discordAPI";
import { HuggingFaceAPI } from "../utils/huggingfaceAPI";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getDatabase } from '../utils/databaseAdapter.js';
@@ -105,6 +106,9 @@ async function processFileUpload(context, formdata = null) {
case 'discord':
uploadChannel = 'Discord';
break;
case 'huggingface':
uploadChannel = 'HuggingFace';
break;
case 'external':
uploadChannel = 'External';
break;
@@ -200,6 +204,14 @@ async function processFileUpload(context, formdata = null) {
} else {
err = await res.text();
}
} else if (uploadChannel === 'HuggingFace') {
// ---------------------HuggingFace 渠道------------------
const res = await uploadFileToHuggingFace(context, fullId, metadata, returnLink);
if (res.status === 200 || !autoRetry) {
return res;
} else {
err = await res.text();
}
} else if (uploadChannel === 'External') {
// --------------------外链渠道----------------------
const res = await uploadFileToExternal(context, fullId, metadata, returnLink);
@@ -633,12 +645,96 @@ async function uploadFileToDiscord(context, fullId, metadata, returnLink) {
}
// 上传到 HuggingFace
async function uploadFileToHuggingFace(context, fullId, metadata, returnLink) {
const { env, waitUntil, uploadConfig, formdata } = context;
const db = getDatabase(env);
// 获取 HuggingFace 渠道配置
const hfSettings = uploadConfig.huggingface;
if (!hfSettings || !hfSettings.channels || hfSettings.channels.length === 0) {
return createResponse('Error: No HuggingFace channel configured', { status: 400 });
}
// 选择渠道(支持负载均衡)
const hfChannels = hfSettings.channels;
const hfChannel = hfSettings.loadBalance?.enabled
? hfChannels[Math.floor(Math.random() * hfChannels.length)]
: hfChannels[0];
if (!hfChannel || !hfChannel.token || !hfChannel.repo) {
return createResponse('Error: HuggingFace channel not properly configured', { status: 400 });
}
const file = formdata.get('file');
const fileName = metadata.FileName;
// 构建文件路径images/年月/文件名
const now = new Date();
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
const hfFilePath = `images/${yearMonth}/${fullId}`;
const huggingfaceAPI = new HuggingFaceAPI(hfChannel.token, hfChannel.repo, hfChannel.isPrivate || false);
try {
// 上传文件到 HuggingFace
const result = await huggingfaceAPI.uploadFile(file, hfFilePath, `Upload ${fileName}`);
if (!result.success) {
throw new Error('Failed to upload file to HuggingFace');
}
// 更新 metadata
metadata.Channel = "HuggingFace";
metadata.ChannelName = hfChannel.name || "HuggingFace_env";
metadata.FileSize = (file.size / 1024 / 1024).toFixed(2);
metadata.HfRepo = hfChannel.repo;
metadata.HfFilePath = hfFilePath;
metadata.HfToken = hfChannel.token;
metadata.HfIsPrivate = hfChannel.isPrivate || false;
metadata.HfFileUrl = result.fileUrl;
// 图像审查(公开仓库直接访问,私有仓库需要代理)
let moderateUrl = result.fileUrl;
if (!hfChannel.isPrivate) {
metadata.Label = await moderateContent(env, moderateUrl);
} else {
// 私有仓库暂不支持图像审查,标记为 None
metadata.Label = "None";
}
// 写入 KV 数据库
try {
await db.put(fullId, "", { metadata });
} catch (error) {
return createResponse('Error: Failed to write to KV database', { status: 500 });
}
// 结束上传
waitUntil(endUpload(context, fullId, metadata));
// 返回成功响应
return createResponse(
JSON.stringify([{ 'src': returnLink }]),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('HuggingFace upload error:', error.message);
return createResponse(`Error: HuggingFace upload failed - ${error.message}`, { status: 500 });
}
}
// 自动切换渠道重试
async function tryRetry(err, context, uploadChannel, fullId, metadata, fileExt, fileName, fileType, returnLink) {
const { env, url, formdata } = context;
// 渠道列表Discord 因为有 10MB 限制,放在最后尝试)
const channelList = ['CloudflareR2', 'TelegramNew', 'S3', 'Discord'];
const channelList = ['CloudflareR2', 'TelegramNew', 'S3', 'HuggingFace', 'Discord'];
const errMessages = {};
errMessages[uploadChannel] = 'Error: ' + uploadChannel + err;
@@ -651,6 +747,8 @@ async function tryRetry(err, context, uploadChannel, fullId, metadata, fileExt,
res = await uploadFileToTelegram(context, fullId, metadata, fileExt, fileName, fileType, returnLink);
} else if (channelList[i] === 'S3') {
res = await uploadFileToS3(context, fullId, metadata, returnLink);
} else if (channelList[i] === 'HuggingFace') {
res = await uploadFileToHuggingFace(context, fullId, metadata, returnLink);
} else if (channelList[i] === 'Discord') {
res = await uploadFileToDiscord(context, fullId, metadata, returnLink);
}

View File

@@ -0,0 +1,219 @@
/**
* Hugging Face Hub API 封装类
* 用于上传文件到 Hugging Face 仓库并获取文件
*/
export class HuggingFaceAPI {
constructor(token, repo, isPrivate = false) {
this.token = token;
this.repo = repo; // 格式: username/repo-name
this.isPrivate = isPrivate;
this.baseURL = 'https://huggingface.co';
this.apiURL = 'https://huggingface.co/api';
this.defaultHeaders = {
'Authorization': `Bearer ${this.token}`,
};
}
/**
* 检查仓库是否存在
* @returns {Promise<boolean>}
*/
async repoExists() {
try {
const response = await fetch(`${this.apiURL}/datasets/${this.repo}`, {
method: 'GET',
headers: this.defaultHeaders
});
return response.ok;
} catch (error) {
console.error('Error checking repo existence:', error.message);
return false;
}
}
/**
* 创建仓库(如果不存在)
* @returns {Promise<boolean>}
*/
async createRepoIfNotExists() {
try {
const exists = await this.repoExists();
if (exists) {
return true;
}
const response = await fetch(`${this.apiURL}/repos/create`, {
method: 'POST',
headers: {
...this.defaultHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: this.repo.split('/')[1], // 仓库名(不含用户名)
type: 'dataset',
private: this.isPrivate
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Failed to create repo: ${response.status} - ${errorData.error || response.statusText}`);
}
console.log(`Created HuggingFace repo: ${this.repo}`);
return true;
} catch (error) {
console.error('Error creating repo:', error.message);
return false;
}
}
/**
* 上传文件到仓库
* @param {File|Blob} file - 要上传的文件
* @param {string} filePath - 存储路径(如 images/xxx.jpg
* @param {string} commitMessage - 提交信息
* @returns {Promise<Object>} 上传结果
*/
async uploadFile(file, filePath, commitMessage = 'Upload file') {
try {
// 确保仓库存在
await this.createRepoIfNotExists();
// 将文件转换为 base64
const arrayBuffer = await file.arrayBuffer();
const base64Content = this.arrayBufferToBase64(arrayBuffer);
// 使用 commit API 上传文件
const response = await fetch(`${this.apiURL}/datasets/${this.repo}/commit/main`, {
method: 'POST',
headers: {
...this.defaultHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({
summary: commitMessage,
operations: [{
op: 'addOrUpdate',
path: filePath,
content: base64Content,
encoding: 'base64'
}]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`HuggingFace upload error: ${response.status} - ${errorData.error || response.statusText}`);
}
const result = await response.json();
// 构建文件 URL
const fileUrl = `${this.baseURL}/datasets/${this.repo}/resolve/main/${filePath}`;
return {
success: true,
commitId: result.commitId || result.oid,
filePath: filePath,
fileUrl: fileUrl,
fileSize: file.size
};
} catch (error) {
console.error('HuggingFace upload error:', error.message);
throw error;
}
}
/**
* 删除文件
* @param {string} filePath - 文件路径
* @param {string} commitMessage - 提交信息
* @returns {Promise<boolean>}
*/
async deleteFile(filePath, commitMessage = 'Delete file') {
try {
const response = await fetch(`${this.apiURL}/datasets/${this.repo}/commit/main`, {
method: 'POST',
headers: {
...this.defaultHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({
summary: commitMessage,
operations: [{
op: 'delete',
path: filePath
}]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('HuggingFace delete error:', response.status, errorData);
return false;
}
return true;
} catch (error) {
console.error('Error deleting file from HuggingFace:', error.message);
return false;
}
}
/**
* 获取文件内容(用于私有仓库)
* @param {string} filePath - 文件路径
* @returns {Promise<Response>}
*/
async getFileContent(filePath) {
const fileUrl = `${this.baseURL}/datasets/${this.repo}/resolve/main/${filePath}`;
const response = await fetch(fileUrl, {
headers: this.isPrivate ? this.defaultHeaders : {}
});
return response;
}
/**
* 获取文件的公开访问 URL
* @param {string} filePath - 文件路径
* @returns {string}
*/
getFileURL(filePath) {
return `${this.baseURL}/datasets/${this.repo}/resolve/main/${filePath}`;
}
/**
* 检查文件是否存在
* @param {string} filePath - 文件路径
* @returns {Promise<boolean>}
*/
async fileExists(filePath) {
try {
const fileUrl = this.getFileURL(filePath);
const response = await fetch(fileUrl, {
method: 'HEAD',
headers: this.isPrivate ? this.defaultHeaders : {}
});
return response.ok;
} catch (error) {
return false;
}
}
/**
* ArrayBuffer 转 Base64
* @param {ArrayBuffer} buffer
* @returns {string}
*/
arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
}

View File

@@ -62,6 +62,7 @@ export async function fetchUploadConfig(env, context = null) {
settings.cfr2.channels = settings.cfr2.channels.filter((channel) => channel.enabled);
settings.s3.channels = settings.s3.channels.filter((channel) => channel.enabled);
settings.discord.channels = settings.discord.channels.filter((channel) => channel.enabled);
settings.huggingface.channels = settings.huggingface.channels.filter((channel) => channel.enabled);
// 根据容量限制过滤渠道(仅 R2 和 S3
// 需要 context 来调用 getIndexMeta
@@ -78,7 +79,8 @@ export async function fetchUploadConfig(env, context = null) {
telegram: { channels: [] },
cfr2: { channels: [] },
s3: { channels: [] },
discord: { channels: [] }
discord: { channels: [] },
huggingface: { channels: [] }
};
}
}