mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-22 00:23:44 +08:00
Feat: Add tag management system
- Add tag CRUD APIs (single file and batch operations) - Add tag autocomplete endpoint - Add tag search support in file listing - Update database schema with tags column - Add tag validation and normalization utilities - Initialize Tags:[] for all new uploads
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { purgeCFCache } from "../../../utils/purgeCache.js";
|
||||
import { addFileToIndex } from "../../../utils/indexManager.js";
|
||||
import { getDatabase } from "../../../utils/databaseAdapter.js";
|
||||
import { mergeTags, normalizeTags, validateTag } from "../../../utils/tagHelpers.js";
|
||||
|
||||
/**
|
||||
* Tag Management API for Single Files
|
||||
*
|
||||
* GET /api/manage/tags/{fileId} - Get tags for a file
|
||||
* POST /api/manage/tags/{fileId} - Update tags for a file
|
||||
*
|
||||
* POST body format:
|
||||
* {
|
||||
* action: "set" | "add" | "remove",
|
||||
* tags: ["tag1", "tag2", ...]
|
||||
* }
|
||||
*/
|
||||
export async function onRequest(context) {
|
||||
const {
|
||||
request,
|
||||
env,
|
||||
params,
|
||||
waitUntil,
|
||||
} = context;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Parse file path
|
||||
if (params.path) {
|
||||
params.path = String(params.path).split(',').join('/');
|
||||
}
|
||||
|
||||
// Decode file path
|
||||
const fileId = decodeURIComponent(params.path);
|
||||
|
||||
const db = getDatabase(env);
|
||||
|
||||
try {
|
||||
if (request.method === 'GET') {
|
||||
// Get tags for file
|
||||
return await handleGetTags(db, fileId);
|
||||
} else if (request.method === 'POST') {
|
||||
// Update tags for file
|
||||
return await handleUpdateTags(context, db, fileId, url.hostname);
|
||||
} else {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Method not allowed',
|
||||
allowedMethods: ['GET', 'POST']
|
||||
}), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in tag management for ${fileId}:`, error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET request - Get tags for a file
|
||||
*/
|
||||
async function handleGetTags(db, fileId) {
|
||||
try {
|
||||
const fileData = await db.getWithMetadata(fileId);
|
||||
|
||||
if (!fileData || !fileData.metadata) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File not found',
|
||||
fileId: fileId
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const tags = fileData.metadata.Tags || [];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
fileId: fileId,
|
||||
tags: tags
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get tags: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST request - Update tags for a file
|
||||
*/
|
||||
async function handleUpdateTags(context, db, fileId, hostname) {
|
||||
const { request, waitUntil } = context;
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { action = 'set', tags = [] } = body;
|
||||
|
||||
// Validate action
|
||||
if (!['set', 'add', 'remove'].includes(action)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid action',
|
||||
message: 'Action must be one of: set, add, remove'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate tags array
|
||||
if (!Array.isArray(tags)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid tags format',
|
||||
message: 'Tags must be an array of strings'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate each tag
|
||||
const invalidTags = tags.filter(tag => !validateTag(tag));
|
||||
if (invalidTags.length > 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid tag format',
|
||||
message: 'Tags must contain only alphanumeric characters, underscores, hyphens, and CJK characters',
|
||||
invalidTags: invalidTags
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
const fileData = await db.getWithMetadata(fileId);
|
||||
|
||||
if (!fileData || !fileData.metadata) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File not found',
|
||||
fileId: fileId
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing tags
|
||||
const existingTags = fileData.metadata.Tags || [];
|
||||
|
||||
// Merge tags based on action
|
||||
const updatedTags = mergeTags(existingTags, tags, action);
|
||||
|
||||
// Update metadata
|
||||
fileData.metadata.Tags = updatedTags;
|
||||
|
||||
// Save to database
|
||||
await db.put(fileId, fileData.value, {
|
||||
metadata: fileData.metadata
|
||||
});
|
||||
|
||||
// Clear CDN cache
|
||||
const cdnUrl = `https://${hostname}/file/${fileId}`;
|
||||
await purgeCFCache(context.env, cdnUrl);
|
||||
|
||||
// Update file index asynchronously
|
||||
waitUntil(addFileToIndex(context, fileId, fileData.metadata));
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
fileId: fileId,
|
||||
action: action,
|
||||
tags: updatedTags,
|
||||
metadata: fileData.metadata
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update tags: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { getDatabase } from "../../../utils/databaseAdapter.js";
|
||||
import { extractUniqueTags, filterTagsByPrefix } from "../../../utils/tagHelpers.js";
|
||||
|
||||
/**
|
||||
* Tag Autocomplete API
|
||||
*
|
||||
* GET /api/manage/tags/autocomplete?prefix=ph - Get tag suggestions
|
||||
*
|
||||
* Returns all tags matching the given prefix, useful for autocomplete functionality
|
||||
*/
|
||||
export async function onRequest(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Method not allowed',
|
||||
allowedMethods: ['GET']
|
||||
}), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDatabase(env);
|
||||
|
||||
try {
|
||||
// Get prefix from query parameters
|
||||
const prefix = url.searchParams.get('prefix') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
|
||||
// Validate limit
|
||||
if (limit < 1 || limit > 100) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid limit',
|
||||
message: 'Limit must be between 1 and 100'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all files from database
|
||||
const allTags = new Set();
|
||||
let cursor = null;
|
||||
|
||||
while (true) {
|
||||
const response = await db.list({
|
||||
limit: 1000,
|
||||
cursor: cursor
|
||||
});
|
||||
|
||||
for (const item of response.keys) {
|
||||
// Skip non-file entries
|
||||
if (item.name.startsWith('manage@') || item.name.startsWith('chunk_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract tags from metadata
|
||||
if (item.metadata && Array.isArray(item.metadata.Tags)) {
|
||||
item.metadata.Tags.forEach(tag => {
|
||||
if (tag && typeof tag === 'string') {
|
||||
allTags.add(tag.toLowerCase().trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cursor = response.cursor;
|
||||
if (!cursor) break;
|
||||
|
||||
// Limit iterations for performance
|
||||
if (allTags.size > 10000) break;
|
||||
}
|
||||
|
||||
// Convert to array and sort
|
||||
const tagsArray = Array.from(allTags).sort();
|
||||
|
||||
// Filter by prefix
|
||||
const filteredTags = prefix
|
||||
? filterTagsByPrefix(tagsArray, prefix, limit)
|
||||
: tagsArray.slice(0, limit);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
prefix: prefix,
|
||||
tags: filteredTags,
|
||||
total: filteredTags.length,
|
||||
hasMore: tagsArray.length > filteredTags.length
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60' // Cache for 1 minute
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in tag autocomplete:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { purgeCFCache } from "../../../utils/purgeCache.js";
|
||||
import { batchAddFilesToIndex } from "../../../utils/indexManager.js";
|
||||
import { getDatabase } from "../../../utils/databaseAdapter.js";
|
||||
import { mergeTags, validateTag } from "../../../utils/tagHelpers.js";
|
||||
|
||||
/**
|
||||
* Batch Tag Management API
|
||||
*
|
||||
* POST /api/manage/tags/batch - Update tags for multiple files
|
||||
*
|
||||
* Request body format:
|
||||
* {
|
||||
* fileIds: ["file1", "file2", ...],
|
||||
* action: "set" | "add" | "remove",
|
||||
* tags: ["tag1", "tag2", ...]
|
||||
* }
|
||||
*/
|
||||
export async function onRequest(context) {
|
||||
const {
|
||||
request,
|
||||
env,
|
||||
waitUntil,
|
||||
} = context;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Method not allowed',
|
||||
allowedMethods: ['POST']
|
||||
}), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDatabase(env);
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { fileIds = [], action = 'set', tags = [] } = body;
|
||||
|
||||
// Validate fileIds
|
||||
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid fileIds',
|
||||
message: 'fileIds must be a non-empty array of file identifiers'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate action
|
||||
if (!['set', 'add', 'remove'].includes(action)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid action',
|
||||
message: 'Action must be one of: set, add, remove'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate tags array
|
||||
if (!Array.isArray(tags)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid tags format',
|
||||
message: 'Tags must be an array of strings'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validate each tag
|
||||
const invalidTags = tags.filter(tag => !validateTag(tag));
|
||||
if (invalidTags.length > 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid tag format',
|
||||
message: 'Tags must contain only alphanumeric characters, underscores, hyphens, and CJK characters',
|
||||
invalidTags: invalidTags
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Process files in batch
|
||||
const results = {
|
||||
success: true,
|
||||
total: fileIds.length,
|
||||
updated: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
const updatedFiles = [];
|
||||
|
||||
for (const fileId of fileIds) {
|
||||
try {
|
||||
// Get file metadata
|
||||
const fileData = await db.getWithMetadata(fileId);
|
||||
|
||||
if (!fileData || !fileData.metadata) {
|
||||
results.errors.push({
|
||||
fileId: fileId,
|
||||
error: 'File not found'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get existing tags
|
||||
const existingTags = fileData.metadata.Tags || [];
|
||||
|
||||
// Merge tags based on action
|
||||
const updatedTags = mergeTags(existingTags, tags, action);
|
||||
|
||||
// Update metadata
|
||||
fileData.metadata.Tags = updatedTags;
|
||||
|
||||
// Save to database
|
||||
await db.put(fileId, fileData.value, {
|
||||
metadata: fileData.metadata
|
||||
});
|
||||
|
||||
// Clear CDN cache (async)
|
||||
const cdnUrl = `https://${url.hostname}/file/${fileId}`;
|
||||
waitUntil(purgeCFCache(env, cdnUrl));
|
||||
|
||||
// Track updated file for batch index update
|
||||
updatedFiles.push({
|
||||
fileId: fileId,
|
||||
metadata: fileData.metadata
|
||||
});
|
||||
|
||||
results.updated++;
|
||||
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
fileId: fileId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch update file index asynchronously
|
||||
if (updatedFiles.length > 0) {
|
||||
waitUntil(batchAddFilesToIndex(context, updatedFiles, { skipExisting: false }));
|
||||
}
|
||||
|
||||
// Set success to false if there were any errors
|
||||
if (results.errors.length > 0) {
|
||||
results.success = false;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(results), {
|
||||
status: results.success ? 200 : 207, // 207 = Multi-Status (partial success)
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in batch tag update:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user