From 5bf7c06070f1b43be7229e7a32cd0a3a9d2676ea Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 25 Nov 2025 17:23:58 +0800 Subject: [PATCH] feat: enhance Open Graph image generation for homepage - Added a new endpoint to render a homepage Open Graph image with site statistics and featured photos. - Introduced a new template for the homepage Open Graph image, including site name, description, author avatar, and featured photos. - Updated the Open Graph service to handle rendering of the new homepage image. - Implemented emoji loading functionality for enhanced visual representation in Open Graph images. Signed-off-by: Innei --- .../contents/getting-started/quick-start.mdx | 4 +- apps/docs/contents/storage/providers/b2.mdx | 4 +- .../docs/contents/storage/providers/eagle.mdx | 4 +- .../contents/storage/providers/github.mdx | 4 +- .../docs/contents/storage/providers/index.mdx | 4 +- .../docs/contents/storage/providers/local.mdx | 4 +- apps/docs/contents/storage/providers/s3.mdx | 4 +- .../components/landing/NocturneSections.tsx | 2 +- .../src/modules/content/og/og.controller.ts | 5 + .../src/modules/content/og/og.renderer.tsx | 33 +- .../core/src/modules/content/og/og.service.ts | 95 ++++- .../src/modules/content/og/og.template.tsx | 353 +++++++++++++++++- .../core/src/modules/content/og/tweemoji.ts | 47 +++ .../photo/assets/photo-asset.service.ts | 9 +- .../assets/photo-upload-limit.interceptor.ts | 46 +++ .../photo/assets/photo-upload-limits.ts | 56 +++ .../photo/assets/photo-upload.parser.ts | 221 +++++++++++ .../content/photo/assets/photo.controller.ts | 258 +------------ .../src/modules/content/photo/photo.module.ts | 11 +- .../static-web/static-web.controller.ts | 4 +- .../static-web/static-web.service.ts | 89 +++-- 21 files changed, 958 insertions(+), 299 deletions(-) create mode 100644 be/apps/core/src/modules/content/og/tweemoji.ts create mode 100644 be/apps/core/src/modules/content/photo/assets/photo-upload-limit.interceptor.ts create mode 100644 be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts create mode 100644 be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts diff --git a/apps/docs/contents/getting-started/quick-start.mdx b/apps/docs/contents/getting-started/quick-start.mdx index f86b9822..e29a0e7d 100644 --- a/apps/docs/contents/getting-started/quick-start.mdx +++ b/apps/docs/contents/getting-started/quick-start.mdx @@ -2,7 +2,7 @@ title: Quick Start description: Get your gallery running in about 5 minutes. createdAt: 2025-11-14T22:20:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 2 --- @@ -109,3 +109,5 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel - **Deploy**: Follow the [Deployment Guide](/deployment) for your platform - **Learn more**: Check out [Architecture](/architecture) and [Builder](/builder) documentation + + diff --git a/apps/docs/contents/storage/providers/b2.mdx b/apps/docs/contents/storage/providers/b2.mdx index dd92b2de..1e9d2201 100644 --- a/apps/docs/contents/storage/providers/b2.mdx +++ b/apps/docs/contents/storage/providers/b2.mdx @@ -2,7 +2,7 @@ title: B2 (Backblaze B2) description: Configure Backblaze B2 storage for cost-effective cloud storage. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 33 --- @@ -94,3 +94,5 @@ Compare with AWS S3 to see which fits your usage pattern better. - B2 has generous rate limits, but very high concurrency may still hit limits - Reduce concurrency if needed + + diff --git a/apps/docs/contents/storage/providers/eagle.mdx b/apps/docs/contents/storage/providers/eagle.mdx index 23eccf31..a93499f3 100644 --- a/apps/docs/contents/storage/providers/eagle.mdx +++ b/apps/docs/contents/storage/providers/eagle.mdx @@ -2,7 +2,7 @@ title: Eagle Storage description: Publish directly from an Eagle 4 library with filtering support. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 36 --- @@ -163,3 +163,5 @@ This creates tags in the manifest based on folder structure, useful for organizi - Check that `baseUrl` matches your web server - Ensure the destination directory exists + + diff --git a/apps/docs/contents/storage/providers/github.mdx b/apps/docs/contents/storage/providers/github.mdx index 76df2145..927d54a7 100644 --- a/apps/docs/contents/storage/providers/github.mdx +++ b/apps/docs/contents/storage/providers/github.mdx @@ -2,7 +2,7 @@ title: GitHub Storage description: Use a GitHub repository as photo storage for simple deployments. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 34 --- @@ -98,3 +98,5 @@ For private repositories: - Ensure no individual file exceeds ~100MB - Consider compressing large photos or using a different provider + + diff --git a/apps/docs/contents/storage/providers/index.mdx b/apps/docs/contents/storage/providers/index.mdx index 8ccb4a7d..24874f85 100644 --- a/apps/docs/contents/storage/providers/index.mdx +++ b/apps/docs/contents/storage/providers/index.mdx @@ -2,7 +2,7 @@ title: Storage Providers description: Choose a storage provider for your photo collection. createdAt: 2025-11-14T22:40:00+08:00 -lastModified: 2025-11-24T22:26:48+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 30 --- @@ -109,3 +109,5 @@ export default defineBuilderConfig(() => ({ Credentials and sensitive information should be stored in `.env` and referenced via `process.env`. See each provider's documentation for specific configuration options. + + diff --git a/apps/docs/contents/storage/providers/local.mdx b/apps/docs/contents/storage/providers/local.mdx index 9ae7a3be..7ec684e5 100644 --- a/apps/docs/contents/storage/providers/local.mdx +++ b/apps/docs/contents/storage/providers/local.mdx @@ -2,7 +2,7 @@ title: Local Storage description: Use local file system paths for development and self-hosting. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 35 --- @@ -132,3 +132,5 @@ If you want to serve original photos: - Check that `baseUrl` matches your web server configuration - Ensure the destination directory exists + + diff --git a/apps/docs/contents/storage/providers/s3.mdx b/apps/docs/contents/storage/providers/s3.mdx index 16ae9e39..f4046ffc 100644 --- a/apps/docs/contents/storage/providers/s3.mdx +++ b/apps/docs/contents/storage/providers/s3.mdx @@ -2,7 +2,7 @@ title: S3 / S3-Compatible description: Configure S3 or S3-compatible storage for your photo collection. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-23T19:40:52+08:00 +lastModified: 2025-11-25T17:23:59+08:00 order: 32 --- @@ -119,3 +119,5 @@ This prevents processing temporary or system files. - For non-AWS services, ensure `endpoint` is correctly configured - Check that the endpoint URL format matches your provider's requirements + + diff --git a/apps/landing/src/components/landing/NocturneSections.tsx b/apps/landing/src/components/landing/NocturneSections.tsx index bcc415f5..62d1d6da 100644 --- a/apps/landing/src/components/landing/NocturneSections.tsx +++ b/apps/landing/src/components/landing/NocturneSections.tsx @@ -44,7 +44,7 @@ export const NocturneHero = () => {
{t('preview.imageAlt')} diff --git a/be/apps/core/src/modules/content/og/og.controller.ts b/be/apps/core/src/modules/content/og/og.controller.ts index 783218b2..f144d3a4 100644 --- a/be/apps/core/src/modules/content/og/og.controller.ts +++ b/be/apps/core/src/modules/content/og/og.controller.ts @@ -7,6 +7,11 @@ import { OgService } from './og.service' export class OgController { constructor(private readonly ogService: OgService) {} + @Get('/') + async getHomepageOgImage(@ContextParam() context: Context) { + return await this.ogService.renderHomepage(context) + } + @Get('/:photoId') async getOgImage(@ContextParam() context: Context, @Param('photoId') photoId: string) { return await this.ogService.render(context, photoId) diff --git a/be/apps/core/src/modules/content/og/og.renderer.tsx b/be/apps/core/src/modules/content/og/og.renderer.tsx index 0931e660..77a68acf 100644 --- a/be/apps/core/src/modules/content/og/og.renderer.tsx +++ b/be/apps/core/src/modules/content/og/og.renderer.tsx @@ -5,8 +5,9 @@ import { Resvg } from '@resvg/resvg-js' import type { SatoriOptions } from 'satori' import satori from 'satori' -import type { OgTemplateProps } from './og.template' -import { OgTemplate } from './og.template' +import type { HomepageOgTemplateProps, OgTemplateProps } from './og.template' +import { HomepageOgTemplate, OgTemplate } from './og.template' +import { get_icon_code, load_emoji } from './tweemoji' interface RenderOgImageOptions { template: OgTemplateProps @@ -29,3 +30,31 @@ export async function renderOgImage({ template, fonts }: RenderOgImageOptions): return renderer.render().asPng() } + +interface RenderHomepageOgImageOptions { + template: HomepageOgTemplateProps + fonts: SatoriOptions['fonts'] +} + +export async function renderHomepageOgImage({ template, fonts }: RenderHomepageOgImageOptions): Promise { + const svg = await satori(, { + width: 1200, + height: 628, + fonts, + embedFont: true, + async loadAdditionalAsset(code, segment) { + if (code === 'emoji' && segment) { + return `data:image/svg+xml;base64,${btoa(await load_emoji(get_icon_code(segment)))}` + } + return '' + }, + }) + + const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg) + const renderer = new Resvg(svgInput, { + fitTo: { mode: 'width', value: 1200 }, + background: 'rgba(0,0,0,0)', + }) + + return renderer.render().asPng() +} diff --git a/be/apps/core/src/modules/content/og/og.service.ts b/be/apps/core/src/modules/content/og/og.service.ts index 29d0b028..a8942de1 100644 --- a/be/apps/core/src/modules/content/og/og.service.ts +++ b/be/apps/core/src/modules/content/og/og.service.ts @@ -12,8 +12,8 @@ import { injectable } from 'tsyringe' import { ManifestService } from '../manifest/manifest.service' import geistFontUrl from './assets/Geist-Medium.ttf?url' import harmonySansScMediumFontUrl from './assets/HarmonyOS_Sans_SC_Medium.ttf?url' -import { renderOgImage } from './og.renderer' -import type { ExifInfo, PhotoDimensions } from './og.template' +import { renderHomepageOgImage, renderOgImage } from './og.renderer' +import type { ExifInfo, HomepageOgTemplateProps, PhotoDimensions } from './og.template' const CACHE_CONTROL = 'public, max-age=31536000, stale-while-revalidate=31536000' @@ -102,6 +102,97 @@ export class OgService implements OnModuleDestroy { return new Response(body, { status: 200, headers }) } + async renderHomepage(context: Context): Promise { + const manifest = await this.manifestService.getManifest() + const siteConfig = await this.siteSettingService.getSiteConfig() + + // Calculate statistics + const totalPhotos = manifest.data.length + const uniqueTags = new Set() + manifest.data.forEach((photo) => { + photo.tags?.forEach((tag) => uniqueTags.add(tag)) + }) + const uniqueCameras = manifest.cameras.length + + // Resolve author avatar if available + let authorAvatar: string | null = null + if (siteConfig.author?.avatar) { + // Try to fetch and convert to data URL + const avatarUrl = await this.resolveAuthorAvatar(context, siteConfig.author.avatar) + if (avatarUrl) { + authorAvatar = avatarUrl + } + } + + // Get featured photos (latest 6 photos) for background + const featuredPhotos = await Promise.all( + manifest.data.slice(0, 6).map(async (photo) => { + const thumbnailSrc = await this.resolveThumbnailSrc(context, photo) + return { thumbnailSrc } + }), + ) + + const templateProps: HomepageOgTemplateProps = { + siteName: siteConfig.name || siteConfig.title || 'Photo Gallery', + siteDescription: siteConfig.description || null, + authorAvatar, + accentColor: siteConfig.accentColor, + stats: { + totalPhotos, + uniqueTags: uniqueTags.size, + uniqueCameras, + totalSizeGB: null, // Can be added later if needed + }, + featuredPhotos: featuredPhotos.length > 0 ? featuredPhotos : null, + } + + const png = await renderHomepageOgImage({ + template: templateProps, + fonts: await this.getFontConfig(), + }) + + const headers = new Headers({ + 'content-type': 'image/png', + 'cache-control': CACHE_CONTROL, + 'cloudflare-cdn-cache-control': CACHE_CONTROL, + }) + + const body = this.toArrayBuffer(png) + + return new Response(body, { status: 200, headers }) + } + + private async resolveAuthorAvatar(context: Context, avatarUrl: string): Promise { + // If it's already a data URL, return as is + if (avatarUrl.startsWith('data:')) { + return avatarUrl + } + + // Try to fetch and convert to data URL + try { + const fetched = await this.tryFetchUrl(avatarUrl) + if (fetched) { + return this.bufferToDataUrl(fetched.buffer, fetched.contentType) + } + + // If direct fetch failed, try with context base URL + const base = this.resolveBaseUrl(context) + if (base && !avatarUrl.startsWith('http://') && !avatarUrl.startsWith('https://')) { + const normalizedPath = avatarUrl.startsWith('/') ? avatarUrl : `/${avatarUrl}` + const fullUrl = new URL(normalizedPath, base).toString() + const fetched2 = await this.tryFetchUrl(fullUrl) + if (fetched2) { + return this.bufferToDataUrl(fetched2.buffer, fetched2.contentType) + } + } + + // If all fails, return the original URL (satori might be able to handle it) + return avatarUrl + } catch { + return avatarUrl + } + } + private geistFontPromise: Promise | null = null private harmonySansScMediumFontPromise: Promise | null = null async loadFonts() { diff --git a/be/apps/core/src/modules/content/og/og.template.tsx b/be/apps/core/src/modules/content/og/og.template.tsx index 0f2bc5a2..4714d15f 100644 --- a/be/apps/core/src/modules/content/og/og.template.tsx +++ b/be/apps/core/src/modules/content/og/og.template.tsx @@ -220,11 +220,11 @@ function BaseCanvas({ padding, siteName, children }: BaseCanvasProps) {
) } + +// ==================== Homepage OG Template ==================== + +export interface HomepageOgTemplateProps { + siteName: string + siteDescription?: string | null + authorAvatar?: string | null + accentColor?: string + stats: { + totalPhotos: number + uniqueTags: number + uniqueCameras: number + totalSizeGB?: number | null + } + featuredPhotos?: Array<{ thumbnailSrc: string | null }> | null +} + +export function HomepageOgTemplate({ + siteName, + siteDescription, + authorAvatar, + accentColor = '#007bff', + stats, + featuredPhotos, +}: HomepageOgTemplateProps) { + const hasAvatar = !!authorAvatar + const hasFeaturedPhotos = featuredPhotos && featuredPhotos.length > 0 + + return ( + +
+ {/* Background photo grid - rendered first so content overlays it */} + {hasFeaturedPhotos && ( +
+ {/* First row */} +
+ {featuredPhotos.slice(0, 3).map((photo, index) => ( +
+ {photo.thumbnailSrc && ( + + )} +
+
+ ))} +
+ {/* Second row */} +
+ {featuredPhotos.slice(3, 6).map((photo, index) => ( +
+ {photo.thumbnailSrc && ( + + )} +
+
+ ))} +
+
+ )} + + {/* Dark overlay for text readability */} + {hasFeaturedPhotos && ( +
+ )} + + {/* Content layer */} +
+ {hasAvatar && ( +
+
+ +
+
+
+ )} + +
+
+

+ {siteName} +

+ + {siteDescription && ( +

+ {siteDescription} +

+ )} +
+ +
+ + + + {stats.totalSizeGB !== null && stats.totalSizeGB !== undefined && ( + + )} +
+ +
+
+
+
+ + ) +} + +interface StatItemProps { + icon: string + label: string + value: string +} + +function StatItem({ icon, label, value }: StatItemProps) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+
+ ) +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M` + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K` + } + return num.toString() +} diff --git a/be/apps/core/src/modules/content/og/tweemoji.ts b/be/apps/core/src/modules/content/og/tweemoji.ts new file mode 100644 index 00000000..2f37c1fb --- /dev/null +++ b/be/apps/core/src/modules/content/og/tweemoji.ts @@ -0,0 +1,47 @@ +/* eslint-disable unicorn/prefer-code-point */ +/** + * Modified version of https://github.com/vercel/satori/blob/main/playground/utils/twemoji.ts + */ + +/** + * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. + */ + +/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ + +const U200D = String.fromCharCode(8205) +const UFE0Fg = /\uFE0F/g + +export function get_icon_code(char: string) { + return to_code_point(!char.includes(U200D) ? char.replaceAll(UFE0Fg, '') : char) +} + +function to_code_point(unicodeSurrogates: string) { + const r: string[] = [] + let c = 0, + p = 0, + i = 0 + + while (i < unicodeSurrogates.length) { + c = unicodeSurrogates.charCodeAt(i++) + if (p) { + r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)) + p = 0 + } else if (55296 <= c && c <= 56319) { + p = c + } else { + r.push(c.toString(16)) + } + } + return r.join('-') +} + +const emoji_cache: Record> = {} + +export function load_emoji(code: string) { + const key = code + if (key in emoji_cache) return emoji_cache[key] + return (emoji_cache[key] = fetch( + `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`, + ).then((r) => r.text())) +} diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index b58dfb8e..854e2fe6 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -8,6 +8,7 @@ import { } from '@afilmory/builder/plugins/thumbnail-storage/shared.js' import { StorageManager } from '@afilmory/builder/storage/index.js' import type { GitHubConfig, ManagedStorageConfig, S3CompatibleConfig } from '@afilmory/builder/storage/interfaces.js' +import type { PhotoAssetManifest } from '@afilmory/db' import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db' import { EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' @@ -33,13 +34,7 @@ import { injectable } from 'tsyringe' import { PhotoBuilderService } from '../builder/photo-builder.service' import { PhotoStorageService } from '../storage/photo-storage.service' import { TransactionalStorageManager } from '../storage/transactional-storage.manager' -import type { - PhotoAssetListItem, - PhotoAssetManifest, - PhotoAssetRecord, - PhotoAssetSummary, - UploadAssetInput, -} from './photo-asset.types' +import type { PhotoAssetListItem, PhotoAssetRecord, PhotoAssetSummary, UploadAssetInput } from './photo-asset.types' import { inferContentTypeFromKey } from './storage.utils' const DEFAULT_THUMBNAIL_EXTENSION = { diff --git a/be/apps/core/src/modules/content/photo/assets/photo-upload-limit.interceptor.ts b/be/apps/core/src/modules/content/photo/assets/photo-upload-limit.interceptor.ts new file mode 100644 index 00000000..455cb97d --- /dev/null +++ b/be/apps/core/src/modules/content/photo/assets/photo-upload-limit.interceptor.ts @@ -0,0 +1,46 @@ +import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework' +import { BizException, ErrorCode } from 'core/errors' +import { injectable } from 'tsyringe' + +import { PhotoAssetService } from './photo-asset.service' +import { PhotoUploadParser } from './photo-upload.parser' +import { + resolveBatchSizeLimitBytes, + resolveFileSizeLimitBytes, + setPhotoUploadInputsOnContext, + setPhotoUploadLimitsOnContext, +} from './photo-upload-limits' + +@injectable() +export class PhotoUploadLimitInterceptor implements Interceptor { + constructor( + private readonly photoAssetService: PhotoAssetService, + private readonly photoUploadParser: PhotoUploadParser, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const { hono } = context.getContext() + const uploadSizeLimitBytes = await this.photoAssetService.getUploadSizeLimitBytes() + const fileSizeLimitBytes = resolveFileSizeLimitBytes(uploadSizeLimitBytes) + const totalSizeLimitBytes = resolveBatchSizeLimitBytes(fileSizeLimitBytes) + + const inputs = await this.photoUploadParser.parse(hono, { + fileSizeLimitBytes, + totalSizeLimitBytes, + }) + + if (inputs.length === 0) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: '未找到可上传的文件', + }) + } + + setPhotoUploadLimitsOnContext(hono, { + fileSizeLimitBytes, + totalSizeLimitBytes, + }) + setPhotoUploadInputsOnContext(hono, inputs) + + return await next.handle() + } +} diff --git a/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts b/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts new file mode 100644 index 00000000..b5e6b960 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/assets/photo-upload-limits.ts @@ -0,0 +1,56 @@ +import { BizException, ErrorCode } from 'core/errors' +import type { Context } from 'hono' + +import type { UploadAssetInput } from './photo-asset.types' + +export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB +export const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB +export const MAX_UPLOAD_FILES_PER_BATCH = 32 +export const MAX_TEXT_FIELDS_PER_REQUEST = 64 + +const PHOTO_UPLOAD_LIMIT_CONTEXT_KEY = 'photo.upload.limits' +const PHOTO_UPLOAD_INPUT_CONTEXT_KEY = 'photo.upload.inputs' + +export type PhotoUploadLimits = { + fileSizeLimitBytes: number + totalSizeLimitBytes: number +} + +export function resolveFileSizeLimitBytes(limitFromPlan: number | null): number { + const resolved = limitFromPlan ?? ABSOLUTE_MAX_FILE_SIZE_BYTES + return Math.min(Math.max(resolved, 1), ABSOLUTE_MAX_FILE_SIZE_BYTES) +} + +export function resolveBatchSizeLimitBytes(fileSizeLimitBytes: number): number { + const normalizedFileLimit = Math.max(fileSizeLimitBytes, 1) + const theoreticalBatchLimit = normalizedFileLimit * MAX_UPLOAD_FILES_PER_BATCH + return Math.min(theoreticalBatchLimit, ABSOLUTE_MAX_REQUEST_SIZE_BYTES) +} + +export function setPhotoUploadLimitsOnContext(context: Context, limits: PhotoUploadLimits): void { + context.set(PHOTO_UPLOAD_LIMIT_CONTEXT_KEY, limits) +} + +export function getPhotoUploadLimitsFromContext(context: Context): PhotoUploadLimits { + const limits = context.get(PHOTO_UPLOAD_LIMIT_CONTEXT_KEY) + + if (!limits) { + throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: '上传限制校验未初始化' }) + } + + return limits +} + +export function setPhotoUploadInputsOnContext(context: Context, inputs: UploadAssetInput[]): void { + context.set(PHOTO_UPLOAD_INPUT_CONTEXT_KEY, inputs) +} + +export function getPhotoUploadInputsFromContext(context: Context): UploadAssetInput[] { + const inputs = context.get(PHOTO_UPLOAD_INPUT_CONTEXT_KEY) + + if (!inputs) { + throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: '上传解析结果未初始化' }) + } + + return inputs +} diff --git a/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts b/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts new file mode 100644 index 00000000..55a17bf3 --- /dev/null +++ b/be/apps/core/src/modules/content/photo/assets/photo-upload.parser.ts @@ -0,0 +1,221 @@ +import { Readable } from 'node:stream' + +import type { FileInfo } from 'busboy' +import Busboy from 'busboy' +import { BizException, ErrorCode } from 'core/errors' +import type { Context } from 'hono' +import { injectable } from 'tsyringe' + +import type { UploadAssetInput } from './photo-asset.types' +import { MAX_TEXT_FIELDS_PER_REQUEST, MAX_UPLOAD_FILES_PER_BATCH } from './photo-upload-limits' + +const BYTES_PER_MB = 1024 * 1024 + +type MultipartParseOptions = { + fileSizeLimitBytes: number + totalSizeLimitBytes: number + abortSignal?: AbortSignal +} + +@injectable() +export class PhotoUploadParser { + async parse(context: Context, options: MultipartParseOptions): Promise { + const headers = this.normalizeRequestHeaders(context.req.raw.headers) + if (!headers['content-type']) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 Content-Type 头' }) + } + + const normalizedFileSizeLimit = Math.max(1, Math.floor(options.fileSizeLimitBytes)) + const normalizedBatchLimit = Math.max(normalizedFileSizeLimit, Math.floor(options.totalSizeLimitBytes)) + const busboy = Busboy({ + headers, + limits: { + fileSize: normalizedFileSizeLimit, + files: MAX_UPLOAD_FILES_PER_BATCH, + fields: MAX_TEXT_FIELDS_PER_REQUEST, + }, + }) + + const requestStream = this.createReadableFromRequest(context.req.raw) + const abortSignal = options.abortSignal ?? context.req.raw.signal + + return await new Promise((resolve, reject) => { + const files: UploadAssetInput[] = [] + let directory: string | null = null + let totalBytes = 0 + let settled = false + + const cleanup = () => { + if (abortSignal) { + abortSignal.removeEventListener('abort', onAbort) + } + requestStream.removeListener('error', onStreamError) + } + + const fail = (error: Error) => { + if (settled) { + return + } + settled = true + busboy.destroy(error) + requestStream.destroy() + cleanup() + reject(error) + } + + const finish = () => { + if (settled) { + return + } + settled = true + cleanup() + resolve(files) + } + + const onAbort = () => { + fail(new DOMException('Upload aborted', 'AbortError')) + } + + const onStreamError = (error: Error) => { + fail(error) + } + + if (abortSignal) { + abortSignal.addEventListener('abort', onAbort) + } + requestStream.on('error', onStreamError) + + busboy.on('field', (name, value) => { + if (name !== 'directory') { + return + } + + if (directory !== null) { + return + } + + if (typeof value === 'string') { + directory = this.normalizeDirectoryValue(value) + } + }) + + busboy.on('file', (fieldName: string, stream, info: FileInfo) => { + if (fieldName !== 'files') { + stream.resume() + return + } + + const chunks: Buffer[] = [] + let streamFinished = false + + const handleChunk = (chunk: Buffer) => { + if (settled || streamFinished) { + return + } + + totalBytes += chunk.length + if (totalBytes > normalizedBatchLimit) { + stream.removeListener('data', handleChunk) + stream.resume() + process.nextTick(() => { + fail( + new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: `单次上传大小不能超过 ${this.formatBytesForDisplay(normalizedBatchLimit)}`, + }), + ) + }) + return + } + + chunks.push(chunk) + } + + stream.on('data', handleChunk) + stream.once('limit', () => { + streamFinished = true + stream.removeListener('data', handleChunk) + stream.resume() + process.nextTick(() => { + fail( + new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: `文件 ${info.filename} 超出大小限制 ${this.formatBytesForDisplay(normalizedFileSizeLimit)}`, + }), + ) + }) + }) + stream.once('error', (error) => { + fail(error instanceof Error ? error : new Error('文件上传失败')) + }) + stream.once('end', () => { + streamFinished = true + if (settled) { + return + } + + const buffer = Buffer.concat(chunks) + files.push({ + filename: info.filename, + buffer, + contentType: info.mimeType || undefined, + directory, + }) + }) + }) + + busboy.once('error', (error) => { + fail(error instanceof Error ? error : new Error('上传解析失败')) + }) + busboy.once('filesLimit', () => { + fail( + new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: `单次最多支持上传 ${MAX_UPLOAD_FILES_PER_BATCH} 个文件`, + }), + ) + }) + busboy.once('fieldsLimit', () => { + fail(new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '附带字段数量超出限制' })) + }) + busboy.once('partsLimit', () => { + fail(new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '上传内容分片数量超出限制' })) + }) + busboy.once('finish', finish) + + requestStream.pipe(busboy) + }) + } + + private formatBytesForDisplay(bytes: number): string { + return `${this.formatBytesToMb(bytes)} MB` + } + + private formatBytesToMb(bytes: number): number { + return Number((bytes / BYTES_PER_MB).toFixed(2)) + } + + private normalizeDirectoryValue(value: string | null): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } + + private normalizeRequestHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result + } + + private createReadableFromRequest(request: Request): Readable { + if (request.bodyUsed) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '请求体已被消费' }) + } + if (!request.body) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '上传请求缺少内容' }) + } + + return Readable.fromWeb(request.body as any) + } +} diff --git a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts index 25629dc5..84f91961 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts @@ -1,5 +1,3 @@ -import { Readable } from 'node:stream' - import { Body, ContextParam, @@ -11,9 +9,9 @@ import { Patch, Post, Query, + UseInterceptors, } from '@afilmory/framework' -import type { FileInfo } from 'busboy' -import Busboy from 'busboy' +import { getOptionalDbContext } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' import { Roles } from 'core/guards/roles.decorator' import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' @@ -24,27 +22,9 @@ import { inject } from 'tsyringe' import { UpdatePhotoTagsDto } from './photo-asset.dto' import { PhotoAssetService } from './photo-asset.service' -import type { PhotoAssetListItem, PhotoAssetSummary, UploadAssetInput } from './photo-asset.types' - -const ABSOLUTE_MAX_FILE_SIZE_BYTES = 30 * 1024 * 1024 // 30 MB -const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 200 * 1024 * 1024 // 200MB -const MAX_UPLOAD_FILES_PER_BATCH = 32 -const MAX_TEXT_FIELDS_PER_REQUEST = 64 -const BYTES_PER_MB = 1024 * 1024 - -type MultipartParseOptions = { - fileSizeLimitBytes: number - totalSizeLimitBytes: number - abortSignal: AbortSignal -} - -function formatBytesToMb(bytes: number): number { - return Number((bytes / BYTES_PER_MB).toFixed(2)) -} - -function formatBytesForDisplay(bytes: number): string { - return `${formatBytesToMb(bytes)} MB` -} +import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.types' +import { PhotoUploadLimitInterceptor } from './photo-upload-limit.interceptor' +import { getPhotoUploadInputsFromContext } from './photo-upload-limits' type DeleteAssetsDto = { ids?: string[] @@ -80,27 +60,21 @@ export class PhotoController { return { ids, deleted: true, deleteFromStorage } } + @UseInterceptors(PhotoUploadLimitInterceptor) @Post('assets/upload') async uploadAssets(@ContextParam() context: Context): Promise { return createProgressSseResponse({ context, handler: async ({ sendEvent, abortSignal }) => { try { - const uploadSizeLimitBytes = await this.photoAssetService.getUploadSizeLimitBytes() - const fileSizeLimitBytes = this.resolveFileSizeLimitBytes(uploadSizeLimitBytes) - const totalSizeLimitBytes = this.resolveBatchSizeLimitBytes(fileSizeLimitBytes) - this.assertRequestSizeWithinLimit(context, totalSizeLimitBytes) - - const inputs = await this.parseUploadPayload(context, { - fileSizeLimitBytes, - totalSizeLimitBytes, - abortSignal, - }) - - if (inputs.length === 0) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: '未找到可上传的文件', - }) + const inputs = getPhotoUploadInputsFromContext(context) + // clear the AsyncLocal DB store (transaction and cached db) at the start of the SSE handler so the subsequent service call falls back to the shared pool + // instead of the soon-to-be-released client. This mirrors the implicit timing you had before (the handler didn’t touch the DB until after the transaction was + // gone) and prevents Drizzle from binding to a dead connection. + const dbContext = getOptionalDbContext() + if (dbContext) { + dbContext.transaction = undefined + dbContext.db = undefined } await this.photoAssetService.uploadAssets(inputs, { @@ -134,208 +108,4 @@ export class PhotoController { async updateAssetTags(@Param('id') id: string, @Body() body: UpdatePhotoTagsDto): Promise { return await this.photoAssetService.updateAssetTags(id, body.tags ?? []) } - - private resolveFileSizeLimitBytes(limitFromPlan: number | null): number { - const resolved = limitFromPlan ?? ABSOLUTE_MAX_FILE_SIZE_BYTES - return Math.min(Math.max(resolved, 1), ABSOLUTE_MAX_FILE_SIZE_BYTES) - } - - private resolveBatchSizeLimitBytes(fileSizeLimitBytes: number): number { - const normalizedFileLimit = Math.max(fileSizeLimitBytes, 1) - const theoreticalBatchLimit = normalizedFileLimit * MAX_UPLOAD_FILES_PER_BATCH - return Math.min(theoreticalBatchLimit, ABSOLUTE_MAX_REQUEST_SIZE_BYTES) - } - - private assertRequestSizeWithinLimit(context: Context, limitBytes: number): void { - const contentLengthHeader = context.req.header('content-length') - if (!contentLengthHeader) { - return - } - - const contentLength = Number(contentLengthHeader) - if (!Number.isFinite(contentLength) || contentLength <= limitBytes) { - return - } - - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `单次上传大小不能超过 ${formatBytesForDisplay(limitBytes)}`, - }) - } - - private async parseUploadPayload(context: Context, options: MultipartParseOptions): Promise { - const headers = this.normalizeRequestHeaders(context.req.raw.headers) - if (!headers['content-type']) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 Content-Type 头' }) - } - - const normalizedFileSizeLimit = Math.max(1, Math.floor(options.fileSizeLimitBytes)) - const normalizedBatchLimit = Math.max(normalizedFileSizeLimit, Math.floor(options.totalSizeLimitBytes)) - const busboy = Busboy({ - headers, - limits: { - fileSize: normalizedFileSizeLimit, - files: MAX_UPLOAD_FILES_PER_BATCH, - fields: MAX_TEXT_FIELDS_PER_REQUEST, - }, - }) - - const requestStream = this.createReadableFromRequest(context.req.raw) - - return await new Promise((resolve, reject) => { - const files: UploadAssetInput[] = [] - let directory: string | null = null - let totalBytes = 0 - let settled = false - - const cleanup = () => { - options.abortSignal.removeEventListener('abort', onAbort) - requestStream.removeListener('error', onStreamError) - } - - const fail = (error: Error) => { - if (settled) { - return - } - settled = true - cleanup() - requestStream.destroy(error) - busboy.destroy(error) - reject(error) - } - - const finish = () => { - if (settled) { - return - } - settled = true - cleanup() - resolve(files) - } - - const onAbort = () => { - fail(new DOMException('Upload aborted', 'AbortError')) - } - - const onStreamError = (error: Error) => { - fail(error) - } - - options.abortSignal.addEventListener('abort', onAbort) - requestStream.on('error', onStreamError) - - busboy.on('field', (name, value) => { - if (name !== 'directory') { - return - } - - if (directory !== null) { - return - } - - if (typeof value === 'string') { - directory = this.normalizeDirectoryValue(value) - } - }) - - busboy.on('file', (fieldName: string, stream, info: FileInfo) => { - if (fieldName !== 'files') { - stream.resume() - return - } - - const chunks: Buffer[] = [] - - const handleChunk = (chunk: Buffer) => { - if (settled) { - return - } - - totalBytes += chunk.length - if (totalBytes > normalizedBatchLimit) { - stream.removeListener('data', handleChunk) - fail( - new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `单次上传大小不能超过 ${formatBytesForDisplay(normalizedBatchLimit)}`, - }), - ) - return - } - - chunks.push(chunk) - } - - stream.on('data', handleChunk) - stream.once('limit', () => { - fail( - new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `文件 ${info.filename} 超出大小限制 ${formatBytesForDisplay(normalizedFileSizeLimit)}`, - }), - ) - }) - stream.once('error', (error) => { - fail(error instanceof Error ? error : new Error('文件上传失败')) - }) - stream.once('end', () => { - if (settled) { - return - } - - const buffer = Buffer.concat(chunks) - files.push({ - filename: info.filename, - buffer, - contentType: info.mimeType || undefined, - directory, - }) - }) - }) - - busboy.once('error', (error) => { - fail(error instanceof Error ? error : new Error('上传解析失败')) - }) - busboy.once('filesLimit', () => { - fail( - new BizException(ErrorCode.COMMON_BAD_REQUEST, { - message: `单次最多支持上传 ${MAX_UPLOAD_FILES_PER_BATCH} 个文件`, - }), - ) - }) - busboy.once('fieldsLimit', () => { - fail(new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '附带字段数量超出限制' })) - }) - busboy.once('partsLimit', () => { - fail(new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '上传内容分片数量超出限制' })) - }) - busboy.once('finish', finish) - - requestStream.pipe(busboy) - }) - } - - private normalizeDirectoryValue(value: string | null): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null - } - - private normalizeRequestHeaders(headers: Headers): Record { - const result: Record = {} - headers.forEach((value, key) => { - result[key.toLowerCase()] = value - }) - return result - } - - private createReadableFromRequest(request: Request): Readable { - if (request.bodyUsed) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '请求体已被消费' }) - } - if (!request.body) { - throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '上传请求缺少内容' }) - } - - return Readable.fromWeb(request.body as any) - } } diff --git a/be/apps/core/src/modules/content/photo/photo.module.ts b/be/apps/core/src/modules/content/photo/photo.module.ts index 894c5769..be811da0 100644 --- a/be/apps/core/src/modules/content/photo/photo.module.ts +++ b/be/apps/core/src/modules/content/photo/photo.module.ts @@ -6,12 +6,21 @@ import { ManagedStorageModule } from 'core/modules/platform/managed-storage/mana import { PhotoController } from './assets/photo.controller' import { PhotoAssetService } from './assets/photo-asset.service' +import { PhotoUploadParser } from './assets/photo-upload.parser' +import { PhotoUploadLimitInterceptor } from './assets/photo-upload-limit.interceptor' import { PhotoBuilderService } from './builder/photo-builder.service' import { PhotoStorageService } from './storage/photo-storage.service' @Module({ imports: [SystemSettingModule, BillingModule, ManagedStorageModule], controllers: [PhotoController], - providers: [PhotoBuilderService, PhotoStorageService, PhotoAssetService, BuilderConfigService], + providers: [ + PhotoBuilderService, + PhotoStorageService, + PhotoAssetService, + PhotoUploadLimitInterceptor, + PhotoUploadParser, + BuilderConfigService, + ], }) export class PhotoModule {} diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts index cba278c2..c9be4ebc 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts @@ -28,10 +28,10 @@ export class StaticWebController extends StaticBaseController { if (response.status === 404) { return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService) } - return response + return await this.staticWebService.decorateHomepageResponse(context, response) } - @Get(`/photos/:photoId`) + @Get('/photos/:photoId') async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) { if (StaticControllerUtils.isReservedTenant({ root: true })) { return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService) diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts index 4c743ef7..419aa293 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts @@ -97,8 +97,12 @@ export class StaticWebService extends StaticAssetService { const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument this.removeExistingSocialMeta(document) this.updateDocumentTitle(document, `${photo.id} | ${siteTitle}`) - this.insertOpenGraphTags(document, photo, origin, siteTitle) - this.insertTwitterTags(document, photo, origin, siteTitle) + this.insertSocialMetaTags(document, { + title: `${photo.id} on ${siteTitle}`, + description: photo.description || '', + image: `${origin}/og/${photo.id}`, + url: `${origin}/${photo.id}`, + }) const serialized = document.documentElement.outerHTML return this.createManualHtmlResponse(serialized, headers, 200) @@ -108,6 +112,40 @@ export class StaticWebService extends StaticAssetService { } } + async decorateHomepageResponse(context: Context, response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.toLowerCase().includes('text/html')) { + return response + } + + const html = await response.text() + const headers = new Headers(response.headers) + const siteConfig = await this.siteSettingService.getSiteConfig() + const siteTitle = siteConfig.title?.trim() || siteConfig.name || 'Photo Gallery' + const origin = this.resolveRequestOrigin(context) + if (!origin) { + return this.createManualHtmlResponse(html, headers, response.status) + } + + try { + const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument + this.removeExistingSocialMeta(document) + const description = siteConfig.description?.trim() || '' + this.insertSocialMetaTags(document, { + title: siteTitle, + description, + image: `${origin}/og`, + url: origin, + }) + + const serialized = document.documentElement.outerHTML + return this.createManualHtmlResponse(serialized, headers, 200) + } catch (error) { + this.logger.error('Failed to inject Open Graph tags for homepage', { error }) + return this.createManualHtmlResponse(html, headers, response.status) + } + } + private injectConfigScript(document: StaticAssetDocument, siteConfig: TenantSiteConfig): void { const configScript = document.head?.querySelector('#config') if (!configScript) { @@ -212,44 +250,37 @@ export class StaticWebService extends StaticAssetService { document.title = title } - private insertOpenGraphTags( + private insertSocialMetaTags( document: StaticAssetDocument, - photo: PhotoManifestItem, - origin: string, - siteTitle: string, + data: { title: string; description: string; image: string; url: string }, ): void { - const tags: Record = { + const ogTags: Record = { 'og:type': 'website', - 'og:title': `${photo.id} on ${siteTitle}`, - 'og:description': photo.description || '', - 'og:image': `${origin}/og/${photo.id}`, - 'og:url': `${origin}/${photo.id}`, + 'og:title': data.title, + 'og:description': data.description, + 'og:image': data.image, + 'og:url': data.url, } - for (const [property, content] of Object.entries(tags)) { - const element = document.createElement('meta') - element.setAttribute('property', property) - element.setAttribute('content', content) - document.head?.append(element) + const twitterTags: Record = { + 'twitter:card': 'summary_large_image', + 'twitter:title': data.title, + 'twitter:description': data.description, + 'twitter:image': data.image, } + + this.insertMetaTags(document, ogTags, 'property') + this.insertMetaTags(document, twitterTags, 'name') } - private insertTwitterTags( + private insertMetaTags( document: StaticAssetDocument, - photo: PhotoManifestItem, - origin: string, - siteTitle: string, + tags: Record, + attributeName: 'property' | 'name', ): void { - const tags: Record = { - 'twitter:card': 'summary_large_image', - 'twitter:title': `${photo.id} on ${siteTitle}`, - 'twitter:description': photo.description || '', - 'twitter:image': `${origin}/og/${photo.id}`, - } - - for (const [name, content] of Object.entries(tags)) { + for (const [key, content] of Object.entries(tags)) { const element = document.createElement('meta') - element.setAttribute('name', name) + element.setAttribute(attributeName, key) element.setAttribute('content', content) document.head?.append(element) }