mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
47
be/apps/core/src/modules/content/og/tweemoji.ts
Normal file
47
be/apps/core/src/modules/content/og/tweemoji.ts
Normal 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()))
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 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<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user