feat(admin): 添加 S3 兼容存储备份功能
- 新增 S3 配置管理页面,支持 AWS S3、Cloudflare R2、MinIO 等兼容存储 - 实现 S3 客户端工具类,使用 aws4fetch 进行签名和上传 - 添加手动触发备份功能,将评论、配置和统计数据打包为 JSON 上传至 S3 - 在管理后台数据管理页面集成 S3 配置表单和备份操作按钮 - 新增相关 API 端点用于获取/保存 S3 配置及触发备份
This commit is contained in:
@@ -388,3 +388,23 @@ export function setupTelegramWebhook(): Promise<{ message: string; webhookUrl: s
|
||||
export function sendTelegramTestMessage(): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/settings/telegram/test', {});
|
||||
}
|
||||
|
||||
export type S3SettingsResponse = {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
};
|
||||
|
||||
export function fetchS3Settings(): Promise<S3SettingsResponse> {
|
||||
return get<S3SettingsResponse>('/admin/settings/s3');
|
||||
}
|
||||
|
||||
export function saveS3Settings(data: S3SettingsResponse): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/s3', data);
|
||||
}
|
||||
|
||||
export function triggerS3Backup(): Promise<{ message: string; file: string }> {
|
||||
return post<{ message: string; file: string }>('/admin/backup/s3', {});
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@
|
||||
"desc": "一键备份或恢复系统所有数据(评论 + 配置 + 统计)。",
|
||||
"export": "全量导出",
|
||||
"import": "全量恢复"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 备份",
|
||||
"desc": "配置 S3 兼容存储(AWS S3, Cloudflare R2, MinIO 等),并支持手动触发备份。",
|
||||
"endpoint": "API 端点 (Endpoint)",
|
||||
"endpointHint": "例如: https://[account_id].r2.cloudflarestorage.com",
|
||||
"region": "区域 (Region)",
|
||||
"regionHint": "例如: auto, us-east-1",
|
||||
"bucket": "存储桶 (Bucket)",
|
||||
"accessKey": "Access Key ID",
|
||||
"secretKey": "Secret Access Key",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"backup": "立即备份到 S3",
|
||||
"backingUp": "正在备份...",
|
||||
"success": "备份成功!文件: {file}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
|
||||
@@ -109,6 +109,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. S3 备份 -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ t("data.sections.s3.title") }}</h3>
|
||||
<p class="card-desc">{{ t("data.sections.s3.desc") }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t("data.sections.s3.endpoint") }}</label>
|
||||
<input
|
||||
v-model="s3Config.endpoint"
|
||||
class="form-input"
|
||||
:placeholder="t('data.sections.s3.endpointHint')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label class="form-label">{{ t("data.sections.s3.bucket") }}</label>
|
||||
<input v-model="s3Config.bucket" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
<label class="form-label">{{ t("data.sections.s3.region") }}</label>
|
||||
<input
|
||||
v-model="s3Config.region"
|
||||
class="form-input"
|
||||
:placeholder="t('data.sections.s3.regionHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label class="form-label">{{ t("data.sections.s3.accessKey") }}</label>
|
||||
<input v-model="s3Config.accessKeyId" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group half">
|
||||
<label class="form-label">{{ t("data.sections.s3.secretKey") }}</label>
|
||||
<input v-model="s3Config.secretAccessKey" class="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row" style="margin-top: 16px">
|
||||
<button class="card-button primary" :disabled="s3Saving" @click="handleSaveS3">
|
||||
{{ s3Saving ? t("data.sections.s3.saving") : t("data.sections.s3.save") }}
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="s3BackingUp"
|
||||
@click="handleS3Backup"
|
||||
>
|
||||
{{
|
||||
s3BackingUp ? t("data.sections.s3.backingUp") : t("data.sections.s3.backup")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
type="file"
|
||||
@@ -161,7 +217,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
exportComments,
|
||||
@@ -172,6 +228,10 @@ import {
|
||||
importStats,
|
||||
exportBackup,
|
||||
importBackup,
|
||||
fetchS3Settings,
|
||||
saveS3Settings,
|
||||
triggerS3Backup,
|
||||
type S3SettingsResponse,
|
||||
} from "../../api/admin";
|
||||
import { useSite } from "../../composables/useSite";
|
||||
|
||||
@@ -195,6 +255,52 @@ const showPrefixModal = ref(false);
|
||||
const urlPrefix = ref("");
|
||||
const missingPrefixCount = ref(0);
|
||||
const pendingJson = ref<any[]>([]);
|
||||
const s3Config = ref<S3SettingsResponse>({
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
bucket: "",
|
||||
region: "auto",
|
||||
});
|
||||
const s3Saving = ref(false);
|
||||
const s3BackingUp = ref(false);
|
||||
|
||||
async function loadS3Config() {
|
||||
try {
|
||||
const res = await fetchS3Settings();
|
||||
s3Config.value = res;
|
||||
} catch (e) {
|
||||
// silent fail or log
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveS3() {
|
||||
s3Saving.value = true;
|
||||
try {
|
||||
await saveS3Settings(s3Config.value);
|
||||
showToast(t("common.saveSuccess"));
|
||||
} catch (e: any) {
|
||||
showToast(e.message || t("common.saveFailed"), "error");
|
||||
} finally {
|
||||
s3Saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleS3Backup() {
|
||||
s3BackingUp.value = true;
|
||||
try {
|
||||
const res = await triggerS3Backup();
|
||||
showToast(t("data.sections.s3.success", { file: res.file }));
|
||||
} catch (e: any) {
|
||||
showToast(e.message || t("data.messages.exportFailed"), "error");
|
||||
} finally {
|
||||
s3BackingUp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadS3Config();
|
||||
});
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = msg;
|
||||
@@ -424,4 +530,26 @@ function joinUrl(prefix: string, path: string): string {
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "../../styles/components/data.less";
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"wrangler": "^4.63.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"hono": "^4.11.3",
|
||||
"marked": "^17.0.1",
|
||||
"nodemailer": "^7.0.12",
|
||||
|
||||
38
cwd-api/src/api/admin/s3Settings.ts
Normal file
38
cwd-api/src/api/admin/s3Settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { loadS3Settings, saveS3Settings } from '../../utils/s3Settings';
|
||||
|
||||
export async function getS3Settings(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
const settings = await loadS3Settings(c.env);
|
||||
// Hide secret key for security in UI if needed, but usually user needs to edit it.
|
||||
// For now, return it as is, or maybe mask it partially?
|
||||
// User usually expects to see the inputs.
|
||||
return c.json(settings);
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '加载 S3 配置失败' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateS3Settings(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const endpoint = typeof body.endpoint === 'string' ? body.endpoint.trim() : '';
|
||||
const accessKeyId = typeof body.accessKeyId === 'string' ? body.accessKeyId.trim() : '';
|
||||
const secretAccessKey = typeof body.secretAccessKey === 'string' ? body.secretAccessKey.trim() : '';
|
||||
const bucket = typeof body.bucket === 'string' ? body.bucket.trim() : '';
|
||||
const region = typeof body.region === 'string' ? body.region.trim() : 'auto';
|
||||
|
||||
await saveS3Settings(c.env, {
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
region,
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功' });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '保存失败' }, 500);
|
||||
}
|
||||
}
|
||||
54
cwd-api/src/api/admin/triggerS3Backup.ts
Normal file
54
cwd-api/src/api/admin/triggerS3Backup.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
import { loadS3Settings } from '../../utils/s3Settings';
|
||||
import { S3Client } from '../../utils/s3';
|
||||
import { getConfigs } from './exportConfig';
|
||||
import { getStatsData } from './exportStats';
|
||||
|
||||
export async function triggerS3Backup(c: Context<{ Bindings: Bindings }>) {
|
||||
try {
|
||||
// 1. Load S3 Settings
|
||||
const settings = await loadS3Settings(c.env);
|
||||
if (!settings.endpoint || !settings.bucket || !settings.accessKeyId || !settings.secretAccessKey) {
|
||||
return c.json({ message: 'S3 配置不完整,请先配置 S3 信息' }, 400);
|
||||
}
|
||||
|
||||
// 2. Gather Backup Data (Logic from exportBackup.ts)
|
||||
const { results: comments } = await c.env.CWD_DB.prepare('SELECT * FROM Comment ORDER BY priority DESC, created DESC').all();
|
||||
const configs = await getConfigs(c.env);
|
||||
const stats = await getStatsData(c.env);
|
||||
|
||||
const backupData = {
|
||||
version: '1.0',
|
||||
timestamp: Date.now(),
|
||||
comments: comments,
|
||||
settings: configs,
|
||||
page_stats: stats.page_stats,
|
||||
page_visit_daily: stats.page_visit_daily,
|
||||
likes: stats.likes,
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(backupData, null, 2);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const fileName = `cwd-backup-${dateStr}-${Date.now()}.json`;
|
||||
|
||||
// 3. Upload to S3
|
||||
const s3 = new S3Client({
|
||||
endpoint: settings.endpoint,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
bucket: settings.bucket,
|
||||
region: settings.region,
|
||||
});
|
||||
|
||||
await s3.putObject(fileName, jsonString);
|
||||
|
||||
return c.json({
|
||||
message: '备份成功',
|
||||
file: fileName,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('S3 Backup Error:', e);
|
||||
return c.json({ message: e.message || 'S3 备份失败' }, 500);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import { listLikes } from './api/admin/listLikes';
|
||||
import { getLikeStats } from './api/admin/likeStats';
|
||||
import { getFeatureSettings, updateFeatureSettings } from './api/admin/featureSettings';
|
||||
import { getTelegramSettings, updateTelegramSettings, setupTelegramWebhook, testTelegramMessage } from './api/admin/telegramSettings';
|
||||
import { getS3Settings, updateS3Settings } from './api/admin/s3Settings';
|
||||
import { triggerS3Backup } from './api/admin/triggerS3Backup';
|
||||
import { telegramWebhook } from './api/telegram/webhook';
|
||||
import { ensureSchema } from './utils/dbMigration';
|
||||
|
||||
@@ -340,6 +342,10 @@ app.put('/admin/settings/telegram', updateTelegramSettings);
|
||||
app.post('/admin/settings/telegram/setup', setupTelegramWebhook);
|
||||
app.post('/admin/settings/telegram/test', testTelegramMessage);
|
||||
|
||||
app.get('/admin/settings/s3', getS3Settings);
|
||||
app.put('/admin/settings/s3', updateS3Settings);
|
||||
app.post('/admin/backup/s3', triggerS3Backup);
|
||||
|
||||
app.get('/admin/settings/admin-display', async (c) => {
|
||||
try {
|
||||
const settings = await loadAdminDisplaySettings(c.env);
|
||||
|
||||
52
cwd-api/src/utils/s3.ts
Normal file
52
cwd-api/src/utils/s3.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { AwsClient } from 'aws4fetch';
|
||||
|
||||
export interface S3Config {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export class S3Client {
|
||||
private client: AwsClient;
|
||||
private bucket: string;
|
||||
private endpoint: string;
|
||||
|
||||
constructor(config: S3Config) {
|
||||
this.client = new AwsClient({
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
region: config.region || 'auto',
|
||||
service: 's3',
|
||||
});
|
||||
this.bucket = config.bucket;
|
||||
// Ensure endpoint doesn't have trailing slash
|
||||
this.endpoint = config.endpoint.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async putObject(key: string, body: string | Uint8Array | Blob) {
|
||||
const url = `${this.endpoint}/${this.bucket}/${key}`;
|
||||
// Some S3 compatible storages need path style access
|
||||
// If endpoint is like https://s3.amazonaws.com, it might need virtual host style
|
||||
// But for broad compatibility (MinIO, R2, etc.), path style is often safer if endpoint is custom.
|
||||
// However, aws4fetch handles signing.
|
||||
|
||||
// Let's try to construct URL carefully.
|
||||
// If endpoint includes bucket in hostname, we shouldn't add it to path.
|
||||
// But assuming user provides a generic endpoint (e.g. https://<account>.r2.cloudflarestorage.com)
|
||||
// We will append bucket to path: https://<endpoint>/<bucket>/<key>
|
||||
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`S3 Upload Failed: ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
49
cwd-api/src/utils/s3Settings.ts
Normal file
49
cwd-api/src/utils/s3Settings.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Bindings } from '../bindings';
|
||||
|
||||
const S3_CONFIG_KEY = 's3_config';
|
||||
|
||||
export interface S3Settings {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export async function loadS3Settings(env: Bindings): Promise<S3Settings> {
|
||||
await env.CWD_DB.prepare('CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
const row = await env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
|
||||
.bind(S3_CONFIG_KEY)
|
||||
.first<{ value: string }>();
|
||||
|
||||
const defaults: S3Settings = {
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
bucket: '',
|
||||
region: 'auto',
|
||||
};
|
||||
|
||||
if (!row || !row.value) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(row.value);
|
||||
return {
|
||||
endpoint: parsed.endpoint || '',
|
||||
accessKeyId: parsed.accessKeyId || '',
|
||||
secretAccessKey: parsed.secretAccessKey || '',
|
||||
bucket: parsed.bucket || '',
|
||||
region: parsed.region || 'auto',
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveS3Settings(env: Bindings, settings: S3Settings) {
|
||||
await env.CWD_DB.prepare('CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
const value = JSON.stringify(settings);
|
||||
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)').bind(S3_CONFIG_KEY, value).run();
|
||||
}
|
||||
Reference in New Issue
Block a user