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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-25 17:23:58 +08:00
parent b5b4c9b7f1
commit 5bf7c06070
21 changed files with 958 additions and 299 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -44,7 +44,7 @@ export const NocturneHero = () => {
<div className="grid gap-6 lg:grid-cols-[1.35fr,1fr]">
<div className="mt-4 aspect-4/3 w-full overflow-hidden rounded-2xl border border-white/10">
<img
src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true"
src="https://cdn.jsdelivr.net/gh/Afilmory/assets/afilmory-readme.webp"
alt={t('preview.imageAlt')}
className="h-full w-full object-cover"
/>

View File

@@ -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)

View File

@@ -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<Uint8Array> {
const svg = await satori(<HomepageOgTemplate {...template} />, {
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()
}

View File

@@ -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<Response> {
const manifest = await this.manifestService.getManifest()
const siteConfig = await this.siteSettingService.getSiteConfig()
// Calculate statistics
const totalPhotos = manifest.data.length
const uniqueTags = new Set<string>()
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<string | null> {
// 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<NonSharedBuffer> | null = null
private harmonySansScMediumFontPromise: Promise<NonSharedBuffer> | null = null
async loadFonts() {

View File

@@ -220,11 +220,11 @@ function BaseCanvas({ padding, siteName, children }: BaseCanvasProps) {
<div
style={{
position: 'absolute',
bottom: `${padding}px`,
right: `${padding}px`,
fontSize: '14px',
bottom: `32px`,
right: `32px`,
fontSize: '20px',
fontWeight: '500',
color: 'rgba(255,255,255,0.28)',
color: 'rgba(255,255,255,0.68)',
letterSpacing: '0.5px',
display: 'flex',
}}
@@ -540,3 +540,348 @@ function InfoPanel({ title, tags, exifItems, camera, formattedDate, accentColor,
</div>
)
}
// ==================== 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 (
<BaseCanvas padding={0} siteName={siteName}>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
width: '100%',
height: '100%',
position: 'relative',
}}
>
{/* Background photo grid - rendered first so content overlays it */}
{hasFeaturedPhotos && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
gap: 0,
opacity: 0.2,
}}
>
{/* First row */}
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: 0,
flex: 1,
}}
>
{featuredPhotos.slice(0, 3).map((photo, index) => (
<div
key={index}
style={{
flex: 1,
borderRadius: 0,
overflow: 'hidden',
backgroundColor: '#050505',
position: 'relative',
display: 'flex',
}}
>
{photo.thumbnailSrc && (
<img
src={photo.thumbnailSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.3) 0%, transparent 50%)',
}}
/>
</div>
))}
</div>
{/* Second row */}
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: 0,
flex: 1,
}}
>
{featuredPhotos.slice(3, 6).map((photo, index) => (
<div
key={index + 3}
style={{
flex: 1,
borderRadius: 0,
overflow: 'hidden',
backgroundColor: '#050505',
position: 'relative',
display: 'flex',
}}
>
{photo.thumbnailSrc && (
<img
src={photo.thumbnailSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.3) 0%, transparent 50%)',
}}
/>
</div>
))}
</div>
</div>
)}
{/* Dark overlay for text readability */}
{hasFeaturedPhotos && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.3) 50%, rgba(0,0,0,0.5) 100%)',
}}
/>
)}
{/* Content layer */}
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: hasAvatar ? '48px' : '0',
width: '100%',
height: '100%',
position: 'relative',
padding: '60px',
}}
>
{hasAvatar && (
<div
style={{
display: 'flex',
flexShrink: 0,
}}
>
<div
style={{
width: '180px',
height: '180px',
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)',
backgroundColor: '#050505',
display: 'flex',
position: 'relative',
}}
>
<img
src={authorAvatar}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%)',
display: 'flex',
}}
/>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '24px',
flex: 1,
height: '100%',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<h1
style={{
margin: 0,
fontSize: '56px',
fontWeight: 700,
letterSpacing: '-1px',
lineHeight: 1.2,
color: '#ffffff',
}}
>
{siteName}
</h1>
{siteDescription && (
<p
style={{
margin: 0,
fontSize: '22px',
fontWeight: 400,
lineHeight: 1.4,
color: 'rgba(255,255,255,0.75)',
maxWidth: '700px',
}}
>
{siteDescription}
</p>
)}
</div>
<div
style={{
display: 'flex',
gap: '32px',
flexWrap: 'wrap',
marginTop: '8px',
}}
>
<StatItem icon="📸" label="Photos" value={formatNumber(stats.totalPhotos)} />
<StatItem icon="🏷️" label="Tags" value={formatNumber(stats.uniqueTags)} />
<StatItem icon="📷" label="Cameras" value={formatNumber(stats.uniqueCameras)} />
{stats.totalSizeGB !== null && stats.totalSizeGB !== undefined && (
<StatItem icon="💾" label="Storage" value={`${stats.totalSizeGB.toFixed(1)} GB`} />
)}
</div>
<div
style={{
width: '120px',
height: '4px',
background: accentColor,
borderRadius: '2px',
marginTop: '12px',
}}
/>
</div>
</div>
</div>
</BaseCanvas>
)
}
interface StatItemProps {
icon: string
label: string
value: string
}
function StatItem({ icon, label, value }: StatItemProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'rgba(255,255,255,0.6)',
fontSize: '14px',
letterSpacing: '0.3px',
}}
>
<span>{icon}</span>
<span>{label}</span>
</div>
<div
style={{
fontSize: '28px',
fontWeight: 600,
color: '#ffffff',
letterSpacing: '-0.5px',
}}
>
{value}
</div>
</div>
)
}
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()
}

View File

@@ -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<string, Promise<string>> = {}
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()))
}

View File

@@ -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 = {

View File

@@ -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<FrameworkResponse> {
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()
}
}

View File

@@ -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
}

View File

@@ -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<UploadAssetInput[]> {
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<UploadAssetInput[]>((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<string, string> {
const result: Record<string, string> = {}
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)
}
}

View File

@@ -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<Response> {
return createProgressSseResponse<DataSyncProgressEvent>({
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 didnt 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<PhotoAssetListItem> {
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<UploadAssetInput[]> {
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<UploadAssetInput[]>((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<string, string> {
const result: Record<string, string> = {}
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)
}
}

View File

@@ -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 {}

View File

@@ -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)

View File

@@ -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<Response> {
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<string, string> = {
const ogTags: Record<string, string> = {
'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<string, string> = {
'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<string, string>,
attributeName: 'property' | 'name',
): void {
const tags: Record<string, string> = {
'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)
}