feat: add custom SQL cleanup for scheduled maintenance (#781)

- Add CustomSqlCleanup type to models
- Add validateCustomSql and executeCustomSqlCleanup functions
- Add SQL validation: DELETE only, single statement, max 1000 chars
- Integrate custom SQL cleanup with scheduled job
- Add frontend UI with tabs for basic/custom SQL cleanup
- Support i18n for English and Chinese

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2025-12-11 23:33:33 +08:00
committed by GitHub
parent a25199eb34
commit d15a4904a5
4 changed files with 277 additions and 92 deletions

View File

@@ -3,7 +3,78 @@ import { Context } from 'hono';
import { cleanup } from '../common';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models';
import { CleanupSettings, CustomSqlCleanup } from '../models';
// Normalize SQL: trim and remove trailing semicolon
const normalizeSql = (sql: string): string => {
let normalized = sql.trim();
if (normalized.endsWith(';')) {
normalized = normalized.slice(0, -1).trim();
}
return normalized;
};
// Validate custom SQL cleanup statement
export const validateCustomSql = (sql: string): { valid: boolean; error?: string } => {
if (!sql || !sql.trim()) {
return { valid: false, error: "SQL statement is empty" };
}
const trimmedSql = normalizeSql(sql);
// Check SQL length (max 1000 characters)
if (trimmedSql.length > 1000) {
return { valid: false, error: "SQL statement is too long (max 1000 characters)" };
}
const sqlUpper = trimmedSql.toUpperCase();
// Only allow DELETE statements
if (!sqlUpper.startsWith('DELETE ')) {
return { valid: false, error: "Only DELETE statements are allowed" };
}
// Only allow single statement (no semicolons after trimming)
if (trimmedSql.includes(';')) {
return { valid: false, error: "Only single SQL statement is allowed" };
}
// Forbid SQL comments
if (/--/.test(trimmedSql) || /\/\*/.test(trimmedSql)) {
return { valid: false, error: "SQL comments are not allowed" };
}
return { valid: true };
};
// Execute custom SQL cleanup
export const executeCustomSqlCleanup = async (
c: Context<HonoCustomType>,
customSql: CustomSqlCleanup
): Promise<{ success: boolean; rowsAffected?: number; error?: string }> => {
if (!customSql || !customSql.sql) {
return { success: false, error: "Invalid custom SQL cleanup config" };
}
const validation = validateCustomSql(customSql.sql);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const sql = normalizeSql(customSql.sql);
try {
console.log(`Executing custom SQL cleanup [${customSql.name}]: ${sql}`);
const result = await c.env.DB.prepare(sql).run();
const rowsAffected = result.meta?.changes ?? 0;
console.log(`Custom SQL cleanup [${customSql.name}] completed, rows affected: ${rowsAffected}`);
return { success: true, rowsAffected };
} catch (error) {
const errorMessage = (error as Error).message || "Unknown error";
console.error(`Custom SQL cleanup [${customSql.name}] failed:`, errorMessage);
return { success: false, error: errorMessage };
}
};
export default {
cleanup: async (c: Context<HonoCustomType>) => {
@@ -22,6 +93,19 @@ export default {
},
saveCleanup: async (c: Context<HonoCustomType>) => {
const cleanupSetting = await c.req.json<CleanupSettings>();
// Validate custom SQL cleanup list
if (cleanupSetting.customSqlCleanupList && cleanupSetting.customSqlCleanupList.length > 0) {
for (const customSql of cleanupSetting.customSqlCleanupList) {
if (customSql.sql) {
const validation = validateCustomSql(customSql.sql);
if (!validation.valid) {
return c.text(`Invalid SQL [${customSql.name || 'unnamed'}]: ${validation.error}`, 400);
}
}
}
}
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
return c.json({ success: true })
}

View File

@@ -34,6 +34,13 @@ export type WebhookMail = {
parsedHtml: string;
}
export type CustomSqlCleanup = {
id: string; // Unique identifier
name: string; // Cleanup task name
sql: string; // Custom SQL statement (DELETE only)
enabled: boolean; // Whether to enable auto cleanup
}
export type CleanupSettings = {
enableMailsAutoCleanup: boolean | undefined;
@@ -50,6 +57,7 @@ export type CleanupSettings = {
cleanUnboundAddressDays: number;
enableEmptyAddressAutoCleanup: boolean | undefined;
cleanEmptyAddressDays: number;
customSqlCleanupList: CustomSqlCleanup[] | undefined;
}
export class GeoData {

View File

@@ -3,6 +3,7 @@ import { cleanup } from './common'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CleanupSettings } from './models';
import { executeCustomSqlCleanup } from './admin_api/cleanup_api';
export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any) {
console.log("Scheduled event: ", event);
@@ -64,4 +65,18 @@ export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any)
autoCleanupSetting.cleanEmptyAddressDays
);
}
// Execute custom SQL cleanup tasks
if (autoCleanupSetting.customSqlCleanupList && autoCleanupSetting.customSqlCleanupList.length > 0) {
for (const customSql of autoCleanupSetting.customSqlCleanupList) {
if (customSql.enabled && customSql.sql) {
const result = await executeCustomSqlCleanup(
{ env: env, } as Context<HonoCustomType>,
customSql
);
if (!result.success) {
console.error(`Custom SQL cleanup [${customSql.name}] failed: ${result.error}`);
}
}
}
}
}