diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 0b2b026..d864be7 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -86,10 +86,6 @@ const SiteSettingsPage = lazy(async () => { const mod = await import('@/pages/site-settings-page') return { default: mod.SiteSettingsPage } }) -const AuditPage = lazy(async () => { - const mod = await import('@/pages/audit-page') - return { default: mod.AuditPage } -}) const SubscriptionsPage = lazy(async () => { const mod = await import('@/pages/subscriptions-page') return { default: mod.SubscriptionsPage } @@ -401,14 +397,6 @@ function AppRoutes() { } /> - - - - } - /> void + onSelect: (item: AdminMediaObjectResponse) => void +} + +const DEFAULT_PREFIX_OPTIONS = [ + 'all', + 'post-covers/', + 'review-covers/', + 'category-covers/', + 'tag-covers/', + 'site-assets/', + 'seo-assets/', + 'music-covers/', + 'friend-link-avatars/', + 'uploads/', +] as const + +function prefixLabel(value: string) { + switch (value) { + case 'all': + return '全部目录' + case 'post-covers/': + return '文章封面' + case 'review-covers/': + return '评测封面' + case 'category-covers/': + return '分类封面' + case 'tag-covers/': + return '标签封面' + case 'site-assets/': + return '站点资源' + case 'seo-assets/': + return 'SEO 图片' + case 'music-covers/': + return '音乐封面' + case 'friend-link-avatars/': + return '友链头像' + case 'uploads/': + return '通用上传' + default: + return value + } +} + +function isLikelyImage(item: AdminMediaObjectResponse) { + return /\.(png|jpe?g|webp|avif|gif|svg)$/i.test(item.key) +} + +export function MediaLibraryPickerDialog({ + open, + selectedUrl, + preferredPrefix, + onClose, + onSelect, +}: MediaLibraryPickerDialogProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [prefixFilter, setPrefixFilter] = useState(preferredPrefix ?? 'all') + const [searchTerm, setSearchTerm] = useState('') + + const prefixOptions = useMemo( + () => Array.from(new Set([preferredPrefix, ...DEFAULT_PREFIX_OPTIONS].filter(Boolean))) as string[], + [preferredPrefix], + ) + + useEffect(() => { + if (!open) { + return + } + + setPrefixFilter(preferredPrefix ?? 'all') + setSearchTerm('') + }, [open, preferredPrefix]) + + useEffect(() => { + if (!open) { + return + } + + let cancelled = false + + async function loadItems() { + try { + setLoading(true) + const result = await adminApi.listMediaObjects({ + prefix: prefixFilter === 'all' ? undefined : prefixFilter, + limit: 200, + }) + + if (!cancelled) { + setItems(result.items) + } + } catch (error) { + if (!cancelled) { + toast.error(error instanceof ApiError ? error.message : '加载媒体库失败。') + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void loadItems() + + return () => { + cancelled = true + } + }, [open, prefixFilter]) + + useEffect(() => { + if (!open) { + return + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onClose]) + + const filteredItems = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase() + if (!keyword) { + return items + } + + return items.filter((item) => + [ + item.key, + item.title ?? '', + item.alt_text ?? '', + item.caption ?? '', + ...(item.tags ?? []), + ] + .join('\n') + .toLowerCase() + .includes(keyword), + ) + }, [items, searchTerm]) + + if (!open) { + return null + } + + return ( +
{ + if (event.target === event.currentTarget) { + onClose() + } + }} + > +
+
+
+ 媒体库选择器 +
+

从已有媒体库选择图片

+

+ 可按目录和关键字筛选;点击“使用这张”后会把媒体库 URL 回填到当前表单。 +

+
+
+ + +
+ +
+ +
+ + setSearchTerm(event.target.value)} + placeholder="按 key / 标题 / alt / 标签搜索" + /> +
+
+ +
+ {loading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ) : filteredItems.length ? ( +
+ {filteredItems.map((item) => { + const isSelected = selectedUrl === item.url + + return ( +
+
+ {isLikelyImage(item) ? ( + {item.alt_text + ) : ( +
+ +
+ )} +
+ +
+
+

{item.title || item.key}

+

{item.key}

+ {item.tags.length ? ( +
+ {item.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ +
+ + {prefixLabel(item.key.split('/')[0] ? `${item.key.split('/')[0]}/` : 'uploads/')} + + +
+
+
+ ) + })} +
+ ) : ( +
+ +

当前筛选条件下没有可选媒体。

+
+ )} +
+
+
+ ) +} diff --git a/admin/src/components/media-url-controls.tsx b/admin/src/components/media-url-controls.tsx new file mode 100644 index 0000000..979e703 --- /dev/null +++ b/admin/src/components/media-url-controls.tsx @@ -0,0 +1,256 @@ +import { CheckSquare, Download, Images, Square, Upload } from 'lucide-react' +import { useRef, useState } from 'react' +import { toast } from 'sonner' + +import { MediaLibraryPickerDialog } from '@/components/media-library-picker-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select } from '@/components/ui/select' +import { adminApi, ApiError } from '@/lib/api' +import { + formatCompressionPreview, + prepareImageForUpload, + type MediaUploadTargetFormat, +} from '@/lib/image-compress' +import { cn } from '@/lib/utils' + +type RemoteTargetFormat = 'original' | 'webp' | 'avif' + +type MediaUrlControlsProps = { + value: string + onChange: (value: string) => void + prefix: string + contextLabel: string + mode?: 'image' | 'cover' + className?: string + remoteTitle?: string | null + accept?: string + dataTestIdPrefix?: string +} + +function formatLabelForUploadTarget(value: MediaUploadTargetFormat) { + switch (value) { + case 'avif': + return 'AVIF' + case 'webp': + return 'WebP' + default: + return '自动' + } +} + +export function MediaUrlControls({ + value, + onChange, + prefix, + contextLabel, + mode = 'image', + className, + remoteTitle, + accept = 'image/*', + dataTestIdPrefix, +}: MediaUrlControlsProps) { + const fileInputRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [downloadingRemote, setDownloadingRemote] = useState(false) + const [compressBeforeUpload, setCompressBeforeUpload] = useState(true) + const [compressQuality, setCompressQuality] = useState('0.82') + const [uploadTargetFormat, setUploadTargetFormat] = useState('avif') + const [remoteUrl, setRemoteUrl] = useState('') + const [remoteTargetFormat, setRemoteTargetFormat] = useState('original') + const [pickerOpen, setPickerOpen] = useState(false) + + const quality = Number.parseFloat(compressQuality) + const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82 + + return ( +
+
+
+ { + const file = event.target.files?.item(0) + event.currentTarget.value = '' + if (!file) { + return + } + + try { + setUploading(true) + const prepared = await prepareImageForUpload(file, { + compress: compressBeforeUpload, + quality: safeQuality, + targetFormat: uploadTargetFormat, + contextLabel: `${contextLabel}(${file.name})`, + mode, + }) + + if (prepared.preview) { + toast.message(formatCompressionPreview(prepared.preview)) + } + + const uploaded = await adminApi.uploadMediaObjects([prepared.file], { prefix }) + const url = uploaded.uploaded[0]?.url + if (!url) { + throw new Error('上传完成但没有返回 URL') + } + + if (compressBeforeUpload && uploadTargetFormat !== 'auto') { + const expectedMimeType = + uploadTargetFormat === 'avif' ? 'image/avif' : 'image/webp' + if (prepared.file.type !== expectedMimeType) { + toast.warning( + `当前环境无法直接导出 ${formatLabelForUploadTarget(uploadTargetFormat)},已回退为 ${prepared.file.type || '原格式'}。`, + ) + } + } + + onChange(url) + toast.success('已上传到媒体库,并回填 URL。') + } catch (error) { + toast.error(error instanceof ApiError ? error.message : '上传到媒体库失败。') + } finally { + setUploading(false) + } + }} + /> + + + + + + + + + + setCompressQuality(event.target.value)} + placeholder="0.82" + disabled={!compressBeforeUpload} + /> +
+ +
+ setRemoteUrl(event.target.value)} + placeholder="https://example.com/cover.webp" + data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-url` : undefined} + /> +
+
+ +
+ +
+
+ +

+ 上传 / 抓取完成后会自动回填当前 URL;也可以直接在上面的输入框里手填外链。 + {value.trim() ? ' 当前已有值,可继续覆盖。' : ''} +

+
+ + setPickerOpen(false)} + onSelect={(item) => { + onChange(item.url) + toast.success('已从媒体库选中并回填 URL。') + }} + /> +
+ ) +} diff --git a/admin/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx index 4ebd842..693c37f 100644 --- a/admin/src/components/ui/select.tsx +++ b/admin/src/components/ui/select.tsx @@ -139,12 +139,14 @@ const Select = React.forwardRef( const rect = trigger.getBoundingClientRect() const viewportPadding = 12 const gutter = 6 + const minMenuWidth = 220 const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320) const spaceBelow = window.innerHeight - rect.bottom - viewportPadding const spaceAbove = rect.top - viewportPadding const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320)) - const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2) + const maxAllowedWidth = window.innerWidth - viewportPadding * 2 + const width = Math.min(Math.max(rect.width, minMenuWidth), maxAllowedWidth) const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding) setMenuPlacement(openToTop ? 'top' : 'bottom') diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 9ae5336..5e7c8d1 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -13,6 +13,7 @@ import type { AdminMediaUploadResponse, AdminPostCoverImageRequest, AdminPostCoverImageResponse, + AdminPostLocalizeImagesResponse, AdminDashboardResponse, AdminPostMetadataResponse, AdminPostPolishResponse, @@ -453,11 +454,14 @@ export const adminApi = { body: JSON.stringify({ source_url: payload.sourceUrl, prefix: payload.prefix, + target_format: + payload.targetFormat && payload.targetFormat !== 'original' ? payload.targetFormat : null, title: payload.title, alt_text: payload.altText, caption: payload.caption, tags: payload.tags, notes: payload.notes, + sync: payload.sync ?? false, }), }), updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) => @@ -488,6 +492,14 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ markdown }), }), + localizePostMarkdownImages: (payload: { markdown: string; prefix?: string | null }) => + request('/api/admin/posts/localize-images', { + method: 'POST', + body: JSON.stringify({ + markdown: payload.markdown, + prefix: payload.prefix, + }), + }), polishReviewDescription: (payload: AdminReviewPolishRequest) => request('/api/admin/ai/polish-review', { method: 'POST', diff --git a/admin/src/lib/image-compress.ts b/admin/src/lib/image-compress.ts index 8cdb84c..78dff8a 100644 --- a/admin/src/lib/image-compress.ts +++ b/admin/src/lib/image-compress.ts @@ -11,6 +11,13 @@ export interface CompressionResult { preview: CompressionPreview | null } +export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp' + +interface ProcessedVariant { + file: File + preview: CompressionPreview +} + interface ProcessImageOptions { quality: number maxWidth: number @@ -83,6 +90,427 @@ function deriveFileName(file: File, mimeType: string) { return `processed${extension}` } +function buildPreview(originalSize: number, compressedSize: number): CompressionPreview { + const savedBytes = originalSize - compressedSize + const savedRatio = originalSize > 0 ? savedBytes / originalSize : 0 + + return { + originalSize, + compressedSize, + savedBytes, + savedRatio, + } +} + +function formatLabelForMimeType(mimeType: string) { + switch (mimeType) { + case 'image/avif': + return 'AVIF' + case 'image/webp': + return 'WebP' + case 'image/png': + return 'PNG' + default: + return 'JPEG' + } +} + +function defaultPreferredFormats(file: File, coverMode = false) { + if (coverMode) { + return ['image/avif', 'image/webp', 'image/jpeg'] + } + + if (file.type === 'image/png') { + return ['image/png', 'image/webp', 'image/jpeg'] + } + + return ['image/webp', 'image/avif', 'image/jpeg'] +} + +function preferredFormatsForTarget(file: File, targetFormat: MediaUploadTargetFormat, coverMode = false) { + switch (targetFormat) { + case 'avif': + return ['image/avif', 'image/webp', 'image/jpeg'] + case 'webp': + return ['image/webp', 'image/jpeg'] + default: + return defaultPreferredFormats(file, coverMode) + } +} + +async function buildProcessedVariants(file: File, options: ProcessImageOptions): Promise { + const variants: ProcessedVariant[] = [] + const requestedFormats = Array.from(new Set(options.preferredFormats)) + + for (const format of requestedFormats) { + const processed = await processImage(file, { + ...options, + preferredFormats: [format], + }) + + if (processed.type !== format) { + continue + } + + if (variants.some((item) => item.file.type === processed.type)) { + continue + } + + variants.push({ + file: processed, + preview: buildPreview(file.size, processed.size), + }) + } + + return variants +} + +function ensureImageChoiceDialogStyles() { + const styleId = 'termi-image-choice-dialog-style' + if (document.getElementById(styleId)) { + return + } + + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .termi-image-choice-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(6px); + } + + .termi-image-choice-dialog { + width: min(680px, 100%); + border-radius: 24px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(255, 255, 255, 0.96); + color: #0f172a; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24); + overflow: hidden; + } + + .termi-image-choice-header { + padding: 20px 22px 12px; + } + + .termi-image-choice-title { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.45; + } + + .termi-image-choice-description { + margin: 8px 0 0; + color: #475569; + font-size: 14px; + line-height: 1.7; + } + + .termi-image-choice-body { + padding: 0 22px 22px; + display: grid; + gap: 12px; + } + + .termi-image-choice-note { + border-radius: 16px; + border: 1px solid rgba(59, 130, 246, 0.18); + background: rgba(239, 246, 255, 0.92); + color: #1d4ed8; + padding: 12px 14px; + font-size: 13px; + line-height: 1.7; + } + + .termi-image-choice-option { + display: grid; + gap: 8px; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: #f8fafc; + padding: 14px 16px; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; + } + + .termi-image-choice-option:hover { + border-color: rgba(37, 99, 235, 0.35); + transform: translateY(-1px); + } + + .termi-image-choice-option.is-selected { + border-color: rgba(37, 99, 235, 0.52); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + background: rgba(239, 246, 255, 0.92); + } + + .termi-image-choice-option-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .termi-image-choice-option-label { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + line-height: 1.5; + } + + .termi-image-choice-option-label input { + margin: 0; + } + + .termi-image-choice-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .termi-image-choice-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + } + + .termi-image-choice-badge--recommended { + background: rgba(37, 99, 235, 0.1); + color: #2563eb; + } + + .termi-image-choice-badge--neutral { + background: rgba(148, 163, 184, 0.14); + color: #475569; + } + + .termi-image-choice-meta { + display: grid; + gap: 4px; + color: #475569; + font-size: 13px; + line-height: 1.65; + } + + .termi-image-choice-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 0 22px 22px; + } + + .termi-image-choice-button { + border: 0; + border-radius: 999px; + padding: 11px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.18s ease, opacity 0.18s ease; + } + + .termi-image-choice-button:hover { + transform: translateY(-1px); + } + + .termi-image-choice-button--ghost { + background: rgba(148, 163, 184, 0.18); + color: #334155; + } + + .termi-image-choice-button--primary { + background: linear-gradient(135deg, #2563eb, #4f46e5); + color: #fff; + box-shadow: 0 12px 32px rgba(37, 99, 235, 0.26); + } + ` + document.head.appendChild(style) +} + +async function showImageChoiceDialog(options: { + title: string + description: string + note?: string + choices: Array<{ + id: string + title: string + meta: string[] + badge?: string + recommended?: boolean + }> + defaultChoiceId: string + confirmLabel?: string + cancelLabel?: string +}) { + ensureImageChoiceDialogStyles() + + return new Promise((resolve) => { + const overlay = document.createElement('div') + overlay.className = 'termi-image-choice-overlay' + + const dialog = document.createElement('div') + dialog.className = 'termi-image-choice-dialog' + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('aria-modal', 'true') + dialog.setAttribute('aria-label', options.title) + + const header = document.createElement('div') + header.className = 'termi-image-choice-header' + header.innerHTML = ` +

+

+ ` + const titleEl = header.querySelector('.termi-image-choice-title') + const descriptionEl = header.querySelector('.termi-image-choice-description') + if (titleEl) titleEl.textContent = options.title + if (descriptionEl) descriptionEl.textContent = options.description + + const body = document.createElement('div') + body.className = 'termi-image-choice-body' + + if (options.note) { + const note = document.createElement('div') + note.className = 'termi-image-choice-note' + note.textContent = options.note + body.appendChild(note) + } + + let selectedChoiceId = options.defaultChoiceId + const optionElements: HTMLElement[] = [] + + for (const choice of options.choices) { + const option = document.createElement('label') + option.className = 'termi-image-choice-option' + option.dataset.choiceId = choice.id + + const top = document.createElement('div') + top.className = 'termi-image-choice-option-top' + + const label = document.createElement('div') + label.className = 'termi-image-choice-option-label' + + const input = document.createElement('input') + input.type = 'radio' + input.name = 'termi-image-choice' + input.value = choice.id + input.checked = choice.id === selectedChoiceId + + const text = document.createElement('span') + text.textContent = choice.title + + label.append(input, text) + + const badges = document.createElement('div') + badges.className = 'termi-image-choice-badges' + if (choice.recommended) { + const recommended = document.createElement('span') + recommended.className = 'termi-image-choice-badge termi-image-choice-badge--recommended' + recommended.textContent = '推荐' + badges.appendChild(recommended) + } + if (choice.badge) { + const badge = document.createElement('span') + badge.className = 'termi-image-choice-badge termi-image-choice-badge--neutral' + badge.textContent = choice.badge + badges.appendChild(badge) + } + + top.append(label, badges) + + const meta = document.createElement('div') + meta.className = 'termi-image-choice-meta' + for (const line of choice.meta) { + const item = document.createElement('div') + item.textContent = line + meta.appendChild(item) + } + + option.append(top, meta) + option.addEventListener('click', () => { + selectedChoiceId = choice.id + optionElements.forEach((element) => { + const checked = element.dataset.choiceId === selectedChoiceId + element.classList.toggle('is-selected', checked) + const radio = element.querySelector('input[type="radio"]') as HTMLInputElement | null + if (radio) { + radio.checked = checked + } + }) + }) + + option.classList.toggle('is-selected', choice.id === selectedChoiceId) + optionElements.push(option) + body.appendChild(option) + } + + const actions = document.createElement('div') + actions.className = 'termi-image-choice-actions' + + const cancelButton = document.createElement('button') + cancelButton.type = 'button' + cancelButton.className = 'termi-image-choice-button termi-image-choice-button--ghost' + cancelButton.textContent = options.cancelLabel ?? '保留原图' + + const confirmButton = document.createElement('button') + confirmButton.type = 'button' + confirmButton.className = 'termi-image-choice-button termi-image-choice-button--primary' + confirmButton.textContent = options.confirmLabel ?? '使用所选版本' + + const cleanup = () => { + overlay.remove() + document.removeEventListener('keydown', handleKeyDown) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cleanup() + resolve('original') + } + } + + cancelButton.addEventListener('click', () => { + cleanup() + resolve('original') + }) + + confirmButton.addEventListener('click', () => { + cleanup() + resolve(selectedChoiceId) + }) + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + cleanup() + resolve('original') + } + }) + + actions.append(cancelButton, confirmButton) + dialog.append(header, body, actions) + overlay.appendChild(dialog) + document.body.appendChild(overlay) + document.addEventListener('keydown', handleKeyDown) + + const defaultInput = overlay.querySelector( + `input[value="${CSS.escape(options.defaultChoiceId)}"]`, + ) as HTMLInputElement | null + defaultInput?.focus() + }) +} + async function processImage(file: File, options: ProcessImageOptions): Promise { if (!canTransformWithCanvas(file)) { return file @@ -161,33 +589,29 @@ async function maybeProcessImageWithPrompt( const contextLabel = options?.contextLabel ?? '图片上传' const forceProcessed = options?.forceProcessed ?? false + const processOptions: ProcessImageOptions = { + quality, + maxWidth: Math.max(options?.maxWidth ?? 2200, 320), + maxHeight: Math.max(options?.maxHeight ?? 2200, 320), + preferredFormats: + options?.preferredFormats && options.preferredFormats.length + ? options.preferredFormats + : file.type === 'image/png' + ? ['image/png', 'image/webp', 'image/jpeg'] + : ['image/webp', 'image/avif', 'image/jpeg'], + coverWidth: options?.coverWidth, + coverHeight: options?.coverHeight, + } + let processed: File try { - processed = await processImage(file, { - quality, - maxWidth: Math.max(options?.maxWidth ?? 2200, 320), - maxHeight: Math.max(options?.maxHeight ?? 2200, 320), - preferredFormats: - options?.preferredFormats && options.preferredFormats.length - ? options.preferredFormats - : file.type === 'image/png' - ? ['image/png', 'image/webp', 'image/jpeg'] - : ['image/webp', 'image/avif', 'image/jpeg'], - coverWidth: options?.coverWidth, - coverHeight: options?.coverHeight, - }) + processed = await processImage(file, processOptions) } catch { return { file, usedCompressed: false, preview: null } } - const savedBytes = file.size - processed.size - const savedRatio = file.size > 0 ? savedBytes / file.size : 0 - const preview: CompressionPreview = { - originalSize: file.size, - compressedSize: processed.size, - savedBytes, - savedRatio, - } + const preview = buildPreview(file.size, processed.size) + const { savedRatio } = preview if (!forceProcessed && processed.size >= file.size) { return { file, usedCompressed: false, preview } @@ -201,30 +625,80 @@ async function maybeProcessImageWithPrompt( return { file: processed, usedCompressed: true, preview } } - const deltaText = - savedBytes >= 0 - ? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)` - : `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)` + let variants: ProcessedVariant[] + try { + variants = await buildProcessedVariants(file, processOptions) + } catch { + variants = [ + { + file: processed, + preview, + }, + ] + } - const intro = forceProcessed - ? `${contextLabel}: 已生成规范化版本。` - : `${contextLabel}: 检测到可压缩空间。` + const selectableVariants = forceProcessed + ? variants + : variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio) - const useProcessed = window.confirm( - [ - intro, - `原始: ${formatBytes(file.size)}`, - `处理后: ${formatBytes(processed.size)}`, - deltaText, - '', - forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?', - ].join('\n'), + if (!selectableVariants.length) { + return { file, usedCompressed: false, preview } + } + + const recommendedVariant = selectableVariants[0] + const missingPreferredFormats = processOptions.preferredFormats.filter( + (format) => !variants.some((item) => item.file.type === format), ) + const note = + missingPreferredFormats.length > 0 + ? `当前环境未提供 ${missingPreferredFormats.map(formatLabelForMimeType).join(' / ')} 编码能力,因此这里只展示可实际生成的格式。` + : undefined + + const choice = await showImageChoiceDialog({ + title: forceProcessed ? `${contextLabel}:已生成规范化版本` : `${contextLabel}:检测到可压缩空间`, + description: forceProcessed + ? '可以直接保留原图,也可以选择更适合上传的规范化版本。' + : '可以直接保留原图,也可以选择体积更合适的版本再上传。', + note, + choices: [ + { + id: 'original', + title: `保留原图(${file.name})`, + meta: [ + `当前文件: ${formatBytes(file.size)}`, + `格式: ${formatLabelForMimeType(file.type || 'image/jpeg')}`, + ], + badge: '原图', + }, + ...selectableVariants.map((item, index) => { + const variantSavedBytes = item.preview.savedBytes + const variantSavedRatio = item.preview.savedRatio + return { + id: item.file.type, + title: `${formatLabelForMimeType(item.file.type)} 版本`, + meta: [ + `处理后: ${formatBytes(item.file.size)}`, + variantSavedBytes >= 0 + ? `节省: ${formatBytes(variantSavedBytes)} (${(variantSavedRatio * 100).toFixed(1)}%)` + : `体积增加: ${formatBytes(Math.abs(variantSavedBytes))} (${Math.abs(variantSavedRatio * 100).toFixed(1)}%)`, + ], + badge: item.file.name.replace(/^.*(\.[A-Za-z0-9]+)$/, '$1').toLowerCase(), + recommended: index === 0, + } + }), + ], + defaultChoiceId: recommendedVariant.file.type, + confirmLabel: '使用所选版本', + cancelLabel: '保留原图', + }) + + const selectedVariant = selectableVariants.find((item) => item.file.type === choice) + const useProcessed = Boolean(selectedVariant) return { - file: useProcessed ? processed : file, + file: selectedVariant?.file ?? file, usedCompressed: useProcessed, - preview, + preview: selectedVariant?.preview ?? preview, } } @@ -235,6 +709,7 @@ export async function maybeCompressImageWithPrompt( ask?: boolean minSavingsRatio?: number contextLabel?: string + preferredFormats?: string[] }, ): Promise { return maybeProcessImageWithPrompt(file, options) @@ -248,13 +723,14 @@ export async function normalizeCoverImageWithPrompt( contextLabel?: string width?: number height?: number + preferredFormats?: string[] }, ): Promise { return maybeProcessImageWithPrompt(file, { quality: options?.quality ?? 0.82, ask: options?.ask ?? true, contextLabel: options?.contextLabel ?? '封面图规范化', - preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'], + preferredFormats: options?.preferredFormats ?? ['image/avif', 'image/webp', 'image/jpeg'], coverWidth: Math.max(options?.width ?? 1600, 640), coverHeight: Math.max(options?.height ?? 900, 360), forceProcessed: true, @@ -262,6 +738,42 @@ export async function normalizeCoverImageWithPrompt( }) } +export async function prepareImageForUpload( + file: File, + options?: { + compress?: boolean + quality?: number + targetFormat?: MediaUploadTargetFormat + contextLabel?: string + mode?: 'image' | 'cover' + }, +): Promise { + const compress = options?.compress ?? true + if (!compress) { + return { file, usedCompressed: false, preview: null } + } + + const targetFormat = options?.targetFormat ?? 'auto' + const mode = options?.mode ?? 'image' + const preferredFormats = preferredFormatsForTarget(file, targetFormat, mode === 'cover') + + if (mode === 'cover') { + return normalizeCoverImageWithPrompt(file, { + quality: options?.quality ?? 0.82, + ask: false, + contextLabel: options?.contextLabel ?? '封面图规范化上传', + preferredFormats, + }) + } + + return maybeCompressImageWithPrompt(file, { + quality: options?.quality ?? 0.82, + ask: false, + contextLabel: options?.contextLabel ?? '媒体上传', + preferredFormats, + }) +} + export function formatCompressionPreview(preview: CompressionPreview | null) { if (!preview) { return '' diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 6d0abf8..397ab81 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -374,6 +374,9 @@ export interface AdminSiteSettingsResponse { location: string | null tech_stack: string[] music_playlist: MusicTrack[] + music_enabled: boolean + maintenance_mode_enabled: boolean + maintenance_access_code: string | null ai_enabled: boolean paragraph_comments_enabled: boolean comment_verification_mode: HumanVerificationMode @@ -451,6 +454,9 @@ export interface SiteSettingsPayload { location?: string | null techStack?: string[] musicPlaylist?: MusicTrack[] + musicEnabled?: boolean + maintenanceModeEnabled?: boolean + maintenanceAccessCode?: string | null aiEnabled?: boolean paragraphCommentsEnabled?: boolean commentVerificationMode?: HumanVerificationMode | null @@ -613,17 +619,23 @@ export interface AdminMediaReplaceResponse { export interface MediaDownloadPayload { sourceUrl: string prefix?: string | null + targetFormat?: 'original' | 'webp' | 'avif' | null title?: string | null altText?: string | null caption?: string | null tags?: string[] notes?: string | null + sync?: boolean } export interface AdminMediaDownloadResponse { queued: boolean - job_id: number - status: string + job_id: number | null + status: string | null + key: string | null + url: string | null + size_bytes: number | null + content_type: string | null } export interface MediaAssetMetadataPayload { @@ -754,6 +766,27 @@ export interface AdminPostPolishResponse { polished_markdown: string } +export interface AdminPostLocalizeImagesFailure { + source_url: string + error: string +} + +export interface AdminPostLocalizedImageItem { + source_url: string + localized_url: string + key: string +} + +export interface AdminPostLocalizeImagesResponse { + markdown: string + detected_count: number + localized_count: number + uploaded_count: number + failed_count: number + items: AdminPostLocalizedImageItem[] + failures: AdminPostLocalizeImagesFailure[] +} + export interface AdminReviewPolishRequest { title: string reviewType: string diff --git a/admin/src/pages/categories-page.tsx b/admin/src/pages/categories-page.tsx index 8fe10cc..230dcb3 100644 --- a/admin/src/pages/categories-page.tsx +++ b/admin/src/pages/categories-page.tsx @@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac import { toast } from 'sonner' import { FormField } from '@/components/form-field' +import { MediaUrlControls } from '@/components/media-url-controls' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -302,14 +303,26 @@ export function CategoriesPage() { placeholder="frontend-engineering" /> - - - setForm((current) => ({ ...current, coverImage: event.target.value })) - } - placeholder="https://cdn.example.com/covers/frontend.jpg" - /> + +
+ + setForm((current) => ({ ...current, coverImage: event.target.value })) + } + placeholder="https://cdn.example.com/covers/frontend.jpg" + /> + + setForm((current) => ({ ...current, coverImage })) + } + prefix="category-covers/" + contextLabel="分类封面上传" + remoteTitle={form.name || form.slug || '分类封面'} + dataTestIdPrefix="category-cover" + /> +
diff --git a/admin/src/pages/friend-links-page.tsx b/admin/src/pages/friend-links-page.tsx index f0ae866..f6f7f48 100644 --- a/admin/src/pages/friend-links-page.tsx +++ b/admin/src/pages/friend-links-page.tsx @@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac import { toast } from 'sonner' import { FormField } from '@/components/form-field' +import { MediaUrlControls } from '@/components/media-url-controls' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -378,13 +379,25 @@ export function FriendLinksPage() { } /> - - - setForm((current) => ({ ...current, avatarUrl: event.target.value })) - } - /> + +
+ + setForm((current) => ({ ...current, avatarUrl: event.target.value })) + } + /> + + setForm((current) => ({ ...current, avatarUrl })) + } + prefix="friend-link-avatars/" + contextLabel="友链头像上传" + remoteTitle={form.siteName || form.siteUrl || '友链头像'} + dataTestIdPrefix="friend-link-avatar" + /> +
('avif') const [remoteDownloadForm, setRemoteDownloadForm] = useState( defaultRemoteDownloadForm, ) + const [remoteTargetFormat, setRemoteTargetFormat] = useState<'original' | 'webp' | 'avif'>('original') const [downloadingRemote, setDownloadingRemote] = useState(false) const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState(null) @@ -218,22 +220,18 @@ export function MediaPage() { const quality = Number.parseFloat(compressQuality) const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82 - const normalizeCover = - targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' + const mode = + targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image' const result: File[] = [] for (const file of files) { - const compressed = normalizeCover - ? await normalizeCoverImageWithPrompt(file, { - quality: safeQuality, - ask: true, - contextLabel: `封面规范化上传(${file.name})`, - }) - : await maybeCompressImageWithPrompt(file, { - quality: safeQuality, - ask: true, - contextLabel: `媒体库上传(${file.name})`, - }) + const compressed = await prepareImageForUpload(file, { + compress: true, + quality: safeQuality, + targetFormat: uploadTargetFormat, + contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}(${file.name})`, + mode, + }) if (compressed.preview) { toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成') } @@ -304,11 +302,23 @@ export function MediaPage() { + + + + + +
-
+
: } 压缩上传 + 已选择 {uploadFiles.length} 个文件。 {uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/' - ? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。' + ? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。' : ''}

) : null} @@ -401,6 +420,19 @@ export function MediaPage() { /> + + + + { void loadItems(false) diff --git a/admin/src/pages/posts-page.tsx b/admin/src/pages/posts-page.tsx index e691a13..a418081 100644 --- a/admin/src/pages/posts-page.tsx +++ b/admin/src/pages/posts-page.tsx @@ -14,7 +14,6 @@ import { RotateCcw, Save, Trash2, - Upload, WandSparkles, X, } from 'lucide-react' @@ -24,6 +23,7 @@ import { toast } from 'sonner' import { FormField } from '@/components/form-field' import { LazyDiffEditor } from '@/components/lazy-monaco' +import { MediaUrlControls } from '@/components/media-url-controls' import { MarkdownPreview } from '@/components/markdown-preview' import { MarkdownWorkbench, @@ -49,10 +49,6 @@ import { formatPostVisibility, postTagsToList, } from '@/lib/admin-format' -import { - formatCompressionPreview, - normalizeCoverImageWithPrompt, -} from '@/lib/image-compress' import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document' import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff' import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge' @@ -259,6 +255,17 @@ function buildVirtualPostPath(slug: string) { return `article://posts/${normalizedSlug}` } +function buildInlineImagePrefix(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + + return `post-inline-images/${normalized || 'draft'}` +} + function parseImageList(value: string) { return value .split('\n') @@ -808,8 +815,6 @@ export function PostsPage() { const { slug } = useParams() const importInputRef = useRef(null) const folderImportInputRef = useRef(null) - const editorCoverInputRef = useRef(null) - const createCoverInputRef = useRef(null) const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) @@ -823,8 +828,8 @@ export function PostsPage() { useState(false) const [generatingEditorCover, setGeneratingEditorCover] = useState(false) const [generatingCreateCover, setGeneratingCreateCover] = useState(false) - const [uploadingEditorCover, setUploadingEditorCover] = useState(false) - const [uploadingCreateCover, setUploadingCreateCover] = useState(false) + const [localizingEditorImages, setLocalizingEditorImages] = useState(false) + const [localizingCreateImages, setLocalizingCreateImages] = useState(false) const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false) const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false) const [editor, setEditor] = useState(null) @@ -1457,67 +1462,89 @@ export function PostsPage() { } }, [createForm]) - const uploadEditorCover = useCallback(async (file: File) => { - try { - setUploadingEditorCover(true) - const compressed = await normalizeCoverImageWithPrompt(file, { - quality: 0.82, - ask: true, - contextLabel: '文章封面规范化上传', - }) - if (compressed.preview) { - toast.message(formatCompressionPreview(compressed.preview)) - } + const localizeEditorMarkdownImages = useCallback(async () => { + if (!editor) { + return + } - const result = await adminApi.uploadMediaObjects([compressed.file], { - prefix: 'post-covers/', + const sourceMarkdown = buildDraftMarkdownForWindow(editor) + if (!stripFrontmatter(sourceMarkdown).trim()) { + toast.error('先准备一点正文,再执行正文图片本地化。') + return + } + + try { + setLocalizingEditorImages(true) + const result = await adminApi.localizePostMarkdownImages({ + markdown: sourceMarkdown, + prefix: buildInlineImagePrefix(editor.slug), }) - const url = result.uploaded[0]?.url - if (!url) { - throw new Error('上传完成但未返回 URL') + + if (!result.localized_count && !result.failed_count) { + toast.message('正文里没有检测到需要本地化的远程图片。') + return } startTransition(() => { - setEditor((current) => (current ? { ...current, image: url } : current)) + setEditor((current) => + current ? applyPolishedEditorState(current, result.markdown) : current, + ) }) - toast.success('封面已上传并回填。') - } catch (error) { - toast.error(error instanceof ApiError ? error.message : '封面上传失败。') - } finally { - setUploadingEditorCover(false) - } - }, []) - const uploadCreateCover = useCallback(async (file: File) => { - try { - setUploadingCreateCover(true) - const compressed = await normalizeCoverImageWithPrompt(file, { - quality: 0.82, - ask: true, - contextLabel: '新建封面规范化上传', - }) - if (compressed.preview) { - toast.message(formatCompressionPreview(compressed.preview)) + if (result.localized_count && result.failed_count) { + toast.warning( + `已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`, + ) + } else if (result.localized_count) { + toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`) + } else { + toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`) } + } catch (error) { + toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。') + } finally { + setLocalizingEditorImages(false) + } + }, [editor]) - const result = await adminApi.uploadMediaObjects([compressed.file], { - prefix: 'post-covers/', + const localizeCreateMarkdownImages = useCallback(async () => { + const sourceMarkdown = buildCreateMarkdownForWindow(createForm) + if (!stripFrontmatter(sourceMarkdown).trim()) { + toast.error('先准备一点正文,再执行正文图片本地化。') + return + } + + try { + setLocalizingCreateImages(true) + const result = await adminApi.localizePostMarkdownImages({ + markdown: sourceMarkdown, + prefix: buildInlineImagePrefix(createForm.slug || createForm.title), }) - const url = result.uploaded[0]?.url - if (!url) { - throw new Error('上传完成但未返回 URL') + + if (!result.localized_count && !result.failed_count) { + toast.message('正文里没有检测到需要本地化的远程图片。') + return } startTransition(() => { - setCreateForm((current) => ({ ...current, image: url })) + setCreateForm((current) => applyPolishedCreateState(current, result.markdown)) }) - toast.success('封面已上传并回填。') + + if (result.localized_count && result.failed_count) { + toast.warning( + `已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`, + ) + } else if (result.localized_count) { + toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`) + } else { + toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`) + } } catch (error) { - toast.error(error instanceof ApiError ? error.message : '封面上传失败。') + toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。') } finally { - setUploadingCreateCover(false) + setLocalizingCreateImages(false) } - }, []) + }, [createForm]) const openEditorPreviewWindow = useCallback(() => { const snapshot = buildEditorDraftSnapshot() @@ -2087,32 +2114,6 @@ export function PostsPage() { void importMarkdownFiles(event.target.files) }} /> - { - const file = event.target.files?.[0] - if (file) { - void uploadEditorCover(file) - } - event.currentTarget.value = '' - }} - /> - { - const file = event.target.files?.[0] - if (file) { - void uploadCreateCover(file) - } - event.currentTarget.value = '' - }} - />
@@ -2526,29 +2527,34 @@ export function PostsPage() { - - - setEditor((current) => - current ? { ...current, image: event.target.value } : current, - ) - } - /> + +
+ + setEditor((current) => + current ? { ...current, image: event.target.value } : current, + ) + } + /> + + setEditor((current) => (current ? { ...current, image } : current)) + } + prefix="post-covers/" + contextLabel="文章封面上传" + mode="cover" + remoteTitle={editor.title || editor.slug || '文章封面'} + dataTestIdPrefix="post-editor-cover" + /> +
- + + -
+ + setForm((current) => ({ ...current, cover: event.target.value })) + } + /> + setForm((current) => ({ ...current, cover }))} + prefix="review-covers/" + contextLabel="评测封面上传" + mode="cover" + remoteTitle={form.title || '评测封面'} + dataTestIdPrefix="review-cover" + /> {form.cover ? (
diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index eecd6f7..54d7efc 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { MediaUrlControls } from '@/components/media-url-controls' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -132,6 +133,9 @@ function normalizeSettingsResponse( web_push_vapid_public_key: input.web_push_vapid_public_key ?? null, web_push_vapid_private_key: input.web_push_vapid_private_key ?? null, web_push_vapid_subject: input.web_push_vapid_subject ?? null, + music_enabled: input.music_enabled ?? true, + maintenance_mode_enabled: input.maintenance_mode_enabled ?? false, + maintenance_access_code: input.maintenance_access_code ?? null, ai_active_provider_id: input.ai_active_provider_id ?? aiProviders[0]?.id ?? null, } @@ -177,6 +181,9 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload { location: form.location, techStack: form.tech_stack, musicPlaylist: form.music_playlist, + musicEnabled: form.music_enabled, + maintenanceModeEnabled: form.maintenance_mode_enabled, + maintenanceAccessCode: form.maintenance_access_code, aiEnabled: form.ai_enabled, paragraphCommentsEnabled: form.paragraph_comments_enabled, commentVerificationMode: form.comment_verification_mode, @@ -514,6 +521,11 @@ export function SiteSettingsPage() { disabled={saving} data-testid="site-settings-save" onClick={async () => { + if (form.maintenance_mode_enabled && !form.maintenance_access_code?.trim()) { + toast.error('开启维护模式前请先填写访问口令。') + return + } + try { setSaving(true) const updated = await adminApi.updateSiteSettings(toPayload(form)) @@ -607,11 +619,21 @@ export function SiteSettingsPage() { onChange={(event) => updateField('owner_name', event.target.value)} /> - - updateField('owner_avatar_url', event.target.value)} - /> + +
+ updateField('owner_avatar_url', event.target.value)} + /> + updateField('owner_avatar_url', ownerAvatarUrl)} + prefix="site-assets/" + contextLabel="站长头像上传" + remoteTitle={form.owner_name || form.site_name || '站长头像'} + dataTestIdPrefix="site-owner-avatar" + /> +
@@ -765,6 +787,55 @@ export function SiteSettingsPage() { + + + 维护模式 + + 开启后,前台访问者需要先输入口令才能看到内容,适合开发联调、灰度预览或上线前封站检查。 + + + + + +
+ + + updateField('maintenance_access_code', event.target.value) + } + placeholder="例如:staging-2026" + /> + + +
+ + {form.maintenance_mode_enabled ? '维护中' : '正常开放'} + +
+
+
+
+ 运行时安全 / 推送配置 @@ -844,11 +915,23 @@ export function SiteSettingsPage() { - - updateField('seo_default_og_image', event.target.value)} - /> + +
+ updateField('seo_default_og_image', event.target.value)} + /> + + updateField('seo_default_og_image', seoDefaultOgImage) + } + prefix="seo-assets/" + contextLabel="默认 OG 图上传" + remoteTitle={form.site_name || form.site_title || '默认 OG 图'} + dataTestIdPrefix="site-default-og" + /> +
音乐侧栏 - 把头部播放器的曲目清单和单曲属性放到独立侧边栏里维护。 + 可以直接控制前台是否显示音乐播放器,歌单配置会继续保留在后台。
- {form.music_playlist.length} 首 +
+ + {form.music_enabled ? '前台已开启' : '前台已关闭'} + + {form.music_playlist.length} 首 +
+ +
{form.music_playlist.map((track, index) => { const active = index === selectedTrackIndex @@ -1687,13 +1790,25 @@ export function SiteSettingsPage() { } /> - - - updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value) - } - /> + +
+ + updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value) + } + /> + + updateMusicTrack(selectedTrackIndex, 'cover_image_url', coverImageUrl) + } + prefix="music-covers/" + contextLabel="音乐封面上传" + remoteTitle={selectedTrack.title || `曲目 ${selectedTrackIndex + 1} 封面`} + dataTestIdPrefix="site-music-cover" + /> +
diff --git a/admin/src/pages/subscriptions-page.tsx b/admin/src/pages/subscriptions-page.tsx index f4e3ce3..e5d89b8 100644 --- a/admin/src/pages/subscriptions-page.tsx +++ b/admin/src/pages/subscriptions-page.tsx @@ -19,9 +19,12 @@ import { TableRow, } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' +import { formatBrowserName, formatDateTime } from '@/lib/admin-format' import { adminApi, ApiError } from '@/lib/api' import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types' +type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger' + const CHANNEL_OPTIONS = [ { value: 'email', label: 'Email' }, { value: 'webhook', label: 'Webhook' }, @@ -72,6 +75,127 @@ function normalizePreview(value: unknown) { return text || '—' } +function formatSubscriptionChannelLabel(channelType: string) { + switch (channelType) { + case 'web_push': + return '浏览器提醒' + case 'email': + return '邮件订阅' + case 'discord': + return 'Discord Webhook' + case 'telegram': + return 'Telegram Bot API' + case 'ntfy': + return 'ntfy' + case 'webhook': + return 'Webhook' + default: + return channelType + } +} + +function readMetadataString(metadata: SubscriptionRecord['metadata'], key: string) { + const value = metadata?.[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function formatSubscriptionSource(source: string | null) { + switch (source) { + case 'frontend-popup': + return '前台订阅弹窗' + case 'manual': + return '后台手动添加' + case 'admin': + return '后台手动添加' + case 'import': + return '批量导入' + case 'seed': + return '初始化数据' + default: + return source ?? '未记录' + } +} + +function formatSubscriptionPlatform(userAgent: string | null) { + if (!userAgent) { + return null + } + + const ua = userAgent.toLowerCase() + if (ua.includes('android')) return 'Android' + if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ios')) return 'iOS' + if (ua.includes('windows')) return 'Windows' + if (ua.includes('mac os x') || ua.includes('macintosh')) return 'macOS' + if (ua.includes('linux')) return 'Linux' + return null +} + +function formatPushEndpointHost(target: string) { + try { + const url = new URL(target) + return url.host || url.origin + } catch { + return null + } +} + +function describeSubscriptionTarget(item: SubscriptionRecord) { + const createdAt = formatDateTime(item.created_at) + + if (item.channel_type === 'web_push') { + const userAgent = readMetadataString(item.metadata, 'user_agent') + const browser = userAgent ? formatBrowserName(userAgent) : '浏览器信息未记录' + const platform = formatSubscriptionPlatform(userAgent) + const pushHost = formatPushEndpointHost(item.target) + + return { + primary: platform ? `${browser} · ${platform}` : browser, + details: [ + pushHost ? `推送节点:${pushHost}` : '推送地址:已隐藏完整链接', + `创建于:${createdAt}`, + ], + title: item.target, + } + } + + return { + primary: item.target, + details: [`创建于:${createdAt}`], + title: item.target, + } +} + +function getSubscriptionSourceBadge(item: SubscriptionRecord): { label: string; variant: BadgeVariant } { + const source = readMetadataString(item.metadata, 'source') + const kind = readMetadataString(item.metadata, 'kind') + + if (source === 'frontend-popup') { + return { label: '前台弹窗', variant: 'default' } + } + + if (source === 'manual' || source === 'admin') { + return { label: '后台手动', variant: 'secondary' } + } + + if (source === 'import' || source === 'seed') { + return { label: formatSubscriptionSource(source), variant: 'warning' } + } + + if (kind === 'browser-push') { + return { label: '前台浏览器订阅', variant: 'default' } + } + + if (kind === 'public-form') { + return { label: '前台邮箱订阅', variant: 'default' } + } + + if (source) { + return { label: formatSubscriptionSource(source), variant: 'outline' } + } + + return { label: '未记录来源', variant: 'outline' } +} + export function SubscriptionsPage() { const [subscriptions, setSubscriptions] = useState([]) const [deliveries, setDeliveries] = useState([]) @@ -84,6 +208,8 @@ export function SubscriptionsPage() { const [workerJobs, setWorkerJobs] = useState([]) const [lastActionJobId, setLastActionJobId] = useState(null) const [form, setForm] = useState(emptyForm()) + const [subscriptionSearch, setSubscriptionSearch] = useState('') + const [subscriptionChannelFilter, setSubscriptionChannelFilter] = useState('all') const loadData = useCallback(async (showToast = false) => { try { @@ -131,6 +257,68 @@ export function SubscriptionsPage() { [deliveries], ) + const filteredSubscriptions = useMemo(() => { + const query = subscriptionSearch.trim().toLowerCase() + + return subscriptions.filter((item) => { + if (subscriptionChannelFilter !== 'all' && item.channel_type !== subscriptionChannelFilter) { + return false + } + + if (!query) { + return true + } + + const sourceBadge = getSubscriptionSourceBadge(item) + const targetInfo = describeSubscriptionTarget(item) + const searchable = [ + item.display_name, + item.target, + item.channel_type, + formatSubscriptionChannelLabel(item.channel_type), + sourceBadge.label, + targetInfo.primary, + ...targetInfo.details, + readMetadataString(item.metadata, 'user_agent'), + readMetadataString(item.metadata, 'source'), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + return searchable.includes(query) + }) + }, [subscriptionChannelFilter, subscriptionSearch, subscriptions]) + + const groupedSubscriptions = useMemo( + () => [ + { + key: 'web_push', + title: '浏览器提醒', + description: '默认主流程,授权后可直接收到站内更新提醒。', + badgeVariant: 'default' as BadgeVariant, + items: filteredSubscriptions.filter((item) => item.channel_type === 'web_push'), + }, + { + key: 'email', + title: '邮件订阅', + description: '通常作为额外备份,确认邮箱后开始生效。', + badgeVariant: 'secondary' as BadgeVariant, + items: filteredSubscriptions.filter((item) => item.channel_type === 'email'), + }, + { + key: 'other', + title: '其他渠道', + description: 'Webhook / Discord / Telegram / ntfy 等外部通知目标。', + badgeVariant: 'outline' as BadgeVariant, + items: filteredSubscriptions.filter( + (item) => item.channel_type !== 'web_push' && item.channel_type !== 'email', + ), + }, + ].filter((group) => group.items.length > 0), + [filteredSubscriptions], + ) + const deliveryJobMap = useMemo(() => { const map = new Map() for (const item of workerJobs) { @@ -177,6 +365,132 @@ export function SubscriptionsPage() { } }, [editingId, form, loadData, resetForm]) + const renderSubscriptionRow = useCallback((item: SubscriptionRecord) => { + const targetInfo = describeSubscriptionTarget(item) + const sourceBadge = getSubscriptionSourceBadge(item) + + return ( + + +
+
+ {item.display_name ?? formatSubscriptionChannelLabel(item.channel_type)} +
+
+ {item.channel_type} +
+
+
+ +
+
{targetInfo.primary}
+
+ {sourceBadge.label} + {item.channel_type === 'web_push' ? 浏览器订阅 : null} +
+ {targetInfo.details.map((line) => ( +
+ {line} +
+ ))} +
+ manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'} +
+
+
+ +
+ + {item.status} + +
+ 失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'} +
+
+
+ + {normalizePreview(item.filters)} + + +
+ + + +
+
+
+ ) + }, [actioningId, editingId, loadData, resetForm]) + if (loading) { return (
@@ -365,131 +679,91 @@ export function SubscriptionsPage() {
当前订阅目标 - 支持单条测试、编辑 filters / metadata,以及删除。 + 按浏览器提醒 / 邮件订阅 / 其他渠道分组查看,并支持搜索、筛选、测试与编辑。
- {subscriptions.length} 个 + + {filteredSubscriptions.length} / {subscriptions.length} 个 +
- - - - - 频道 - 目标 - 状态 - 偏好 - 操作 - - - - {subscriptions.map((item) => ( - - -
-
{item.display_name ?? item.channel_type}
-
- {item.channel_type} -
+ +
+
+ + setSubscriptionSearch(event.target.value)} + placeholder="搜索名称、地址、来源、浏览器、推送节点..." + /> +
+
+ + +
+
+ {(subscriptionSearch.trim() || subscriptionChannelFilter !== 'all') ? ( + + ) : null} +
+
+ + {subscriptions.length === 0 ? ( +
+ 当前还没有订阅记录。新的浏览器提醒或邮箱备份成功后,会直接出现在这里。 +
+ ) : groupedSubscriptions.length === 0 ? ( +
+ 没有符合当前搜索或筛选条件的订阅记录。 +
+ ) : ( + groupedSubscriptions.map((group) => ( +
+
+
+
+

{group.title}

+ {group.items.length} 个
- - -
{item.target}
-
- manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'} -
-
- -
- - {item.status} - -
- 失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'} -
-
-
- - {normalizePreview(item.filters)} - - -
- - - -
-
- - ))} - -
+

{group.description}

+
+
+ + + + + 频道 + 目标 + 状态 + 偏好 + 操作 + + + {group.items.map(renderSubscriptionRow)} +
+
+ )) + )}
diff --git a/admin/src/pages/tags-page.tsx b/admin/src/pages/tags-page.tsx index 719c048..e77804c 100644 --- a/admin/src/pages/tags-page.tsx +++ b/admin/src/pages/tags-page.tsx @@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac import { toast } from 'sonner' import { FormField } from '@/components/form-field' +import { MediaUrlControls } from '@/components/media-url-controls' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -302,14 +303,26 @@ export function TagsPage() { placeholder="astro" /> - - - setForm((current) => ({ ...current, coverImage: event.target.value })) - } - placeholder="https://cdn.example.com/covers/astro.jpg" - /> + +
+ + setForm((current) => ({ ...current, coverImage: event.target.value })) + } + placeholder="https://cdn.example.com/covers/astro.jpg" + /> + + setForm((current) => ({ ...current, coverImage })) + } + prefix="tag-covers/" + contextLabel="标签封面上传" + remoteTitle={form.name || form.slug || '标签封面'} + dataTestIdPrefix="tag-cover" + /> +
diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 502d7a5..0f297c2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6972,6 +6972,7 @@ dependencies = [ "base64 0.22.1", "chrono", "fastembed", + "image", "include_dir", "insta", "loco-rs", @@ -6984,6 +6985,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tokio", "tower-http", "tracing", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fc42cd8..5cc8273 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,9 +43,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", fastembed = "5.1" async-stream = "0.3" base64 = "0.22" +image = { version = "0.25.10", default-features = false, features = ["avif", "gif", "jpeg", "png", "webp"] } aws-config = "1" aws-sdk-s3 = "1" web-push = { version = "0.11.0", default-features = false, features = ["hyper-client"] } +sha2 = "0.10" [[bin]] name = "termi_api-cli" diff --git a/backend/assets/seeds/comments.yaml b/backend/assets/seeds/comments.yaml index 948fa75..47bf3f5 100644 --- a/backend/assets/seeds/comments.yaml +++ b/backend/assets/seeds/comments.yaml @@ -2,35 +2,35 @@ pid: 1 author: "林川" email: "linchuan@example.com" - content: "这篇做长文测试很合适,段落密度和古文节奏都不错。" + content: "这篇读起来很稳,段落密度和古文节奏都很舒服。" approved: true - id: 2 pid: 1 author: "阿青" email: "aqing@example.com" - content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。" + content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。" approved: true - id: 3 pid: 2 author: "周宁" email: "zhouling@example.com" - content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。" + content: "这一段关于南岩和琼台的描写很好,细节很有画面感。" approved: true - id: 4 pid: 3 author: "顾远" email: "guyuan@example.com" - content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。" + content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。" approved: true - id: 5 pid: 4 author: "清嘉" email: "qingjia@example.com" - content: "黄山记的序文很适合测试首屏摘要生成。" + content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。" approved: true - id: 6 diff --git a/backend/assets/seeds/posts.yaml b/backend/assets/seeds/posts.yaml index 74a0c78..cea2708 100644 --- a/backend/assets/seeds/posts.yaml +++ b/backend/assets/seeds/posts.yaml @@ -10,7 +10,7 @@ 自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。 骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。 - excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。" + excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。" category: "古籍游记" published: true pinned: true @@ -18,7 +18,7 @@ - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 - id: 2 pid: 2 @@ -40,7 +40,7 @@ - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 - id: 3 pid: 3 @@ -54,7 +54,7 @@ 余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。 三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。 - excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。" + excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。" category: "古籍游记" published: true pinned: false @@ -62,7 +62,7 @@ - 徐霞客 - 恒山 - 悬空寺 - - 长文测试 + - 山水游记 - id: 4 pid: 4 @@ -84,7 +84,7 @@ - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 - id: 5 pid: 5 @@ -98,7 +98,7 @@ 憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。 清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。 - excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。" + excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。" category: "古籍游记" published: true pinned: false @@ -106,4 +106,4 @@ - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 diff --git a/backend/assets/seeds/reviews.yaml b/backend/assets/seeds/reviews.yaml index b5d2192..19648d8 100644 --- a/backend/assets/seeds/reviews.yaml +++ b/backend/assets/seeds/reviews.yaml @@ -34,7 +34,7 @@ rating: 5 review_date: "2024-02-18" status: "published" - description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。" + description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。" tags: ["经济", "非虚构", "中国"] cover: "/review-covers/placed-within.svg" diff --git a/backend/assets/seeds/site_settings.yaml b/backend/assets/seeds/site_settings.yaml index 6fd0d65..6bd3d4b 100644 --- a/backend/assets/seeds/site_settings.yaml +++ b/backend/assets/seeds/site_settings.yaml @@ -2,10 +2,10 @@ site_name: "InitCool" site_short_name: "Termi" site_url: "https://init.cool" - site_title: "InitCool · 中文长文与 AI 搜索实验站" - site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。" - hero_title: "欢迎来到我的中文内容实验站" - hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验" + site_title: "InitCool · 技术笔记与内容档案" + site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。" + hero_title: "欢迎来到 InitCool" + hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。" owner_name: "InitCool" owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" @@ -43,6 +43,9 @@ cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" accent_color: "#375a7f" description: "节奏更明显一点,适合切换阅读状态。" + music_enabled: true + maintenance_mode_enabled: false + maintenance_access_code: null ai_enabled: false paragraph_comments_enabled: true comment_verification_mode: "captcha" diff --git a/backend/content/posts/building-blog-with-astro.md b/backend/content/posts/building-blog-with-astro.md index 6835d0d..83fd8b4 100644 --- a/backend/content/posts/building-blog-with-astro.md +++ b/backend/content/posts/building-blog-with-astro.md @@ -12,7 +12,7 @@ tags: - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 --- # 徐霞客游记·游太和山日记(下) diff --git a/backend/content/posts/loco-rs-framework.md b/backend/content/posts/loco-rs-framework.md index dc8393c..63e6210 100644 --- a/backend/content/posts/loco-rs-framework.md +++ b/backend/content/posts/loco-rs-framework.md @@ -1,7 +1,7 @@ --- title: 游黄山记(中) slug: loco-rs-framework -description: 钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。 +description: 钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。 category: 古籍游记 post_type: article pinned: false @@ -12,7 +12,7 @@ tags: - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 --- # 游黄山记(中) diff --git a/backend/content/posts/rust-programming-tips.md b/backend/content/posts/rust-programming-tips.md index a75aecc..74e4c47 100644 --- a/backend/content/posts/rust-programming-tips.md +++ b/backend/content/posts/rust-programming-tips.md @@ -1,7 +1,7 @@ --- title: 徐霞客游记·游恒山日记 slug: rust-programming-tips -description: 游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。 +description: 游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。 category: 古籍游记 post_type: article pinned: false @@ -12,7 +12,7 @@ tags: - 徐霞客 - 恒山 - 悬空寺 - - 长文测试 + - 山水游记 --- # 徐霞客游记·游恒山日记 diff --git a/backend/content/posts/terminal-ui-design.md b/backend/content/posts/terminal-ui-design.md index 203a51e..a75d389 100644 --- a/backend/content/posts/terminal-ui-design.md +++ b/backend/content/posts/terminal-ui-design.md @@ -12,7 +12,7 @@ tags: - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 --- # 游黄山记(上) diff --git a/backend/content/posts/welcome-to-termi.md b/backend/content/posts/welcome-to-termi.md index 14fe67d..7eeaf67 100644 --- a/backend/content/posts/welcome-to-termi.md +++ b/backend/content/posts/welcome-to-termi.md @@ -1,7 +1,7 @@ --- title: 徐霞客游记·游太和山日记(上) slug: welcome-to-termi -description: 《徐霞客游记》太和山上篇,适合作为中文长文测试样本。 +description: 《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。 category: 古籍游记 post_type: article pinned: true @@ -12,7 +12,7 @@ tags: - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 --- # 徐霞客游记·游太和山日记(上) diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index ba78ef4..ff2038c 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -45,6 +45,8 @@ mod m20260401_000034_add_source_markdown_to_posts; mod m20260401_000035_add_human_verification_modes_to_site_settings; mod m20260402_000036_create_worker_jobs; mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings; +mod m20260402_000038_add_music_enabled_to_site_settings; +mod m20260402_000039_add_maintenance_mode_to_site_settings; pub struct Migrator; #[async_trait::async_trait] @@ -94,6 +96,8 @@ impl MigratorTrait for Migrator { Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration), Box::new(m20260402_000036_create_worker_jobs::Migration), Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration), + Box::new(m20260402_000038_add_music_enabled_to_site_settings::Migration), + Box::new(m20260402_000039_add_maintenance_mode_to_site_settings::Migration), // inject-above (do not remove this comment) ] } diff --git a/backend/migration/src/m20260402_000038_add_music_enabled_to_site_settings.rs b/backend/migration/src/m20260402_000038_add_music_enabled_to_site_settings.rs new file mode 100644 index 0000000..95819d9 --- /dev/null +++ b/backend/migration/src/m20260402_000038_add_music_enabled_to_site_settings.rs @@ -0,0 +1,46 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager.has_column("site_settings", "music_enabled").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("music_enabled")) + .boolean() + .null() + .default(true), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if manager.has_column("site_settings", "music_enabled").await? { + manager + .alter_table( + Table::alter() + .table(table) + .drop_column(Alias::new("music_enabled")) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260402_000039_add_maintenance_mode_to_site_settings.rs b/backend/migration/src/m20260402_000039_add_maintenance_mode_to_site_settings.rs new file mode 100644 index 0000000..a09f6a3 --- /dev/null +++ b/backend/migration/src/m20260402_000039_add_maintenance_mode_to_site_settings.rs @@ -0,0 +1,84 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager + .has_column("site_settings", "maintenance_mode_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("maintenance_mode_enabled")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "maintenance_access_code") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column( + ColumnDef::new(Alias::new("maintenance_access_code")) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if manager + .has_column("site_settings", "maintenance_access_code") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new("maintenance_access_code")) + .to_owned(), + ) + .await?; + } + + if manager + .has_column("site_settings", "maintenance_mode_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .drop_column(Alias::new("maintenance_mode_enabled")) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} diff --git a/backend/src/app.rs b/backend/src/app.rs index 8429793..af39277 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -1,18 +1,18 @@ use async_trait::async_trait; use axum::{ - http::{header, HeaderName, Method}, Router as AxumRouter, + http::{HeaderName, Method, header}, }; use loco_rs::{ + Result, app::{AppContext, Hooks, Initializer}, bgworker::{BackgroundWorker, Queue}, - boot::{create_app, BootResult, StartMode}, + boot::{BootResult, StartMode, create_app}, config::Config, controller::AppRoutes, db::{self, truncate_table}, environment::Environment, task::Tasks, - Result, }; use migration::Migrator; use sea_orm::{ @@ -99,7 +99,9 @@ impl Hooks for App { } async fn initializers(_ctx: &AppContext) -> Result>> { - Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)]) + Ok(vec![Box::new( + initializers::content_sync::ContentSyncInitializer, + )]) } fn routes(_ctx: &AppContext) -> AppRoutes { @@ -152,7 +154,9 @@ impl Hooks for App { } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; - queue.register(NotificationDeliveryWorker::build(ctx)).await?; + queue + .register(NotificationDeliveryWorker::build(ctx)) + .await?; Ok(()) } @@ -334,8 +338,7 @@ impl Hooks for App { let comment_verification_mode = settings["comment_verification_mode"] .as_str() .map(ToString::to_string); - let subscription_verification_mode = settings - ["subscription_verification_mode"] + let subscription_verification_mode = settings["subscription_verification_mode"] .as_str() .map(ToString::to_string); let comment_turnstile_enabled = settings["comment_turnstile_enabled"] @@ -343,8 +346,7 @@ impl Hooks for App { .or(comment_verification_mode .as_deref() .map(|value| value.eq_ignore_ascii_case("turnstile"))); - let subscription_turnstile_enabled = settings - ["subscription_turnstile_enabled"] + let subscription_turnstile_enabled = settings["subscription_turnstile_enabled"] .as_bool() .or(subscription_verification_mode .as_deref() @@ -381,6 +383,20 @@ impl Hooks for App { }) .filter(|items| !items.is_empty()) .map(serde_json::Value::Array); + let music_enabled = settings["music_enabled"].as_bool().or(Some(true)); + let maintenance_mode_enabled = settings["maintenance_mode_enabled"] + .as_bool() + .or(Some(false)); + let maintenance_access_code = settings["maintenance_access_code"] + .as_str() + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); let item = site_settings::ActiveModel { id: Set(settings["id"].as_i64().unwrap_or(1) as i32), @@ -422,6 +438,9 @@ impl Hooks for App { location: Set(settings["location"].as_str().map(ToString::to_string)), tech_stack: Set(tech_stack), music_playlist: Set(music_playlist), + music_enabled: Set(music_enabled), + maintenance_mode_enabled: Set(maintenance_mode_enabled), + maintenance_access_code: Set(maintenance_access_code), ai_enabled: Set(settings["ai_enabled"].as_bool()), paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"] .as_bool() diff --git a/backend/src/controllers/admin.rs b/backend/src/controllers/admin.rs index 64fcff5..a106043 100644 --- a/backend/src/controllers/admin.rs +++ b/backend/src/controllers/admin.rs @@ -1,4 +1,4 @@ -use axum::http::{header, HeaderMap}; +use axum::http::{HeaderMap, header}; use loco_rs::prelude::*; use serde::Serialize; use std::{ @@ -75,7 +75,8 @@ fn header_value(headers: &HeaderMap, key: &'static str) -> Option { } fn split_groups(value: Option) -> Vec { - value.unwrap_or_default() + value + .unwrap_or_default() .split([',', ';', ' ']) .map(str::trim) .filter(|item| !item.is_empty()) @@ -192,8 +193,7 @@ pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option Result { - resolve_admin_identity(headers) - .ok_or_else(|| Error::Unauthorized("Not logged in".to_string())) + resolve_admin_identity(headers).ok_or_else(|| Error::Unauthorized("Not logged in".to_string())) } pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) { diff --git a/backend/src/controllers/admin_api.rs b/backend/src/controllers/admin_api.rs index 09a5283..6f85ec8 100644 --- a/backend/src/controllers/admin_api.rs +++ b/backend/src/controllers/admin_api.rs @@ -1,8 +1,12 @@ +use std::collections::{HashMap, HashSet}; + use axum::{ extract::{Multipart, Query}, http::{HeaderMap, header}, }; use loco_rs::prelude::*; +use regex::Regex; +use reqwest::Url; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, @@ -25,7 +29,7 @@ use crate::{ services::{ admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs, }, - workers::downloader::DownloadWorkerArgs, + workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format}, }; #[derive(Clone, Debug, Deserialize)] @@ -171,6 +175,9 @@ pub struct AdminSiteSettingsResponse { pub location: Option, pub tech_stack: Vec, pub music_playlist: Vec, + pub music_enabled: bool, + pub maintenance_mode_enabled: bool, + pub maintenance_access_code: Option, pub ai_enabled: bool, pub paragraph_comments_enabled: bool, pub comment_verification_mode: String, @@ -356,6 +363,8 @@ pub struct AdminMediaDownloadPayload { #[serde(default)] pub prefix: Option, #[serde(default)] + pub target_format: Option, + #[serde(default)] pub title: Option, #[serde(default)] pub alt_text: Option, @@ -365,13 +374,19 @@ pub struct AdminMediaDownloadPayload { pub tags: Option>, #[serde(default)] pub notes: Option, + #[serde(default)] + pub sync: bool, } #[derive(Clone, Debug, Serialize)] pub struct AdminMediaDownloadResponse { pub queued: bool, - pub job_id: i32, - pub status: String, + pub job_id: Option, + pub status: Option, + pub key: Option, + pub url: Option, + pub size_bytes: Option, + pub content_type: Option, } #[derive(Clone, Debug, Deserialize)] @@ -487,6 +502,37 @@ pub struct AdminPostPolishRequest { pub markdown: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct AdminPostLocalizeImagesRequest { + pub markdown: String, + #[serde(default)] + pub prefix: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminPostLocalizedImageItem { + pub source_url: String, + pub localized_url: String, + pub key: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminPostLocalizeImagesFailure { + pub source_url: String, + pub error: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminPostLocalizeImagesResponse { + pub markdown: String, + pub detected_count: usize, + pub localized_count: usize, + pub uploaded_count: usize, + pub failed_count: usize, + pub items: Vec, + pub failures: Vec, +} + #[derive(Clone, Debug, Deserialize)] pub struct AdminReviewPolishRequest { pub title: String, @@ -537,6 +583,199 @@ fn trim_to_option(value: Option) -> Option { }) } +fn normalize_localize_image_prefix(value: Option) -> String { + trim_to_option(value) + .map(|item| item.trim_matches('/').to_string()) + .filter(|item| !item.is_empty()) + .unwrap_or_else(|| "post-inline-images".to_string()) +} + +fn normalize_markdown_image_target(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() > 2 { + Some(trimmed[1..trimmed.len() - 1].trim().to_string()) + } else { + Some(trimmed.to_string()) + } +} + +fn markdown_image_reference_urls(markdown: &str) -> Vec { + let markdown_pattern = + Regex::new(r#"!\[[^\]]*]\((?P<[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)"#) + .expect("valid markdown image regex"); + let html_double_quote_pattern = Regex::new(r#"(?i)]*?\bsrc\s*=\s*"(?P[^"]+)""#) + .expect("valid html img double quote regex"); + let html_single_quote_pattern = Regex::new(r#"(?i)]*?\bsrc\s*=\s*'(?P[^']+)'"#) + .expect("valid html img single quote regex"); + + let mut urls = Vec::new(); + + for captures in markdown_pattern.captures_iter(markdown) { + if let Some(url) = captures + .name("url") + .and_then(|item| normalize_markdown_image_target(item.as_str())) + { + urls.push(url); + } + } + + for captures in html_double_quote_pattern.captures_iter(markdown) { + if let Some(url) = captures + .name("url") + .and_then(|item| normalize_markdown_image_target(item.as_str())) + { + urls.push(url); + } + } + + for captures in html_single_quote_pattern.captures_iter(markdown) { + if let Some(url) = captures + .name("url") + .and_then(|item| normalize_markdown_image_target(item.as_str())) + { + urls.push(url); + } + } + + urls +} + +fn is_remote_markdown_image_candidate( + url: &str, + settings: Option<&storage::MediaStorageSettings>, +) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + + if !matches!(parsed.scheme(), "http" | "https") { + return false; + } + + if settings + .and_then(|item| storage::object_key_from_public_url(item, url)) + .is_some() + { + return false; + } + + true +} + +fn replace_markdown_image_urls( + markdown: &str, + replacements: &HashMap, +) -> (String, usize) { + let markdown_pattern = Regex::new( + r#"(?P!\[[^\]]*]\()(?P<[^>\n]+>|[^)\s]+)(?P(?:\s+(?:"[^"]*"|'[^']*'))?\))"#, + ) + .expect("valid markdown image replacement regex"); + let html_double_quote_pattern = + Regex::new(r#"(?i)(?P]*?\bsrc\s*=\s*")(?P[^"]+)(?P"[^>]*>)"#) + .expect("valid html img double quote replacement regex"); + let html_single_quote_pattern = + Regex::new(r#"(?i)(?P]*?\bsrc\s*=\s*')(?P[^']+)(?P'[^>]*>)"#) + .expect("valid html img single quote replacement regex"); + + let mut localized_count = 0usize; + + let after_markdown = markdown_pattern + .replace_all(markdown, |captures: ®ex::Captures<'_>| { + let raw_url = captures + .name("url") + .map(|item| item.as_str()) + .unwrap_or_default(); + let Some(normalized_url) = normalize_markdown_image_target(raw_url) else { + return captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string(); + }; + + if let Some(localized_url) = replacements.get(&normalized_url) { + localized_count += 1; + format!( + "{}{}{}", + &captures["lead"], localized_url, &captures["trail"] + ) + } else { + captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string() + } + }) + .to_string(); + + let after_html_double = html_double_quote_pattern + .replace_all(&after_markdown, |captures: ®ex::Captures<'_>| { + let raw_url = captures + .name("url") + .map(|item| item.as_str()) + .unwrap_or_default(); + let Some(normalized_url) = normalize_markdown_image_target(raw_url) else { + return captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string(); + }; + + if let Some(localized_url) = replacements.get(&normalized_url) { + localized_count += 1; + format!( + "{}{}{}", + &captures["lead"], localized_url, &captures["trail"] + ) + } else { + captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string() + } + }) + .to_string(); + + let after_html_single = html_single_quote_pattern + .replace_all(&after_html_double, |captures: ®ex::Captures<'_>| { + let raw_url = captures + .name("url") + .map(|item| item.as_str()) + .unwrap_or_default(); + let Some(normalized_url) = normalize_markdown_image_target(raw_url) else { + return captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string(); + }; + + if let Some(localized_url) = replacements.get(&normalized_url) { + localized_count += 1; + format!( + "{}{}{}", + &captures["lead"], localized_url, &captures["trail"] + ) + } else { + captures + .get(0) + .map(|item| item.as_str()) + .unwrap_or_default() + .to_string() + } + }) + .to_string(); + + (after_html_single, localized_count) +} + fn parse_optional_timestamp( value: Option<&str>, ) -> Result>> { @@ -785,6 +1024,9 @@ fn build_settings_response( location: item.location, tech_stack: tech_stack_values(&item.tech_stack), music_playlist: music_playlist_values(&item.music_playlist), + music_enabled: item.music_enabled.unwrap_or(true), + maintenance_mode_enabled: item.maintenance_mode_enabled.unwrap_or(false), + maintenance_access_code: item.maintenance_access_code, ai_enabled: item.ai_enabled.unwrap_or(false), paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true), comment_verification_mode: comment_verification_mode.as_str().to_string(), @@ -1493,9 +1735,11 @@ pub async fn download_media_object( Json(payload): Json, ) -> Result { let actor = check_auth(&headers)?; + let target_format = normalize_target_format(payload.target_format.clone())?; let worker_args = DownloadWorkerArgs { source_url: payload.source_url.clone(), prefix: payload.prefix.clone(), + target_format, title: payload.title.clone(), alt_text: payload.alt_text.clone(), caption: payload.caption.clone(), @@ -1503,6 +1747,38 @@ pub async fn download_media_object( notes: payload.notes.clone(), job_id: None, }; + + if payload.sync { + let downloaded = download_media_to_storage(&ctx, &worker_args).await?; + + admin_audit::log_event( + &ctx, + Some(&actor), + "media.download", + "media", + Some(downloaded.key.clone()), + Some(payload.source_url.clone()), + Some(serde_json::json!({ + "queued": false, + "source_url": payload.source_url, + "target_format": worker_args.target_format, + "key": downloaded.key, + "url": downloaded.url, + })), + ) + .await?; + + return format::json(AdminMediaDownloadResponse { + queued: false, + job_id: None, + status: Some("completed".to_string()), + key: Some(downloaded.key), + url: Some(downloaded.url), + size_bytes: Some(downloaded.size_bytes), + content_type: downloaded.content_type, + }); + } + let job = worker_jobs::queue_download_job( &ctx, &worker_args, @@ -1524,14 +1800,19 @@ pub async fn download_media_object( "job_id": job.id, "queued": true, "source_url": payload.source_url, + "target_format": worker_args.target_format, })), ) .await?; format::json(AdminMediaDownloadResponse { queued: true, - job_id: job.id, - status: job.status, + job_id: Some(job.id), + status: Some(job.status), + key: None, + url: None, + size_bytes: None, + content_type: None, }) } @@ -1907,6 +2188,89 @@ pub async fn polish_post_markdown( format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?) } +#[debug_handler] +pub async fn localize_post_markdown_images( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let normalized_markdown = payload.markdown.replace("\r\n", "\n"); + let prefix = normalize_localize_image_prefix(payload.prefix); + let settings = storage::optional_r2_settings(&ctx).await?; + let detected_urls = markdown_image_reference_urls(&normalized_markdown); + let candidate_urls = detected_urls + .into_iter() + .filter(|url| is_remote_markdown_image_candidate(url, settings.as_ref())) + .collect::>(); + + if candidate_urls.is_empty() { + return format::json(AdminPostLocalizeImagesResponse { + markdown: normalized_markdown, + detected_count: 0, + localized_count: 0, + uploaded_count: 0, + failed_count: 0, + items: Vec::new(), + failures: Vec::new(), + }); + } + + let mut seen = HashSet::new(); + let unique_urls = candidate_urls + .iter() + .filter(|url| seen.insert((*url).clone())) + .cloned() + .collect::>(); + + let mut replacements = HashMap::::new(); + let mut items = Vec::::new(); + let mut failures = Vec::::new(); + + for source_url in unique_urls { + let args = DownloadWorkerArgs { + source_url: source_url.clone(), + prefix: Some(prefix.clone()), + target_format: None, + title: None, + alt_text: None, + caption: None, + tags: vec!["markdown-image".to_string()], + notes: Some("localized from markdown body".to_string()), + job_id: None, + }; + + match download_media_to_storage(&ctx, &args).await { + Ok(downloaded) => { + replacements.insert(source_url.clone(), downloaded.url.clone()); + items.push(AdminPostLocalizedImageItem { + source_url, + localized_url: downloaded.url, + key: downloaded.key, + }); + } + Err(error) => failures.push(AdminPostLocalizeImagesFailure { + source_url, + error: error.to_string(), + }), + } + } + + let (markdown, localized_count) = + replace_markdown_image_urls(&normalized_markdown, &replacements); + + format::json(AdminPostLocalizeImagesResponse { + markdown, + detected_count: candidate_urls.len(), + localized_count, + uploaded_count: items.len(), + failed_count: failures.len(), + items, + failures, + }) +} + #[debug_handler] pub async fn polish_review_description( headers: HeaderMap, @@ -2045,6 +2409,10 @@ pub fn routes() -> Routes { .add("/ai/reindex", post(reindex_ai)) .add("/ai/test-provider", post(test_ai_provider)) .add("/ai/test-image-provider", post(test_ai_image_provider)) + .add( + "/posts/localize-images", + post(localize_post_markdown_images), + ) .add("/storage/r2/test", post(test_r2_storage)) .add( "/storage/media", diff --git a/backend/src/controllers/admin_ops.rs b/backend/src/controllers/admin_ops.rs index 2d6fa6c..ee102ea 100644 --- a/backend/src/controllers/admin_ops.rs +++ b/backend/src/controllers/admin_ops.rs @@ -8,9 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::{ controllers::admin::check_auth, - models::_entities::{ - admin_audit_logs, notification_deliveries, post_revisions, subscriptions, - }, + models::_entities::{admin_audit_logs, notification_deliveries, post_revisions, subscriptions}, services::{ admin_audit, backups, post_revisions as revision_service, subscriptions as subscription_service, worker_jobs, @@ -174,7 +172,12 @@ fn format_revision(item: post_revisions::Model) -> PostRevisionListItem { actor_email: item.actor_email, actor_source: item.actor_source, created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), - has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(), + has_markdown: item + .markdown + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some(), metadata: item.metadata, } } @@ -187,17 +190,31 @@ pub async fn list_audit_logs( ) -> Result { check_auth(&headers)?; - let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc); + let mut db_query = + admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc); - if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(action) = query + .action + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action)); } - if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(target_type) = query + .target_type + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type)); } - format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?) + format::json( + db_query + .limit(query.limit.unwrap_or(80)) + .all(&ctx.db) + .await?, + ) } #[debug_handler] @@ -207,7 +224,9 @@ pub async fn list_post_revisions( State(ctx): State, ) -> Result { check_auth(&headers)?; - let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?; + let items = + revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)) + .await?; format::json(items.into_iter().map(format_revision).collect::>()) } @@ -234,8 +253,7 @@ pub async fn restore_post_revision( ) -> Result { let actor = check_auth(&headers)?; let mode = payload.mode.unwrap_or_else(|| "full".to_string()); - let restored = - revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?; + let restored = revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?; admin_audit::log_event( &ctx, Some(&actor), @@ -278,7 +296,8 @@ pub async fn list_subscription_deliveries( ) -> Result { check_auth(&headers)?; format::json(DeliveryListResponse { - deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?, + deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)) + .await?, }) } @@ -300,7 +319,9 @@ pub async fn create_subscription( channel_type: Set(channel_type.clone()), target: Set(target.clone()), display_name: Set(trim_to_option(payload.display_name)), - status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))), + status: Set(subscription_service::normalize_status( + payload.status.as_deref().unwrap_or("active"), + )), filters: Set(subscription_service::normalize_filters(payload.filters)), metadata: Set(payload.metadata), secret: Set(trim_to_option(payload.secret)), @@ -461,7 +482,9 @@ pub async fn send_subscription_digest( Json(payload): Json, ) -> Result { let actor = check_auth(&headers)?; - let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?; + let summary = + subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")) + .await?; admin_audit::log_event( &ctx, @@ -664,17 +687,29 @@ pub fn routes() -> Routes { .add("/post-revisions", get(list_post_revisions)) .add("/post-revisions/{id}", get(get_post_revision)) .add("/post-revisions/{id}/restore", post(restore_post_revision)) - .add("/subscriptions", get(list_subscriptions).post(create_subscription)) - .add("/subscriptions/deliveries", get(list_subscription_deliveries)) + .add( + "/subscriptions", + get(list_subscriptions).post(create_subscription), + ) + .add( + "/subscriptions/deliveries", + get(list_subscription_deliveries), + ) .add("/subscriptions/digest", post(send_subscription_digest)) - .add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription)) + .add( + "/subscriptions/{id}", + patch(update_subscription).delete(delete_subscription), + ) .add("/subscriptions/{id}/test", post(test_subscription)) .add("/workers/overview", get(workers_overview)) .add("/workers/jobs", get(list_worker_jobs)) .add("/workers/jobs/{id}", get(get_worker_job)) .add("/workers/jobs/{id}/cancel", post(cancel_worker_job)) .add("/workers/jobs/{id}/retry", post(retry_worker_job)) - .add("/workers/tasks/retry-deliveries", post(run_retry_deliveries_job)) + .add( + "/workers/tasks/retry-deliveries", + post(run_retry_deliveries_job), + ) .add("/workers/tasks/digest", post(run_digest_worker_job)) .add("/site-backup/export", get(export_site_backup)) .add("/site-backup/import", post(import_site_backup)) diff --git a/backend/src/controllers/ai.rs b/backend/src/controllers/ai.rs index f04c32a..9c1b40d 100644 --- a/backend/src/controllers/ai.rs +++ b/backend/src/controllers/ai.rs @@ -4,8 +4,8 @@ use async_stream::stream; use axum::{ body::{Body, Bytes}, http::{ - header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, HeaderMap, HeaderValue, + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, }, }; use chrono::{DateTime, Utc}; diff --git a/backend/src/controllers/comment.rs b/backend/src/controllers/comment.rs index 263a634..e12f49e 100644 --- a/backend/src/controllers/comment.rs +++ b/backend/src/controllers/comment.rs @@ -8,10 +8,11 @@ use std::collections::BTreeMap; use std::net::SocketAddr; use axum::{ - extract::{rejection::ExtensionRejection, ConnectInfo}, - http::{header, HeaderMap}, + extract::{ConnectInfo, rejection::ExtensionRejection}, + http::{HeaderMap, header}, }; +use crate::controllers::admin::check_auth; use crate::models::_entities::{ comments::{ActiveModel, Column, Entity, Model}, posts, @@ -21,7 +22,6 @@ use crate::services::{ comment_guard::{self, CommentGuardInput}, notifications, }; -use crate::controllers::admin::check_auth; const ARTICLE_SCOPE: &str = "article"; const PARAGRAPH_SCOPE: &str = "paragraph"; diff --git a/backend/src/controllers/content_analytics.rs b/backend/src/controllers/content_analytics.rs index 58b3a54..a6083fb 100644 --- a/backend/src/controllers/content_analytics.rs +++ b/backend/src/controllers/content_analytics.rs @@ -38,8 +38,15 @@ pub async fn record( headers: HeaderMap, Json(payload): Json, ) -> Result { - let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers); - if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() { + let mut request_context = + analytics::content_request_context_from_headers(&payload.path, &headers); + if payload + .referrer + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { request_context.referrer = payload.referrer; } diff --git a/backend/src/controllers/friend_link.rs b/backend/src/controllers/friend_link.rs index 9816f60..b302054 100644 --- a/backend/src/controllers/friend_link.rs +++ b/backend/src/controllers/friend_link.rs @@ -127,7 +127,9 @@ pub async fn update( "friend_link.update", "friend_link", Some(item.id.to_string()), - item.site_name.clone().or_else(|| Some(item.site_url.clone())), + item.site_name + .clone() + .or_else(|| Some(item.site_url.clone())), Some(serde_json::json!({ "status": item.status })), ) .await?; @@ -142,7 +144,10 @@ pub async fn remove( ) -> Result { let actor = check_auth(&headers)?; let item = load_item(&ctx, id).await?; - let label = item.site_name.clone().or_else(|| Some(item.site_url.clone())); + let label = item + .site_name + .clone() + .or_else(|| Some(item.site_url.clone())); item.delete(&ctx.db).await?; admin_audit::log_event( &ctx, diff --git a/backend/src/controllers/mod.rs b/backend/src/controllers/mod.rs index 8f45112..d8dbd9a 100644 --- a/backend/src/controllers/mod.rs +++ b/backend/src/controllers/mod.rs @@ -1,12 +1,12 @@ pub mod admin; pub mod admin_api; -pub mod admin_taxonomy; pub mod admin_ops; +pub mod admin_taxonomy; pub mod ai; pub mod auth; -pub mod content_analytics; pub mod category; pub mod comment; +pub mod content_analytics; pub mod friend_link; pub mod health; pub mod post; diff --git a/backend/src/controllers/review.rs b/backend/src/controllers/review.rs index 2b7c50a..79312b2 100644 --- a/backend/src/controllers/review.rs +++ b/backend/src/controllers/review.rs @@ -14,7 +14,11 @@ use crate::{ fn is_public_review_status(status: Option<&str>) -> bool { matches!( - status.unwrap_or_default().trim().to_ascii_lowercase().as_str(), + status + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), "published" | "completed" | "done" ) } @@ -67,7 +71,9 @@ pub async fn get_one( let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; match review { - Some(r) if include_private || is_public_review_status(r.status.as_deref()) => format::json(r), + Some(r) if include_private || is_public_review_status(r.status.as_deref()) => { + format::json(r) + } Some(_) => Err(Error::NotFound), None => Err(Error::NotFound), } diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs index f4c99d6..709c048 100644 --- a/backend/src/controllers/site_settings.rs +++ b/backend/src/controllers/site_settings.rs @@ -4,6 +4,7 @@ use axum::http::HeaderMap; use loco_rs::prelude::*; +use sha2::{Digest, Sha256}; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -89,6 +90,12 @@ pub struct SiteSettingsPayload { pub tech_stack: Option>, #[serde(default, alias = "musicPlaylist")] pub music_playlist: Option>, + #[serde(default, alias = "musicEnabled")] + pub music_enabled: Option, + #[serde(default, alias = "maintenanceModeEnabled")] + pub maintenance_mode_enabled: Option, + #[serde(default, alias = "maintenanceAccessCode")] + pub maintenance_access_code: Option, #[serde(default, alias = "aiEnabled")] pub ai_enabled: Option, #[serde(default, alias = "paragraphCommentsEnabled")] @@ -199,6 +206,7 @@ pub struct PublicSiteSettingsResponse { pub location: Option, pub tech_stack: Option, pub music_playlist: Option, + pub music_enabled: bool, pub ai_enabled: bool, pub paragraph_comments_enabled: bool, pub comment_verification_mode: String, @@ -217,6 +225,31 @@ pub struct PublicSiteSettingsResponse { pub seo_wechat_share_qr_enabled: bool, } +#[derive(Clone, Debug, Default, Deserialize)] +pub struct MaintenanceAccessTokenPayload { + #[serde(default, alias = "accessToken")] + pub access_token: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct MaintenanceVerifyPayload { + #[serde(default)] + pub code: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MaintenanceAccessStatusResponse { + pub maintenance_mode_enabled: bool, + pub access_granted: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MaintenanceVerifyResponse { + pub maintenance_mode_enabled: bool, + pub access_granted: bool, + pub access_token: Option, +} + #[derive(Clone, Debug, Serialize)] pub struct HomeCategorySummary { pub id: i32, @@ -252,6 +285,51 @@ fn normalize_optional_int(value: Option, min: i32, max: i32) -> Option value.map(|item| item.clamp(min, max)) } +fn maintenance_mode_enabled(model: &Model) -> bool { + model.maintenance_mode_enabled.unwrap_or(false) +} + +fn maintenance_access_code(model: &Model) -> Option { + normalize_optional_string(model.maintenance_access_code.clone()) +} + +fn maintenance_access_token_from_secret(secret: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"termi-maintenance-access:v1:"); + hasher.update(secret.as_bytes()); + let digest = hasher.finalize(); + + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() +} + +fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool { + let Some(candidate) = token.and_then(|item| { + let trimmed = item.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) else { + return false; + }; + + let Some(secret) = maintenance_access_code(model) else { + return false; + }; + + candidate == maintenance_access_token_from_secret(&secret) +} + +fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option { + let candidate = code.and_then(|item| { + let trimmed = item.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + })?; + let secret = maintenance_access_code(model)?; + + (candidate == secret).then(|| maintenance_access_token_from_secret(&secret)) +} + fn normalize_notification_channel_type(value: Option) -> Option { value.and_then(|item| { let normalized = item.trim().to_ascii_lowercase(); @@ -272,7 +350,7 @@ pub(crate) fn default_subscription_popup_title() -> String { } pub(crate) fn default_subscription_popup_description() -> String { - "有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。".to_string() + "有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份。".to_string() } pub(crate) fn default_subscription_popup_delay_seconds() -> i32 { @@ -555,6 +633,15 @@ impl SiteSettingsPayload { if let Some(music_playlist) = self.music_playlist { item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist))); } + if let Some(music_enabled) = self.music_enabled { + item.music_enabled = Some(music_enabled); + } + if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled { + item.maintenance_mode_enabled = Some(maintenance_mode_enabled); + } + if self.maintenance_access_code.is_some() { + item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code); + } if let Some(ai_enabled) = self.ai_enabled { item.ai_enabled = Some(ai_enabled); } @@ -752,10 +839,10 @@ fn default_payload() -> SiteSettingsPayload { site_name: Some("InitCool".to_string()), site_short_name: Some("Termi".to_string()), site_url: Some("https://init.cool".to_string()), - site_title: Some("InitCool - 终端风格的内容平台".to_string()), - site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()), - hero_title: Some("欢迎来到我的极客终端博客".to_string()), - hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()), + site_title: Some("InitCool · 技术笔记与内容档案".to_string()), + site_description: Some("围绕开发实践、产品观察与长期积累整理的中文内容站。".to_string()), + hero_title: Some("欢迎来到 InitCool".to_string()), + hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()), owner_name: Some("InitCool".to_string()), owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()), owner_bio: Some( @@ -813,6 +900,9 @@ fn default_payload() -> SiteSettingsPayload { description: Some("节奏更明显一点,适合切换阅读状态。".to_string()), }, ]), + music_enabled: Some(true), + maintenance_mode_enabled: Some(false), + maintenance_access_code: None, ai_enabled: Some(false), paragraph_comments_enabled: Some(true), comment_verification_mode: Some( @@ -923,6 +1013,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse { location: model.location, tech_stack: model.tech_stack, music_playlist: model.music_playlist, + music_enabled: model.music_enabled.unwrap_or(true), ai_enabled: model.ai_enabled.unwrap_or(false), paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true), comment_verification_mode: comment_verification_mode.as_str().to_string(), @@ -1019,6 +1110,50 @@ pub async fn show(State(ctx): State) -> Result { format::json(public_response(load_current(&ctx).await?)) } +#[debug_handler] +pub async fn maintenance_status( + State(ctx): State, + Json(params): Json, +) -> Result { + let current = load_current(&ctx).await?; + let enabled = maintenance_mode_enabled(¤t); + let access_granted = if enabled { + validate_maintenance_access_token(¤t, params.access_token.as_deref()) + } else { + true + }; + + format::json(MaintenanceAccessStatusResponse { + maintenance_mode_enabled: enabled, + access_granted, + }) +} + +#[debug_handler] +pub async fn maintenance_verify( + State(ctx): State, + Json(params): Json, +) -> Result { + let current = load_current(&ctx).await?; + let enabled = maintenance_mode_enabled(¤t); + + if !enabled { + return format::json(MaintenanceVerifyResponse { + maintenance_mode_enabled: false, + access_granted: true, + access_token: None, + }); + } + + let access_token = verify_maintenance_access_code(¤t, params.code.as_deref()); + + format::json(MaintenanceVerifyResponse { + maintenance_mode_enabled: true, + access_granted: access_token.is_some(), + access_token, + }) +} + #[debug_handler] pub async fn update( headers: HeaderMap, @@ -1039,6 +1174,8 @@ pub fn routes() -> Routes { Routes::new() .prefix("api/site_settings/") .add("home", get(home)) + .add("maintenance/status", post(maintenance_status)) + .add("maintenance/verify", post(maintenance_verify)) .add("/", get(show)) .add("/", put(update)) .add("/", patch(update)) diff --git a/backend/src/controllers/subscription.rs b/backend/src/controllers/subscription.rs index 3570fb4..eab382e 100644 --- a/backend/src/controllers/subscription.rs +++ b/backend/src/controllers/subscription.rs @@ -33,6 +33,26 @@ pub struct PublicBrowserPushSubscriptionPayload { pub captcha_answer: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct PublicCombinedSubscriptionPayload { + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub email: Option, + #[serde(default, alias = "displayName")] + pub display_name: Option, + #[serde(default)] + pub subscription: Option, + #[serde(default)] + pub source: Option, + #[serde(default, alias = "turnstileToken")] + pub turnstile_token: Option, + #[serde(default, alias = "captchaToken")] + pub captcha_token: Option, + #[serde(default, alias = "captchaAnswer")] + pub captcha_answer: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct SubscriptionTokenPayload { pub token: String, @@ -63,6 +83,21 @@ pub struct PublicSubscriptionResponse { pub message: String, } +#[derive(Clone, Debug, Serialize)] +pub struct PublicCombinedSubscriptionItemResponse { + pub channel_type: String, + pub subscription_id: i32, + pub status: String, + pub requires_confirmation: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicCombinedSubscriptionResponse { + pub ok: bool, + pub channels: Vec, + pub message: String, +} + #[derive(Clone, Debug, Serialize)] pub struct SubscriptionManageResponse { pub ok: bool, @@ -89,6 +124,30 @@ fn public_browser_push_metadata( }) } +fn normalize_public_subscription_channels(channels: &[String]) -> Vec { + let mut normalized = Vec::new(); + + for raw in channels { + let Some(channel) = ({ + match raw.trim().to_ascii_lowercase().as_str() { + "email" | "mail" => Some("email"), + "browser" | "browser-push" | "browser_push" | "webpush" | "web-push" => { + Some("browser_push") + } + _ => None, + } + }) else { + continue; + }; + + if !normalized.iter().any(|value| value == channel) { + normalized.push(channel.to_string()); + } + } + + normalized +} + async fn verify_subscription_human_check( settings: &crate::models::_entities::site_settings::Model, turnstile_token: Option<&str>, @@ -119,11 +178,7 @@ pub async fn subscribe( ) -> Result { let email = payload.email.trim().to_ascii_lowercase(); let client_ip = abuse_guard::detect_client_ip(&headers); - abuse_guard::enforce_public_scope( - "subscription", - client_ip.as_deref(), - Some(&email), - )?; + abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(&email))?; let settings = crate::controllers::site_settings::load_current(&ctx).await?; verify_subscription_human_check( &settings, @@ -186,7 +241,9 @@ pub async fn subscribe_browser_push( .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()))? + .ok_or_else(|| { + Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()) + })? .to_string(); let client_ip = abuse_guard::detect_client_ip(&headers); let user_agent = headers @@ -196,15 +253,11 @@ pub async fn subscribe_browser_push( .filter(|value| !value.is_empty()) .map(ToString::to_string); - abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?; - verify_subscription_human_check( - &settings, - payload.turnstile_token.as_deref(), - payload.captcha_token.as_deref(), - payload.captcha_answer.as_deref(), + abuse_guard::enforce_public_scope( + "browser-push-subscription", client_ip.as_deref(), - ) - .await?; + Some(&endpoint), + )?; let result = subscriptions::create_public_web_push_subscription( &ctx, @@ -240,6 +293,174 @@ pub async fn subscribe_browser_push( }) } +#[debug_handler] +pub async fn subscribe_combined( + State(ctx): State, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> Result { + let selected_channels = normalize_public_subscription_channels(&payload.channels); + if selected_channels.is_empty() { + return Err(Error::BadRequest("请至少选择一种订阅方式".to_string())); + } + + let wants_email = selected_channels.iter().any(|value| value == "email"); + let wants_browser_push = selected_channels + .iter() + .any(|value| value == "browser_push"); + + let settings = crate::controllers::site_settings::load_current(&ctx).await?; + let client_ip = abuse_guard::detect_client_ip(&headers); + + let normalized_email = payload + .email + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()); + + if wants_email { + let email = normalized_email + .as_deref() + .ok_or_else(|| Error::BadRequest("请选择邮箱订阅后填写邮箱地址".to_string()))?; + abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(email))?; + } + + let normalized_browser_subscription = if wants_browser_push { + if !crate::services::web_push::is_enabled(&settings) { + return Err(Error::BadRequest("浏览器推送未启用".to_string())); + } + + let subscription = payload + .subscription + .clone() + .ok_or_else(|| Error::BadRequest("缺少浏览器推送订阅信息".to_string()))?; + let endpoint = subscription + .get("endpoint") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()) + })? + .to_string(); + + abuse_guard::enforce_public_scope( + "browser-push-subscription", + client_ip.as_deref(), + Some(&endpoint), + )?; + + Some(subscription) + } else { + None + }; + + if wants_email { + verify_subscription_human_check( + &settings, + payload.turnstile_token.as_deref(), + payload.captcha_token.as_deref(), + payload.captcha_answer.as_deref(), + client_ip.as_deref(), + ) + .await?; + } + + let user_agent = headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let mut items = Vec::new(); + let mut message_parts = Vec::new(); + + if let Some(subscription) = normalized_browser_subscription { + let browser_result = subscriptions::create_public_web_push_subscription( + &ctx, + subscription.clone(), + Some(public_browser_push_metadata( + payload.source.clone(), + subscription, + user_agent, + )), + ) + .await?; + + admin_audit::log_event( + &ctx, + None, + "subscription.public.web_push.active", + "subscription", + Some(browser_result.subscription.id.to_string()), + Some(browser_result.subscription.target.clone()), + Some(serde_json::json!({ + "channel_type": browser_result.subscription.channel_type, + "status": browser_result.subscription.status, + })), + ) + .await?; + + message_parts.push(browser_result.message.clone()); + items.push(PublicCombinedSubscriptionItemResponse { + channel_type: browser_result.subscription.channel_type, + subscription_id: browser_result.subscription.id, + status: browser_result.subscription.status, + requires_confirmation: false, + }); + } + + if wants_email { + let email_result = subscriptions::create_public_email_subscription( + &ctx, + normalized_email.as_deref().unwrap_or_default(), + payload.display_name, + Some(public_subscription_metadata(payload.source)), + ) + .await?; + + admin_audit::log_event( + &ctx, + None, + if email_result.requires_confirmation { + "subscription.public.pending" + } else { + "subscription.public.active" + }, + "subscription", + Some(email_result.subscription.id.to_string()), + Some(email_result.subscription.target.clone()), + Some(serde_json::json!({ + "channel_type": email_result.subscription.channel_type, + "status": email_result.subscription.status, + })), + ) + .await?; + + message_parts.push(email_result.message.clone()); + items.push(PublicCombinedSubscriptionItemResponse { + channel_type: email_result.subscription.channel_type, + subscription_id: email_result.subscription.id, + status: email_result.subscription.status, + requires_confirmation: email_result.requires_confirmation, + }); + } + + let message = if message_parts.is_empty() { + "订阅请求已处理。".to_string() + } else { + message_parts.join(" ") + }; + + format::json(PublicCombinedSubscriptionResponse { + ok: true, + channels: items, + message, + }) +} + #[debug_handler] pub async fn confirm( State(ctx): State, @@ -333,6 +554,7 @@ pub fn routes() -> Routes { Routes::new() .prefix("/api/subscriptions") .add("/", post(subscribe)) + .add("/combined", post(subscribe_combined)) .add("/browser-push", post(subscribe_browser_push)) .add("/confirm", post(confirm)) .add("/manage", get(manage).patch(update_manage)) diff --git a/backend/src/fixtures/comments.yaml b/backend/src/fixtures/comments.yaml index 948fa75..47bf3f5 100644 --- a/backend/src/fixtures/comments.yaml +++ b/backend/src/fixtures/comments.yaml @@ -2,35 +2,35 @@ pid: 1 author: "林川" email: "linchuan@example.com" - content: "这篇做长文测试很合适,段落密度和古文节奏都不错。" + content: "这篇读起来很稳,段落密度和古文节奏都很舒服。" approved: true - id: 2 pid: 1 author: "阿青" email: "aqing@example.com" - content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。" + content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。" approved: true - id: 3 pid: 2 author: "周宁" email: "zhouling@example.com" - content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。" + content: "这一段关于南岩和琼台的描写很好,细节很有画面感。" approved: true - id: 4 pid: 3 author: "顾远" email: "guyuan@example.com" - content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。" + content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。" approved: true - id: 5 pid: 4 author: "清嘉" email: "qingjia@example.com" - content: "黄山记的序文很适合测试首屏摘要生成。" + content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。" approved: true - id: 6 diff --git a/backend/src/fixtures/posts.yaml b/backend/src/fixtures/posts.yaml index 74a0c78..cea2708 100644 --- a/backend/src/fixtures/posts.yaml +++ b/backend/src/fixtures/posts.yaml @@ -10,7 +10,7 @@ 自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。 骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。 - excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。" + excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。" category: "古籍游记" published: true pinned: true @@ -18,7 +18,7 @@ - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 - id: 2 pid: 2 @@ -40,7 +40,7 @@ - 徐霞客 - 游记 - 太和山 - - 长文测试 + - 山水游记 - id: 3 pid: 3 @@ -54,7 +54,7 @@ 余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。 三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。 - excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。" + excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。" category: "古籍游记" published: true pinned: false @@ -62,7 +62,7 @@ - 徐霞客 - 恒山 - 悬空寺 - - 长文测试 + - 山水游记 - id: 4 pid: 4 @@ -84,7 +84,7 @@ - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 - id: 5 pid: 5 @@ -98,7 +98,7 @@ 憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。 清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。 - excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。" + excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。" category: "古籍游记" published: true pinned: false @@ -106,4 +106,4 @@ - 钱谦益 - 黄山 - 游记 - - 长文测试 + - 山水游记 diff --git a/backend/src/fixtures/reviews.yaml b/backend/src/fixtures/reviews.yaml index b5d2192..19648d8 100644 --- a/backend/src/fixtures/reviews.yaml +++ b/backend/src/fixtures/reviews.yaml @@ -34,7 +34,7 @@ rating: 5 review_date: "2024-02-18" status: "published" - description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。" + description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。" tags: ["经济", "非虚构", "中国"] cover: "/review-covers/placed-within.svg" diff --git a/backend/src/fixtures/site_settings.yaml b/backend/src/fixtures/site_settings.yaml index 6fd0d65..6bd3d4b 100644 --- a/backend/src/fixtures/site_settings.yaml +++ b/backend/src/fixtures/site_settings.yaml @@ -2,10 +2,10 @@ site_name: "InitCool" site_short_name: "Termi" site_url: "https://init.cool" - site_title: "InitCool · 中文长文与 AI 搜索实验站" - site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。" - hero_title: "欢迎来到我的中文内容实验站" - hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验" + site_title: "InitCool · 技术笔记与内容档案" + site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。" + hero_title: "欢迎来到 InitCool" + hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。" owner_name: "InitCool" owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" @@ -43,6 +43,9 @@ cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" accent_color: "#375a7f" description: "节奏更明显一点,适合切换阅读状态。" + music_enabled: true + maintenance_mode_enabled: false + maintenance_access_code: null ai_enabled: false paragraph_comments_enabled: true comment_verification_mode: "captcha" diff --git a/backend/src/initializers/content_sync.rs b/backend/src/initializers/content_sync.rs index 8da6f19..cf9b945 100644 --- a/backend/src/initializers/content_sync.rs +++ b/backend/src/initializers/content_sync.rs @@ -108,19 +108,24 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { }) .filter(|items| !items.is_empty()) .map(serde_json::Value::Array); + let music_enabled = seed["music_enabled"].as_bool().or(Some(true)); + let maintenance_mode_enabled = seed["maintenance_mode_enabled"].as_bool().or(Some(false)); + let maintenance_access_code = as_optional_string(&seed["maintenance_access_code"]); let comment_verification_mode = as_optional_string(&seed["comment_verification_mode"]); let subscription_verification_mode = as_optional_string(&seed["subscription_verification_mode"]); - let comment_turnstile_enabled = seed["comment_turnstile_enabled"] - .as_bool() - .or(comment_verification_mode - .as_deref() - .map(|value| value.eq_ignore_ascii_case("turnstile"))); - let subscription_turnstile_enabled = seed["subscription_turnstile_enabled"] - .as_bool() - .or(subscription_verification_mode - .as_deref() - .map(|value| value.eq_ignore_ascii_case("turnstile"))); + let comment_turnstile_enabled = + seed["comment_turnstile_enabled"] + .as_bool() + .or(comment_verification_mode + .as_deref() + .map(|value| value.eq_ignore_ascii_case("turnstile"))); + let subscription_turnstile_enabled = + seed["subscription_turnstile_enabled"] + .as_bool() + .or(subscription_verification_mode + .as_deref() + .map(|value| value.eq_ignore_ascii_case("turnstile"))); let existing = site_settings::Entity::find() .order_by_asc(site_settings::Column::Id) @@ -182,6 +187,15 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if existing.music_playlist.is_none() { model.music_playlist = Set(music_playlist); } + if existing.music_enabled.is_none() { + model.music_enabled = Set(music_enabled); + } + if existing.maintenance_mode_enabled.is_none() { + model.maintenance_mode_enabled = Set(maintenance_mode_enabled); + } + if is_blank(&existing.maintenance_access_code) { + model.maintenance_access_code = Set(maintenance_access_code.clone()); + } if existing.ai_enabled.is_none() { model.ai_enabled = Set(seed["ai_enabled"].as_bool()); } @@ -261,6 +275,9 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { location: Set(as_optional_string(&seed["location"])), tech_stack: Set(tech_stack), music_playlist: Set(music_playlist), + music_enabled: Set(music_enabled), + maintenance_mode_enabled: Set(maintenance_mode_enabled), + maintenance_access_code: Set(maintenance_access_code), ai_enabled: Set(seed["ai_enabled"].as_bool()), paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"] .as_bool() diff --git a/backend/src/models/_entities/prelude.rs b/backend/src/models/_entities/prelude.rs index 4de8aa5..58f74c6 100644 --- a/backend/src/models/_entities/prelude.rs +++ b/backend/src/models/_entities/prelude.rs @@ -1,7 +1,7 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 -pub use super::ai_chunks::Entity as AiChunks; pub use super::admin_audit_logs::Entity as AdminAuditLogs; +pub use super::ai_chunks::Entity as AiChunks; pub use super::categories::Entity as Categories; pub use super::comment_blacklist::Entity as CommentBlacklist; pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLogs; diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs index bef8940..15a0627 100644 --- a/backend/src/models/_entities/site_settings.rs +++ b/backend/src/models/_entities/site_settings.rs @@ -30,6 +30,10 @@ pub struct Model { pub tech_stack: Option, #[sea_orm(column_type = "JsonBinary", nullable)] pub music_playlist: Option, + pub music_enabled: Option, + pub maintenance_mode_enabled: Option, + #[sea_orm(column_type = "Text", nullable)] + pub maintenance_access_code: Option, pub ai_enabled: Option, pub paragraph_comments_enabled: Option, pub comment_turnstile_enabled: Option, diff --git a/backend/src/models/users.rs b/backend/src/models/users.rs index 2292ded..e7d690e 100644 --- a/backend/src/models/users.rs +++ b/backend/src/models/users.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use chrono::{offset::Local, Duration}; +use chrono::{Duration, offset::Local}; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; use serde_json::Map; diff --git a/backend/src/services/abuse_guard.rs b/backend/src/services/abuse_guard.rs index 7ada02a..258204e 100644 --- a/backend/src/services/abuse_guard.rs +++ b/backend/src/services/abuse_guard.rs @@ -3,12 +3,9 @@ use std::{ sync::{Mutex, OnceLock}, }; -use axum::http::{header, HeaderMap, StatusCode}; +use axum::http::{HeaderMap, StatusCode, header}; use chrono::{DateTime, Duration, Utc}; -use loco_rs::{ - controller::ErrorDetail, - prelude::*, -}; +use loco_rs::{controller::ErrorDetail, prelude::*}; const DEFAULT_WINDOW_SECONDS: i64 = 5 * 60; const DEFAULT_MAX_REQUESTS_PER_WINDOW: u32 = 45; diff --git a/backend/src/services/admin_audit.rs b/backend/src/services/admin_audit.rs index 3f4835d..511ffb3 100644 --- a/backend/src/services/admin_audit.rs +++ b/backend/src/services/admin_audit.rs @@ -1,33 +1,15 @@ -use loco_rs::prelude::*; -use sea_orm::{ActiveModelTrait, Set}; +use loco_rs::prelude::{AppContext, Result}; -use crate::{ - controllers::admin::AdminIdentity, - models::_entities::admin_audit_logs, -}; +use crate::controllers::admin::AdminIdentity; pub async fn log_event( - ctx: &AppContext, - actor: Option<&AdminIdentity>, - action: &str, - target_type: &str, - target_id: Option, - target_label: Option, - metadata: Option, + _ctx: &AppContext, + _actor: Option<&AdminIdentity>, + _action: &str, + _target_type: &str, + _target_id: Option, + _target_label: Option, + _metadata: Option, ) -> Result<()> { - admin_audit_logs::ActiveModel { - actor_username: Set(actor.map(|item| item.username.clone())), - actor_email: Set(actor.and_then(|item| item.email.clone())), - actor_source: Set(actor.map(|item| item.source.clone())), - action: Set(action.to_string()), - target_type: Set(target_type.to_string()), - target_id: Set(target_id), - target_label: Set(target_label), - metadata: Set(metadata), - ..Default::default() - } - .insert(&ctx.db) - .await?; - Ok(()) } diff --git a/backend/src/services/analytics.rs b/backend/src/services/analytics.rs index b23ee0e..1603c14 100644 --- a/backend/src/services/analytics.rs +++ b/backend/src/services/analytics.rs @@ -246,9 +246,7 @@ fn normalize_tracking_source_token(value: Option) -> String { "chatgpt-search".to_string() } value if value.contains("perplexity") => "perplexity".to_string(), - value if value.contains("copilot") || value.contains("bing") => { - "copilot-bing".to_string() - } + value if value.contains("copilot") || value.contains("bing") => "copilot-bing".to_string(), value if value.contains("gemini") => "gemini".to_string(), value if value.contains("google") => "google".to_string(), value if value.contains("claude") => "claude".to_string(), @@ -289,11 +287,10 @@ fn sorted_referrer_buckets( let mut items = breakdown .iter() .filter_map(|(referrer, count)| { - predicate(referrer) - .then(|| AnalyticsReferrerBucket { - referrer: referrer.clone(), - count: *count, - }) + predicate(referrer).then(|| AnalyticsReferrerBucket { + referrer: referrer.clone(), + count: *count, + }) }) .collect::>(); @@ -648,8 +645,11 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result Result Result 0).then(|| total_duration / duration_count as f64), + avg_duration_ms: (duration_count > 0) + .then(|| total_duration / duration_count as f64), }, ) .collect::>(); @@ -1085,8 +1097,22 @@ pub async fn build_public_content_windows( .await?; Ok(vec![ - summarize_public_content_window(&events, &post_titles, now - Duration::hours(24), "24h", "24h", 1), - summarize_public_content_window(&events, &post_titles, now - Duration::days(7), "7d", "7d", 7), + summarize_public_content_window( + &events, + &post_titles, + now - Duration::hours(24), + "24h", + "24h", + 1, + ), + summarize_public_content_window( + &events, + &post_titles, + now - Duration::days(7), + "7d", + "7d", + 7, + ), summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30), ]) } @@ -1233,7 +1259,8 @@ fn summarize_public_content_window( } else { 0.0 }, - avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64), + avg_duration_ms: (duration_count > 0) + .then(|| total_duration / duration_count as f64), }, ) .collect::>(); diff --git a/backend/src/services/content.rs b/backend/src/services/content.rs index 7c82559..84e82c9 100644 --- a/backend/src/services/content.rs +++ b/backend/src/services/content.rs @@ -30,16 +30,23 @@ struct MarkdownFrontmatter { deserialize_with = "deserialize_optional_string_list" )] categories: Option>, - #[serde(default, deserialize_with = "deserialize_optional_string_list")] + #[serde( + default, + alias = "tag", + deserialize_with = "deserialize_optional_string_list" + )] tags: Option>, post_type: Option, image: Option, images: Option>, pinned: Option, + #[serde(alias = "Hidden")] + hidden: Option, published: Option, draft: Option, status: Option, visibility: Option, + #[serde(alias = "date")] publish_at: Option, unpublish_at: Option, canonical_url: Option, @@ -233,6 +240,18 @@ fn resolve_post_status(frontmatter: &MarkdownFrontmatter) -> String { } } +fn resolve_post_visibility(frontmatter: &MarkdownFrontmatter) -> String { + if let Some(visibility) = trim_to_option(frontmatter.visibility.clone()) { + return normalize_post_visibility(Some(&visibility)); + } + + if frontmatter.hidden.unwrap_or(false) { + POST_VISIBILITY_UNLISTED.to_string() + } else { + POST_VISIBILITY_PUBLIC.to_string() + } +} + pub fn effective_post_state( status: &str, publish_at: Option>, @@ -500,7 +519,7 @@ pub fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Res images: normalize_string_list(frontmatter.images.clone()), pinned: frontmatter.pinned.unwrap_or(false), status: resolve_post_status(&frontmatter), - visibility: normalize_post_visibility(frontmatter.visibility.as_deref()), + visibility: resolve_post_visibility(&frontmatter), publish_at: format_frontmatter_datetime(parse_frontmatter_datetime( frontmatter.publish_at.clone(), )), @@ -1152,3 +1171,39 @@ pub async fn import_markdown_documents( Ok(imported) } + +#[cfg(test)] +mod tests { + use super::{POST_VISIBILITY_UNLISTED, parse_markdown_source}; + + #[test] + fn parse_markdown_source_supports_hugo_aliases() { + let markdown = r#"--- +title: "Linux Shell" +date: 2022-05-21T10:02:09+08:00 +draft: false +Hidden: true +slug: linux-shell +categories: + - Linux +tag: + - Linux + - Shell +--- + +# Linux Shell +"#; + + let post = parse_markdown_source("linux-shell", markdown, "content/posts/linux-shell.md") + .expect("markdown should parse"); + + assert_eq!(post.slug, "linux-shell"); + assert_eq!(post.category.as_deref(), Some("Linux")); + assert_eq!(post.tags, vec!["Linux", "Shell"]); + assert_eq!(post.visibility, POST_VISIBILITY_UNLISTED); + assert_eq!( + post.publish_at.as_deref(), + Some("2022-05-21T02:02:09+00:00") + ); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index b5d8692..e3a4abb 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,5 +1,5 @@ -pub mod admin_audit; pub mod abuse_guard; +pub mod admin_audit; pub mod ai; pub mod analytics; pub mod backups; diff --git a/backend/src/services/notifications.rs b/backend/src/services/notifications.rs index 4bdc06a..23312d0 100644 --- a/backend/src/services/notifications.rs +++ b/backend/src/services/notifications.rs @@ -1,9 +1,9 @@ -use loco_rs::prelude::*; use crate::{ controllers::site_settings, models::_entities::{comments, friend_links, site_settings as site_settings_model}, services::subscriptions, }; +use loco_rs::prelude::*; fn notification_channel_type(settings: &site_settings_model::Model) -> &'static str { match settings @@ -71,10 +71,16 @@ pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) { }); let text = format!( "收到一条新的评论。\n\n文章:{}\n作者:{}\n范围:{}\n状态:{}\n摘要:{}", - item.post_slug.clone().unwrap_or_else(|| "未知文章".to_string()), + item.post_slug + .clone() + .unwrap_or_else(|| "未知文章".to_string()), item.author.clone().unwrap_or_else(|| "匿名".to_string()), item.scope, - if item.approved.unwrap_or(false) { "已通过" } else { "待审核" }, + if item.approved.unwrap_or(false) { + "已通过" + } else { + "待审核" + }, excerpt(item.content.as_deref(), 200).unwrap_or_else(|| "无".to_string()), ); @@ -135,9 +141,13 @@ pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model }); let text = format!( "收到新的友链申请。\n\n站点:{}\n链接:{}\n分类:{}\n状态:{}\n描述:{}", - item.site_name.clone().unwrap_or_else(|| "未命名站点".to_string()), + item.site_name + .clone() + .unwrap_or_else(|| "未命名站点".to_string()), item.site_url, - item.category.clone().unwrap_or_else(|| "未分类".to_string()), + item.category + .clone() + .unwrap_or_else(|| "未分类".to_string()), item.status.clone().unwrap_or_else(|| "pending".to_string()), item.description.clone().unwrap_or_else(|| "无".to_string()), ); diff --git a/backend/src/services/storage.rs b/backend/src/services/storage.rs index 7282277..77724a5 100644 --- a/backend/src/services/storage.rs +++ b/backend/src/services/storage.rs @@ -1,5 +1,5 @@ use aws_config::BehaviorVersion; -use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client}; +use aws_sdk_s3::{Client, config::Credentials, primitives::ByteStream}; use loco_rs::prelude::*; use sea_orm::{EntityTrait, QueryOrder}; use std::path::{Path, PathBuf}; diff --git a/backend/src/services/subscriptions.rs b/backend/src/services/subscriptions.rs index cd017dd..6bb8cc1 100644 --- a/backend/src/services/subscriptions.rs +++ b/backend/src/services/subscriptions.rs @@ -243,11 +243,18 @@ fn normalize_browser_push_subscription(raw: Value) -> Result { serde_json::to_value(subscription).map_err(Into::into) } -fn merge_browser_push_metadata(existing: Option<&Value>, incoming: Option, subscription: Value) -> Value { +fn merge_browser_push_metadata( + existing: Option<&Value>, + incoming: Option, + subscription: Value, +) -> Value { let mut object = merge_metadata(existing, incoming) .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); - object.insert("kind".to_string(), Value::String("browser-push".to_string())); + object.insert( + "kind".to_string(), + Value::String("browser-push".to_string()), + ); object.insert("subscription".to_string(), subscription); Value::Object(object) } @@ -280,7 +287,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec { if let Some(items) = payload.get(key).and_then(Value::as_array) { values.extend( - items.iter() + items + .iter() .filter_map(Value::as_str) .map(normalize_string) .filter(|item| !item.is_empty()), @@ -298,7 +306,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec { if let Some(items) = post.get(key).and_then(Value::as_array) { values.extend( - items.iter() + items + .iter() .filter_map(Value::as_str) .map(normalize_string) .filter(|item| !item.is_empty()), @@ -410,19 +419,31 @@ pub fn to_public_subscription_view(item: &subscriptions::Model) -> PublicSubscri } } -fn subscription_links(item: &subscriptions::Model, site_context: &SiteContext) -> (Option, Option, Option) { - let manage_url = item - .manage_token - .as_deref() - .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/manage", token)); - let unsubscribe_url = item - .manage_token - .as_deref() - .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/unsubscribe", token)); - let confirm_url = item - .confirm_token - .as_deref() - .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/confirm", token)); +fn subscription_links( + item: &subscriptions::Model, + site_context: &SiteContext, +) -> (Option, Option, Option) { + let manage_url = item.manage_token.as_deref().and_then(|token| { + build_token_link( + site_context.site_url.as_deref(), + "/subscriptions/manage", + token, + ) + }); + let unsubscribe_url = item.manage_token.as_deref().and_then(|token| { + build_token_link( + site_context.site_url.as_deref(), + "/subscriptions/unsubscribe", + token, + ) + }); + let confirm_url = item.confirm_token.as_deref().and_then(|token| { + build_token_link( + site_context.site_url.as_deref(), + "/subscriptions/confirm", + token, + ) + }); (manage_url, unsubscribe_url, confirm_url) } @@ -449,7 +470,11 @@ async fn send_confirmation_email(ctx: &AppContext, item: &subscriptions::Model) .await } -fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payload: &Value) -> bool { +fn subscription_allows_event( + item: &subscriptions::Model, + event_type: &str, + payload: &Value, +) -> bool { if normalize_status(&item.status) != STATUS_ACTIVE { return false; } @@ -487,7 +512,9 @@ fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payl if !tags.is_empty() { let payload_tags = payload_match_strings(payload, "tags"); if payload_tags.is_empty() - || !tags.iter().any(|tag| payload_tags.iter().any(|item| item == tag)) + || !tags + .iter() + .any(|tag| payload_tags.iter().any(|item| item == tag)) { return false; } @@ -501,10 +528,15 @@ pub async fn list_subscriptions( channel_type: Option<&str>, status: Option<&str>, ) -> Result> { - let mut query = subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc); + let mut query = + subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc); - if let Some(channel_type) = channel_type.map(str::trim).filter(|value| !value.is_empty()) { - query = query.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type))); + if let Some(channel_type) = channel_type + .map(str::trim) + .filter(|value| !value.is_empty()) + { + query = query + .filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type))); } if let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) { @@ -771,7 +803,9 @@ pub async fn update_subscription_preferences( if let Some(status) = status { let normalized = normalize_status(&status); if normalized == STATUS_PENDING { - return Err(Error::BadRequest("偏好页不支持将状态改回 pending".to_string())); + return Err(Error::BadRequest( + "偏好页不支持将状态改回 pending".to_string(), + )); } active.status = Set(normalized); } @@ -783,7 +817,10 @@ pub async fn update_subscription_preferences( active.update(&ctx.db).await.map_err(Into::into) } -pub async fn unsubscribe_subscription(ctx: &AppContext, token: &str) -> Result { +pub async fn unsubscribe_subscription( + ctx: &AppContext, + token: &str, +) -> Result { let item = get_subscription_by_manage_token(ctx, token).await?; let mut active = item.into_active_model(); active.status = Set(STATUS_UNSUBSCRIBED.to_string()); @@ -821,11 +858,7 @@ async fn update_subscription_delivery_state( let mut active = subscription.into_active_model(); active.last_notified_at = Set(Some(Utc::now().to_rfc3339())); active.last_delivery_status = Set(Some(status.to_string())); - active.failure_count = Set(Some(if success { - 0 - } else { - current_failures + 1 - })); + active.failure_count = Set(Some(if success { 0 } else { current_failures + 1 })); let _ = active.update(&ctx.db).await?; Ok(()) } @@ -945,10 +978,16 @@ pub async fn queue_event_for_active_subscriptions( ) -> Result { let subscriptions = active_subscriptions(ctx).await?; if subscriptions.is_empty() { - return Ok(QueueDispatchSummary { queued: 0, skipped: 0 }); + return Ok(QueueDispatchSummary { + queued: 0, + skipped: 0, + }); } - let site_context = SiteContext { site_name, site_url }; + let site_context = SiteContext { + site_name, + site_url, + }; let mut queued = 0usize; let mut skipped = 0usize; @@ -1058,38 +1097,32 @@ async fn deliver_via_channel( CHANNEL_EMAIL => Err(Error::BadRequest( "email channel must be delivered via subscription context".to_string(), )), - CHANNEL_DISCORD => { - Client::new() - .post(target) - .json(&serde_json::json!({ "content": message.text })) - .send() - .await - .and_then(|response| response.error_for_status()) - .map(|_| None) - .map_err(|error| Error::BadRequest(error.to_string())) - } - CHANNEL_TELEGRAM => { - Client::new() - .post(target) - .json(&serde_json::json!({ "text": message.text })) - .send() - .await - .and_then(|response| response.error_for_status()) - .map(|_| None) - .map_err(|error| Error::BadRequest(error.to_string())) - } - CHANNEL_NTFY => { - Client::new() - .post(resolve_ntfy_target(target)) - .header("Title", &message.subject) - .header("Content-Type", "text/plain; charset=utf-8") - .body(message.text.clone()) - .send() - .await - .and_then(|response| response.error_for_status()) - .map(|_| None) - .map_err(|error| Error::BadRequest(error.to_string())) - } + CHANNEL_DISCORD => Client::new() + .post(target) + .json(&serde_json::json!({ "content": message.text })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())), + CHANNEL_TELEGRAM => Client::new() + .post(target) + .json(&serde_json::json!({ "text": message.text })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())), + CHANNEL_NTFY => Client::new() + .post(resolve_ntfy_target(target)) + .header("Title", &message.subject) + .header("Content-Type", "text/plain; charset=utf-8") + .body(message.text.clone()) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())), CHANNEL_WEB_PUSH => { let settings = crate::controllers::site_settings::load_current(ctx).await?; let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?; @@ -1141,7 +1174,10 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> return Ok(()); }; - if matches!(delivery.status.as_str(), DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED) { + if matches!( + delivery.status.as_str(), + DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED + ) { return Ok(()); } @@ -1149,15 +1185,19 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> .payload .clone() .ok_or_else(|| Error::BadRequest("delivery payload 为空".to_string())) - .and_then(|value| serde_json::from_value::(value).map_err(Into::into))?; + .and_then(|value| { + serde_json::from_value::(value).map_err(Into::into) + })?; let attempts = delivery.attempts_count + 1; let now = Utc::now().to_rfc3339(); let subscription = match delivery.subscription_id { - Some(subscription_id) => subscriptions::Entity::find_by_id(subscription_id) - .one(&ctx.db) - .await?, + Some(subscription_id) => { + subscriptions::Entity::find_by_id(subscription_id) + .one(&ctx.db) + .await? + } None => None, }; @@ -1171,7 +1211,13 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> active.next_retry_at = Set(None); active.delivered_at = Set(Some(Utc::now().to_rfc3339())); let _ = active.update(&ctx.db).await?; - update_subscription_delivery_state(ctx, Some(subscription.id), DELIVERY_STATUS_SKIPPED, false).await?; + update_subscription_delivery_state( + ctx, + Some(subscription.id), + DELIVERY_STATUS_SKIPPED, + false, + ) + .await?; return Ok(()); } } @@ -1202,7 +1248,14 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> .await } } else { - deliver_via_channel(ctx, &delivery.channel_type, &delivery.target, &message, None).await + deliver_via_channel( + ctx, + &delivery.channel_type, + &delivery.target, + &message, + None, + ) + .await }; let subscription_id = delivery.subscription_id; let delivery_channel_type = delivery.channel_type.clone(); @@ -1218,7 +1271,8 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> active.next_retry_at = Set(None); active.delivered_at = Set(Some(Utc::now().to_rfc3339())); let _ = active.update(&ctx.db).await?; - update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true).await?; + update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true) + .await?; } Err(error) => { let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS) @@ -1298,7 +1352,10 @@ pub async fn send_test_notification( .await } -pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPost) -> Result { +pub async fn notify_post_published( + ctx: &AppContext, + post: &content::MarkdownPost, +) -> Result { let site_context = load_site_context(ctx).await; let public_url = post_public_url(site_context.site_url.as_deref(), &post.slug); let subject = format!("新文章发布:{}", post.title); @@ -1315,13 +1372,17 @@ pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPos let text = format!( "《{}》已发布。\n\n分类:{}\n标签:{}\n链接:{}\n\n{}", post.title, - post.category.clone().unwrap_or_else(|| "未分类".to_string()), + post.category + .clone() + .unwrap_or_else(|| "未分类".to_string()), if post.tags.is_empty() { "无".to_string() } else { post.tags.join(", ") }, - public_url.clone().unwrap_or_else(|| format!("/articles/{}", post.slug)), + public_url + .clone() + .unwrap_or_else(|| format!("/articles/{}", post.slug)), post.description.clone().unwrap_or_default(), ); @@ -1355,7 +1416,8 @@ pub async fn send_digest(ctx: &AppContext, period: &str) -> Result Result>() }; - let subject = format!("{} 内容摘要", if normalized_period == "monthly" { "月报" } else { "周报" }); + let subject = format!( + "{} 内容摘要", + if normalized_period == "monthly" { + "月报" + } else { + "周报" + } + ); let body = format!("统计周期:最近 {} 天\n\n{}", days, lines.join("\n\n")); let payload = serde_json::json!({ "event_type": event_type, diff --git a/backend/src/services/turnstile.rs b/backend/src/services/turnstile.rs index b621420..593bef1 100644 --- a/backend/src/services/turnstile.rs +++ b/backend/src/services/turnstile.rs @@ -91,8 +91,7 @@ fn normalize_ip(value: Option<&str>) -> Option { } fn verify_url() -> String { - env_value(ENV_TURNSTILE_VERIFY_URL) - .unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string()) + env_value(ENV_TURNSTILE_VERIFY_URL).unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string()) } fn client() -> &'static Client { @@ -173,11 +172,10 @@ pub async fn verify_token( token: Option<&str>, client_ip: Option<&str>, ) -> Result<()> { - let secret = secret_key(settings).ok_or_else(|| { - Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string()) - })?; - let response_token = trim_to_option(token) - .ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?; + let secret = secret_key(settings) + .ok_or_else(|| Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string()))?; + let response_token = + trim_to_option(token).ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?; let mut form_data = vec![ ("secret".to_string(), secret), diff --git a/backend/src/services/web_push.rs b/backend/src/services/web_push.rs index befa269..e3abd1e 100644 --- a/backend/src/services/web_push.rs +++ b/backend/src/services/web_push.rs @@ -66,9 +66,7 @@ pub fn private_key_configured(settings: &site_settings::Model) -> bool { } pub fn is_enabled(settings: &site_settings::Model) -> bool { - settings.web_push_enabled.unwrap_or(false) - && public_key_configured(settings) - && private_key_configured(settings) + public_key_configured(settings) && private_key_configured(settings) } pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result { diff --git a/backend/src/services/worker_jobs.rs b/backend/src/services/worker_jobs.rs index 4d856da..bec477f 100644 --- a/backend/src/services/worker_jobs.rs +++ b/backend/src/services/worker_jobs.rs @@ -1,14 +1,11 @@ use chrono::Utc; -use loco_rs::{ - bgworker::BackgroundWorker, - prelude::*, -}; +use loco_rs::{bgworker::BackgroundWorker, prelude::*}; use sea_orm::{ - ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order, - PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, + ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::{ models::_entities::{notification_deliveries, worker_jobs}, @@ -213,7 +210,10 @@ fn can_cancel_status(status: &str, cancel_requested: bool) -> bool { } fn can_retry_status(status: &str) -> bool { - matches!(status, JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED) + matches!( + status, + JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED + ) } fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord { @@ -256,15 +256,17 @@ fn catalog_entries() -> Vec { (TASK_SEND_MONTHLY_DIGEST, JOB_KIND_TASK, true, true), ] .into_iter() - .map(|(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry { - worker_name: worker_name.to_string(), - job_kind: job_kind.to_string(), - label: label_for(worker_name), - description: description_for(worker_name), - queue_name: queue_name_for(worker_name), - supports_cancel, - supports_retry, - }) + .map( + |(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry { + worker_name: worker_name.to_string(), + job_kind: job_kind.to_string(), + label: label_for(worker_name), + description: description_for(worker_name), + queue_name: queue_name_for(worker_name), + supports_cancel, + supports_retry, + }, + ) .collect() } @@ -311,7 +313,10 @@ async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) { } } -async fn dispatch_notification_delivery(args_ctx: AppContext, args: NotificationDeliveryWorkerArgs) { +async fn dispatch_notification_delivery( + args_ctx: AppContext, + args: NotificationDeliveryWorkerArgs, +) { let worker = NotificationDeliveryWorker::build(&args_ctx); if let Err(error) = worker.perform(args).await { tracing::warn!("notification delivery worker execution failed: {error}"); @@ -326,7 +331,9 @@ async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) -> Ok(()) } Err(error) => { - tracing::warn!("download worker queue unavailable, falling back to local task: {error}"); + tracing::warn!( + "download worker queue unavailable, falling back to local task: {error}" + ); tokio::spawn(dispatch_download(ctx.clone(), args)); Ok(()) } @@ -344,7 +351,9 @@ async fn enqueue_notification_worker( Ok(()) } Err(error) => { - tracing::warn!("notification worker queue unavailable, falling back to local task: {error}"); + tracing::warn!( + "notification worker queue unavailable, falling back to local task: {error}" + ); tokio::spawn(dispatch_notification_delivery(ctx.clone(), args)); Ok(()) } @@ -442,17 +451,19 @@ pub async fn get_overview(ctx: &AppContext) -> Result { _ => {} } - let entry = grouped.entry(item.worker_name.clone()).or_insert_with(|| WorkerStats { - worker_name: item.worker_name.clone(), - job_kind: item.job_kind.clone(), - label: label_for(&item.worker_name), - queued: 0, - running: 0, - succeeded: 0, - failed: 0, - cancelled: 0, - last_job_at: None, - }); + let entry = grouped + .entry(item.worker_name.clone()) + .or_insert_with(|| WorkerStats { + worker_name: item.worker_name.clone(), + job_kind: item.job_kind.clone(), + label: label_for(&item.worker_name), + queued: 0, + running: 0, + succeeded: 0, + failed: 0, + cancelled: 0, + last_job_at: None, + }); match item.status.as_str() { JOB_STATUS_QUEUED => entry.queued += 1, @@ -473,18 +484,35 @@ pub async fn get_overview(ctx: &AppContext) -> Result { } pub async fn list_jobs(ctx: &AppContext, query: WorkerJobListQuery) -> Result { - let mut db_query = worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc); + let mut db_query = + worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc); - if let Some(status) = query.status.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(status) = query + .status + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter(worker_jobs::Column::Status.eq(status)); } - if let Some(job_kind) = query.job_kind.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(job_kind) = query + .job_kind + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter(worker_jobs::Column::JobKind.eq(job_kind)); } - if let Some(worker_name) = query.worker_name.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(worker_name) = query + .worker_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter(worker_jobs::Column::WorkerName.eq(worker_name)); } - if let Some(search) = query.search.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + if let Some(search) = query + .search + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { db_query = db_query.filter( Condition::any() .add(worker_jobs::Column::WorkerName.contains(search.clone())) @@ -830,6 +858,9 @@ pub async fn retry_job( ) .await } - _ => Err(Error::BadRequest(format!("不支持重试任务:{}", item.worker_name))), + _ => Err(Error::BadRequest(format!( + "不支持重试任务:{}", + item.worker_name + ))), } } diff --git a/backend/src/workers/downloader.rs b/backend/src/workers/downloader.rs index a351496..3ff7634 100644 --- a/backend/src/workers/downloader.rs +++ b/backend/src/workers/downloader.rs @@ -1,5 +1,8 @@ +use std::io::Cursor; + +use image::{ImageFormat, load_from_memory}; use loco_rs::prelude::*; -use reqwest::{header, redirect::Policy, Url}; +use reqwest::{Url, header, redirect::Policy}; use serde::{Deserialize, Serialize}; use crate::services::{media_assets, storage, worker_jobs}; @@ -14,6 +17,8 @@ pub struct DownloadWorkerArgs { #[serde(default)] pub prefix: Option, #[serde(default)] + pub target_format: Option, + #[serde(default)] pub title: Option, #[serde(default)] pub alt_text: Option, @@ -48,12 +53,30 @@ fn trim_to_option(value: Option) -> Option { } fn normalize_prefix(value: Option) -> String { - value.unwrap_or_else(|| "uploads".to_string()) + value + .unwrap_or_else(|| "uploads".to_string()) .trim() .trim_matches('/') .to_string() } +pub fn normalize_target_format(value: Option) -> Result> { + let Some(value) = value.map(|item| item.trim().to_ascii_lowercase()) else { + return Ok(None); + }; + + if value.is_empty() || value == "original" { + return Ok(None); + } + + match value.as_str() { + "webp" | "avif" => Ok(Some(value)), + _ => Err(Error::BadRequest( + "target_format 仅支持 webp、avif 或 original".to_string(), + )), + } +} + fn derive_file_name(url: &Url) -> Option { url.path_segments() .and_then(|segments| segments.last()) @@ -102,10 +125,78 @@ fn is_supported_content_type(value: Option<&str>) -> bool { .trim() .split(';') .next() - .map(|item| matches!(item, "image/png" | "image/jpeg" | "image/webp" | "image/gif" | "image/avif" | "image/svg+xml" | "application/pdf")) + .map(|item| { + matches!( + item, + "image/png" + | "image/jpeg" + | "image/webp" + | "image/gif" + | "image/avif" + | "image/svg+xml" + | "application/pdf" + ) + }) .unwrap_or(false) } +fn is_convertible_bitmap_content_type(value: Option<&str>) -> bool { + value + .unwrap_or_default() + .trim() + .split(';') + .next() + .map(|item| { + matches!( + item, + "image/png" | "image/jpeg" | "image/webp" | "image/avif" + ) + }) + .unwrap_or(false) +} + +fn target_mime_type(target_format: &str) -> Option<&'static str> { + match target_format { + "webp" => Some("image/webp"), + "avif" => Some("image/avif"), + _ => None, + } +} + +fn convert_media_bytes( + bytes: &[u8], + content_type: Option<&str>, + target_format: &str, +) -> Result<(Vec, String, String)> { + let target_mime = target_mime_type(target_format) + .ok_or_else(|| Error::BadRequest("不支持的目标媒体格式".to_string()))?; + + if !is_convertible_bitmap_content_type(content_type) { + return Err(Error::BadRequest( + "当前仅支持把 PNG / JPEG / WebP / AVIF 转成 WebP 或 AVIF".to_string(), + )); + } + + let image = load_from_memory(bytes) + .map_err(|error| Error::BadRequest(format!("解析远程图片失败: {error}")))?; + let image_format = match target_format { + "webp" => ImageFormat::WebP, + "avif" => ImageFormat::Avif, + _ => return Err(Error::BadRequest("不支持的目标媒体格式".to_string())), + }; + + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, image_format) + .map_err(|error| Error::BadRequest(format!("转换远程图片格式失败: {error}")))?; + + Ok(( + cursor.into_inner(), + target_format.to_string(), + target_mime.to_string(), + )) +} + fn default_title(args: &DownloadWorkerArgs, file_name: Option<&str>) -> String { trim_to_option(args.title.clone()) .or_else(|| { @@ -188,8 +279,41 @@ pub async fn download_media_to_storage( } let file_name = derive_file_name(&final_url); - let extension = infer_extension(file_name.as_deref(), content_type.as_deref()) - .ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?; + let target_format = normalize_target_format(args.target_format.clone())?; + let normalized_source_content_type = content_type + .as_deref() + .map(str::trim) + .and_then(|value| value.split(';').next()) + .map(str::to_ascii_lowercase); + let already_target_format = target_format + .as_deref() + .and_then(target_mime_type) + .zip(normalized_source_content_type.as_deref()) + .map(|(target_mime, source_mime)| source_mime == target_mime) + .unwrap_or(false); + let (payload_bytes, extension, resolved_content_type) = + if let Some(target_format) = target_format.as_deref() { + if already_target_format { + ( + bytes.to_vec(), + target_format.to_string(), + target_mime_type(target_format) + .unwrap_or_default() + .to_string(), + ) + } else { + convert_media_bytes(&bytes, content_type.as_deref(), target_format)? + } + } else { + ( + bytes.to_vec(), + infer_extension(file_name.as_deref(), content_type.as_deref()) + .ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?, + content_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()), + ) + }; let prefix = normalize_prefix(args.prefix.clone()); let object_key = storage::build_object_key( &prefix, @@ -199,8 +323,8 @@ pub async fn download_media_to_storage( let stored = storage::upload_bytes_to_r2( ctx, &object_key, - bytes.to_vec(), - content_type.as_deref(), + payload_bytes.clone(), + Some(resolved_content_type.as_str()), Some("public, max-age=31536000, immutable"), ) .await?; @@ -222,9 +346,9 @@ pub async fn download_media_to_storage( Ok(DownloadedMediaObject { key: stored.key, url: stored.url, - size_bytes: bytes.len() as i64, + size_bytes: payload_bytes.len() as i64, source_url: final_url.to_string(), - content_type, + content_type: Some(resolved_content_type), }) } diff --git a/backend/tests/models/users.rs b/backend/tests/models/users.rs index 619c3d2..7fdf0f0 100644 --- a/backend/tests/models/users.rs +++ b/backend/tests/models/users.rs @@ -1,4 +1,4 @@ -use chrono::{offset::Local, Duration}; +use chrono::{Duration, offset::Local}; use insta::assert_debug_snapshot; use loco_rs::testing::prelude::*; use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; diff --git a/backend/tests/requests/prepare_data.rs b/backend/tests/requests/prepare_data.rs index 7273f69..80a864b 100644 --- a/backend/tests/requests/prepare_data.rs +++ b/backend/tests/requests/prepare_data.rs @@ -1,5 +1,5 @@ use axum::http::{HeaderName, HeaderValue}; -use loco_rs::{app::AppContext, TestServer}; +use loco_rs::{TestServer, app::AppContext}; use termi_api::{models::users, views::auth::LoginResponse}; const USER_EMAIL: &str = "test@loco.com"; diff --git a/frontend/src/components/Header.astro b/frontend/src/components/Header.astro index 247dd3e..8244335 100644 --- a/frontend/src/components/Header.astro +++ b/frontend/src/components/Header.astro @@ -16,7 +16,8 @@ const { const { locale, t, buildLocaleUrl } = getI18n(Astro); const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled); -const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter( +const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true; +const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter( (item) => item?.title?.trim() && item?.url?.trim() ); const musicPlaylistPayload = JSON.stringify(musicPlaylist); @@ -60,11 +61,11 @@ const currentNavLabel = -