Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-25 23:17:36 +08:00
parent 6e83868d10
commit f48815b3a3
27 changed files with 862 additions and 298 deletions

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { gallerySettingAtom } from '~/atoms/app'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import { usePhotoViewer } from '~/hooks/usePhotoViewer'
import { MageLens } from '~/icons'
@@ -34,6 +35,18 @@ const allTags = photoLoader.getAllTags()
const allCameras = photoLoader.getAllCameras()
const allLenses = photoLoader.getAllLenses()
const PhotoThumbnailIcon = ({ photo }: { photo: ReturnType<typeof photoLoader.getPhotos>[number] }) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
if (!thumbnailSrc) {
return (
<div className="bg-fill-tertiary flex h-6 w-6 items-center justify-center rounded text-xs text-white/70">📷</div>
)
}
return <img src={thumbnailSrc} alt={photo.title || 'Photo'} className="h-6 w-6 rounded object-cover" />
}
const getLocationTokens = (
location?: { locationName?: string | null; city?: string | null; country?: string | null } | null,
) => {
@@ -294,7 +307,7 @@ export const CommandPalette = ({ isOpen, onClose }: CommandPaletteProps) => {
type: 'photo',
title: photo.title || photo.id,
subtitle: photo.description || locationSubtitle || `${photo.exif?.Model || 'Photo'}`,
icon: <img src={photo.thumbnailUrl} alt={photo.title || 'Photo'} className="h-6 w-6 rounded object-cover" />,
icon: <PhotoThumbnailIcon photo={photo} />,
action: () => {
const allPhotos = photoLoader.getPhotos()
const photoIndex = allPhotos.findIndex((p) => p.id === photo.id)

View File

@@ -4,6 +4,7 @@ import { m } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import type { PhotoMarker } from '~/types/map'
interface ClusterPhotoGridProps {
@@ -28,52 +29,7 @@ export const ClusterPhotoGrid = ({ photos, onPhotoClick }: ClusterPhotoGridProps
{/* 照片网格 */}
<div className="grid grid-cols-3 gap-2">
{displayPhotos.map((photoMarker, index) => (
<m.div
key={photoMarker.photo.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: index * 0.05,
}}
className="group relative aspect-square overflow-hidden rounded-lg"
>
<Link
to={`/${photoMarker.photo.id}`}
target="_blank"
onClick={(e) => {
e.stopPropagation()
onPhotoClick?.(photoMarker)
}}
className="block h-full w-full"
>
<LazyImage
src={photoMarker.photo.thumbnailUrl || photoMarker.photo.originalUrl}
alt={photoMarker.photo.title || photoMarker.photo.id}
thumbHash={photoMarker.photo.thumbHash}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
rootMargin="200px"
threshold={0.1}
/>
{/* 悬停遮罩 */}
<div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/20" />
{/* 悬停图标 */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="rounded-full bg-black/50 p-2 backdrop-blur-sm">
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</div>
</div>
</Link>
</m.div>
<ClusterPhotoTile key={photoMarker.photo.id} marker={photoMarker} index={index} onPhotoClick={onPhotoClick} />
))}
{/* 更多照片指示器 */}
@@ -148,3 +104,60 @@ export const ClusterPhotoGrid = ({ photos, onPhotoClick }: ClusterPhotoGridProps
</div>
)
}
const ClusterPhotoTile = ({
marker,
index,
onPhotoClick,
}: {
marker: PhotoMarker
index: number
onPhotoClick?: (photo: PhotoMarker) => void
}) => {
const thumbnailSrc = usePhotoThumbnailSrc(marker.photo)
return (
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: index * 0.05,
}}
className="group relative aspect-square overflow-hidden rounded-lg"
>
<Link
to={`/${marker.photo.id}`}
target="_blank"
onClick={(e) => {
e.stopPropagation()
onPhotoClick?.(marker)
}}
className="block h-full w-full"
>
<LazyImage
src={thumbnailSrc || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
rootMargin="200px"
threshold={0.1}
/>
<div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/20" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="rounded-full bg-black/50 p-2 backdrop-blur-sm">
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</div>
</div>
</Link>
</m.div>
)
}

View File

@@ -1,7 +1,10 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { HoverCard, HoverCardContent, HoverCardTrigger, LazyImage } from '@afilmory/ui'
import { m } from 'motion/react'
import { Marker } from 'react-map-gl/maplibre'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import { ClusterPhotoGrid } from '../ClusterPhotoGrid'
import type { ClusterMarkerProps } from './types'
@@ -66,14 +69,7 @@ export const ClusterMarker = ({
return (
<div key={photoMarker.photo.id} className="absolute opacity-30" style={position}>
<LazyImage
src={photoMarker.photo.thumbnailUrl || photoMarker.photo.originalUrl}
alt={photoMarker.photo.title || photoMarker.photo.id}
thumbHash={photoMarker.photo.thumbHash}
className="h-full w-full object-cover"
rootMargin="100px"
threshold={0.1}
/>
<ClusterMosaicImage photo={photoMarker.photo} />
</div>
)
})}
@@ -117,3 +113,18 @@ export const ClusterMarker = ({
</Marker>
)
}
const ClusterMosaicImage = ({ photo }: { photo: PhotoManifestItem }) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
return (
<LazyImage
src={thumbnailSrc || photo.originalUrl}
alt={photo.title || photo.id}
thumbHash={photo.thumbHash}
className="h-full w-full object-cover"
rootMargin="100px"
threshold={0.1}
/>
)
}

View File

@@ -3,6 +3,8 @@ import { m } from 'motion/react'
import { Marker } from 'react-map-gl/maplibre'
import { Link } from 'react-router'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import type { PhotoMarkerPinProps } from './types'
export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }: PhotoMarkerPinProps) => {
@@ -41,10 +43,8 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
{/* Photo background preview */}
<div className="absolute inset-0 overflow-hidden rounded-full">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
<MarkerImage
photo={marker.photo}
className="h-full w-full object-cover opacity-40"
rootMargin="100px"
threshold={0.1}
@@ -98,10 +98,8 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
{/* Photo header */}
<div className="relative h-32 overflow-hidden">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
<MarkerImage
photo={marker.photo}
className="h-full w-full object-cover"
rootMargin="200px"
threshold={0.1}
@@ -180,3 +178,28 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
</Marker>
)
}
const MarkerImage = ({
photo,
className,
rootMargin,
threshold,
}: {
photo: PhotoMarkerPinProps['marker']['photo']
className?: string
rootMargin?: string
threshold?: number
}) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
return (
<LazyImage
src={thumbnailSrc || photo.originalUrl}
alt={photo.title || photo.id}
thumbHash={photo.thumbHash}
className={className}
rootMargin={rootMargin}
threshold={threshold}
/>
)
}

View File

@@ -229,7 +229,7 @@ export const ExifPanel: FC<{
{/* 直方图 */}
<div className="mb-3">
<div className="mb-2 text-xs font-medium text-white/70">{t('exif.histogram')}</div>
<HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
<HistogramChart photo={currentPhoto} />
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useMobile } from '~/hooks/useMobile'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import { nextFrame } from '~/lib/dom'
import type { PhotoManifest } from '~/types/photo'
@@ -150,31 +151,13 @@ export const GalleryThumbnail: FC<{
{photos.slice(startIndex, endIndex + 1).map((photo, sliceIndex) => {
const index = startIndex + sliceIndex
return (
<button
type="button"
<GalleryThumbnailButton
key={photo.id}
className={clsxm(
'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all',
index === currentIndex
? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
: 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0',
)}
style={
isMobile
? {
width: thumbnailSize.mobile,
height: thumbnailSize.mobile,
}
: {
width: thumbnailSize.desktop,
height: thumbnailSize.desktop,
}
}
photo={photo}
isActive={index === currentIndex}
size={isMobile ? thumbnailSize.mobile : thumbnailSize.desktop}
onClick={() => onIndexChange(index)}
>
{photo.thumbHash && <Thumbhash thumbHash={photo.thumbHash} className="size-fill absolute inset-0" />}
<img src={photo.thumbnailUrl} alt={photo.title} className="absolute inset-0 h-full w-full object-cover" />
</button>
/>
)
})}
@@ -191,3 +174,34 @@ export const GalleryThumbnail: FC<{
</m.div>
)
}
const GalleryThumbnailButton: FC<{
photo: PhotoManifest
isActive: boolean
size: number
onClick: () => void
}> = ({ photo, isActive, size, onClick }) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
return (
<button
type="button"
className={clsxm(
'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all',
isActive
? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
: 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0',
)}
style={{
width: size,
height: size,
}}
onClick={onClick}
>
{photo.thumbHash && <Thumbhash thumbHash={photo.thumbHash} className="size-fill absolute inset-0" />}
{thumbnailSrc ? (
<img src={thumbnailSrc} alt={photo.title} className="absolute inset-0 h-full w-full object-cover" />
) : null}
</button>
)
}

View File

@@ -4,6 +4,9 @@ import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import type { PhotoManifest } from '~/types/photo'
interface CompressedHistogramData {
red: number[]
green: number[]
@@ -178,9 +181,9 @@ const drawHistogram = (canvas: HTMLCanvasElement, histogram: CompressedHistogram
}
export const HistogramChart: FC<{
thumbnailUrl: string
photo: PhotoManifest
className?: string
}> = ({ thumbnailUrl, className = '' }) => {
}> = ({ photo, className = '' }) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const previousHistogramRef = useRef<CompressedHistogramData | null>(null)
const animationRef = useRef<number | null>(null)
@@ -188,8 +191,14 @@ export const HistogramChart: FC<{
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const { t } = useTranslation()
const thumbnailUrl = usePhotoThumbnailSrc(photo)
useEffect(() => {
if (!thumbnailUrl) {
setError(true)
setLoading(false)
return
}
setLoading(true)
setError(false)

View File

@@ -317,7 +317,9 @@ export const PhotoViewer = ({
loadingIndicatorRef={loadingIndicatorRef}
isCurrentImage={isCurrentImage}
src={photo.originalUrl}
thumbnailSrc={photo.thumbnailUrl}
originalObjectKey={photo.s3Key}
thumbnailSrc={photo.thumbnailUrl ?? undefined}
thumbnailObjectKey={photo.thumbnailKey ?? null}
alt={photo.title}
width={isCurrentImage ? currentPhoto.width : undefined}
height={isCurrentImage ? currentPhoto.height : undefined}
@@ -333,6 +335,7 @@ export const PhotoViewer = ({
? {
type: 'motion-photo',
imageUrl: photo.originalUrl,
objectKey: photo.s3Key ?? null,
offset: photo.video.offset,
size: photo.video.size,
presentationTimestamp: photo.video.presentationTimestamp,
@@ -341,6 +344,7 @@ export const PhotoViewer = ({
? {
type: 'live-photo',
videoUrl: photo.video.videoUrl,
objectKey: photo.video.s3Key ?? null,
}
: { type: 'none' }
}

View File

@@ -1,13 +1,15 @@
import { clsxm } from '@afilmory/utils'
import { WebGLImageViewer } from '@afilmory/webgl-viewer'
import { AnimatePresence, m } from 'motion/react'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'
import { useMediaQuery } from 'usehooks-ts'
import { useShowContextMenu } from '~/atoms/context-menu'
import { canUseWebGL } from '~/lib/feature'
import type { AssetDescriptor } from '~/lib/secure-asset'
import { getDisplayAssetUrl, isSecureAccessRequired } from '~/lib/secure-asset'
import { SlidingNumber } from '../number/SlidingNumber'
import { DOMImageViewer } from './DOMImageViewer'
@@ -27,6 +29,8 @@ import type { ProgressiveImageProps, WebGLImageViewerRef } from './types'
export const ProgressiveImage = ({
src,
thumbnailSrc,
originalObjectKey,
thumbnailObjectKey,
alt,
width,
height,
@@ -45,6 +49,7 @@ export const ProgressiveImage = ({
loadingIndicatorRef,
}: ProgressiveImageProps) => {
const { t } = useTranslation()
const secureAccessRequired = isSecureAccessRequired()
// State management
const [state, setState] = useProgressiveImageState()
@@ -77,9 +82,56 @@ export const ProgressiveImage = ({
return src
}, [src])
const [resolvedThumbnailSrc, setResolvedThumbnailSrc] = useState<string | null>(thumbnailSrc ?? null)
useEffect(() => {
let cancelled = false
if (!secureAccessRequired) {
setResolvedThumbnailSrc(thumbnailSrc ?? null)
return
}
if (!thumbnailObjectKey) {
setResolvedThumbnailSrc(thumbnailSrc ?? null)
return
}
getDisplayAssetUrl({
objectKey: thumbnailObjectKey,
intent: 'thumbnail',
fallbackUrl: thumbnailSrc ?? null,
})
.then((url) => {
if (!cancelled) {
setResolvedThumbnailSrc(url)
}
})
.catch((error) => {
console.error('Failed to resolve secure thumbnail', error)
if (!cancelled) {
setResolvedThumbnailSrc(thumbnailSrc ?? null)
}
})
return () => {
cancelled = true
}
}, [secureAccessRequired, thumbnailObjectKey, thumbnailSrc])
const imageSource: AssetDescriptor | string = useMemo(() => {
if (!secureAccessRequired) {
return resolvedSrc
}
if (originalObjectKey) {
return {
objectKey: originalObjectKey,
intent: 'photo',
fallbackUrl: resolvedSrc,
}
}
return resolvedSrc
}, [secureAccessRequired, originalObjectKey, resolvedSrc])
// Hooks
const imageLoaderManagerRef = useImageLoader(
resolvedSrc,
imageSource,
isCurrentImage,
highResLoaded,
error,
@@ -123,11 +175,11 @@ export const ProgressiveImage = ({
onTouchEnd={handleLongPressEnd}
>
{/* 缩略图 - 在高分辨率图片未加载或加载失败时显示 */}
{thumbnailSrc && (!isHighResImageRendered || error) && (
{resolvedThumbnailSrc && (!isHighResImageRendered || error) && (
<img
ref={thumbnailRef}
src={thumbnailSrc}
key={thumbnailSrc}
src={resolvedThumbnailSrc}
key={resolvedThumbnailSrc}
alt={alt}
className={clsxm(
'absolute inset-0 h-full w-full object-contain transition-opacity duration-300',

View File

@@ -1,6 +1,7 @@
import type { RefObject } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import type { PhotoManifest } from '~/types/photo'
import type { AnimationFrameRect, PhotoViewerTransition, PhotoViewerTransitionState } from './types'
@@ -34,6 +35,7 @@ export const usePhotoViewerTransitions = ({
currentBlobSrc,
isMobile,
}: UsePhotoViewerTransitionsParams): UsePhotoViewerTransitionsResult => {
const thumbnailSrc = usePhotoThumbnailSrc(currentPhoto ?? null)
const containerRef = useRef<HTMLDivElement | null>(null)
const cachedTriggerRef = useRef<HTMLElement | null>(triggerElement)
const wasOpenRef = useRef(isOpen)
@@ -134,7 +136,7 @@ export const usePhotoViewerTransitions = ({
return
}
const imageSrc = currentBlobSrc || currentPhoto.thumbnailUrl || currentPhoto.originalUrl || null
const imageSrc = currentBlobSrc || thumbnailSrc || currentPhoto?.originalUrl || null
if (!imageSrc) {
setIsViewerContentVisible(true)
@@ -243,7 +245,7 @@ export const usePhotoViewerTransitions = ({
triggerEl instanceof HTMLImageElement && triggerEl.parentElement ? triggerEl.parentElement : triggerEl,
)
const imageSrc = currentPhoto.thumbnailUrl || currentBlobSrc || currentPhoto.originalUrl || null
const imageSrc = thumbnailSrc || currentBlobSrc || currentPhoto?.originalUrl || null
if (!imageSrc) {
wasOpenRef.current = false

View File

@@ -1,12 +1,14 @@
import { LoadingState } from '@afilmory/webgl-viewer'
import type { TFunction } from 'i18next'
import { startTransition, useCallback, useEffect, useRef, useState } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { MenuItemSeparator, MenuItemText } from '~/atoms/context-menu'
import { isMobileDevice } from '~/lib/device-viewport'
import { ImageLoaderManager } from '~/lib/image-loader-manager'
import type { AssetDescriptor } from '~/lib/secure-asset'
import { resolveAssetRequest } from '~/lib/secure-asset'
import type { LivePhotoVideoHandle } from './LivePhotoVideo'
import type { LoadingIndicatorRef } from './LoadingIndicator'
@@ -59,8 +61,22 @@ export const useProgressiveImageState = (): [
]
}
const serializeSourceDescriptor = (descriptor: AssetDescriptor | string | null): string | null => {
if (!descriptor) {
return null
}
if (typeof descriptor === 'string') {
return descriptor
}
return JSON.stringify({
objectKey: descriptor.objectKey ?? null,
intent: descriptor.intent,
fallbackUrl: descriptor.fallbackUrl ?? null,
})
}
export const useImageLoader = (
src: string,
source: AssetDescriptor | string | null,
isCurrentImage: boolean,
highResLoaded: boolean,
error: boolean,
@@ -72,12 +88,13 @@ export const useImageLoader = (
setHighResLoaded?: (loaded: boolean) => void,
setError?: (error: boolean) => void,
setIsHighResImageRendered?: (rendered: boolean) => void,
) => {
): React.RefObject<ImageLoaderManager | null> => {
const { t } = useTranslation()
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
const sourceKey = useMemo(() => serializeSourceDescriptor(source), [source])
useEffect(() => {
if (highResLoaded || error || !isCurrentImage) return
if (highResLoaded || error || !isCurrentImage || !sourceKey || !source) return
// Create new image loader manager
const imageLoaderManager = new ImageLoaderManager()
@@ -96,7 +113,8 @@ export const useImageLoader = (
const loadImage = async () => {
try {
const result = await imageLoaderManager.loadImage(src, {
const resolvedSource = await resolveAssetRequest(source)
const result = await imageLoaderManager.loadImage(resolvedSource, {
onProgress,
onError,
onLoadingStateUpdate: (state) => {
@@ -121,7 +139,15 @@ export const useImageLoader = (
}
cleanup()
loadImage()
loadImage().catch((loadError) => {
console.error('Failed to load image:', loadError)
setError?.(true)
loadingIndicatorRef?.current?.updateLoadingState({
isVisible: true,
isError: true,
errorMessage: t('photo.error.loading'),
})
})
return () => {
imageLoaderManager.cleanup()
@@ -130,7 +156,7 @@ export const useImageLoader = (
highResLoaded,
error,
onProgress,
src,
sourceKey,
onError,
isCurrentImage,
onBlobSrcChange,
@@ -140,6 +166,7 @@ export const useImageLoader = (
setHighResLoaded,
setError,
setIsHighResImageRendered,
source,
])
return imageLoaderManagerRef

View File

@@ -7,13 +7,22 @@ export const SHOW_SCALE_INDICATOR_DURATION = 1000
// Video source 的 sum typeLive Photo 或 Motion Photo
export type VideoSource =
| { type: 'live-photo'; videoUrl: string }
| { type: 'motion-photo'; imageUrl: string; offset: number; size?: number; presentationTimestamp?: number }
| { type: 'live-photo'; videoUrl: string; objectKey?: string | null }
| {
type: 'motion-photo'
imageUrl: string
objectKey?: string | null
offset: number
size?: number
presentationTimestamp?: number
}
| { type: 'none' }
export interface ProgressiveImageProps {
src: string
thumbnailSrc?: string
originalObjectKey?: string | null
thumbnailObjectKey?: string | null
alt: string
width?: number

View File

@@ -6,6 +6,7 @@ const defaultInjectConfig = {
useApi: false,
useNext: false,
useCloud: false,
secureAccessEnabled: false,
}
export const injectConfig = merge(defaultInjectConfig, __CONFIG__)

View File

@@ -1,4 +1,5 @@
export interface InjectConfig {
useApi: boolean
useCloud: boolean
secureAccessEnabled?: boolean
}

View File

@@ -2,6 +2,7 @@ import type { PhotoManifestItem } from '@afilmory/builder'
import { clsxm as cn } from '@afilmory/utils'
import { thumbHashToDataURL } from 'thumbhash'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import {
CarbonIsoOutline,
MaterialSymbolsShutterSpeed,
@@ -21,7 +22,7 @@ interface PhotoItemProps {
export function PhotoItem({ photo, className }: PhotoItemProps) {
// 生成 thumbhash 预览
const thumbHashDataURL = photo.thumbHash ? thumbHashToDataURL(decompressUint8Array(photo.thumbHash)) : null
const thumbnailSrc = usePhotoThumbnailSrc(photo)
const ratio = photo.aspectRatio
// 格式化 EXIF 数据
@@ -91,12 +92,14 @@ export function PhotoItem({ photo, className }: PhotoItemProps) {
{thumbHashDataURL && (
<img src={thumbHashDataURL} alt={photo.title} className="absolute inset-0 size-full" loading="lazy" />
)}
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="absolute inset-0 size-full object-cover object-center"
loading="lazy"
/>
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt={photo.title}
className="absolute inset-0 size-full object-cover object-center"
loading="lazy"
/>
) : null}
</div>
{/* 图片信息和 EXIF 覆盖层 */}

View File

@@ -0,0 +1,44 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { useEffect, useMemo, useState } from 'react'
import { getDisplayAssetUrl, isSecureAccessRequired } from '~/lib/secure-asset'
export const usePhotoThumbnailSrc = (photo: PhotoManifestItem | null): string | null => {
const secureAccess = isSecureAccessRequired()
const fallback = useMemo(() => photo?.thumbnailUrl ?? null, [photo?.thumbnailUrl])
const [src, setSrc] = useState<string | null>(fallback)
useEffect(() => {
let cancelled = false
if (!photo) {
setSrc(null)
return
}
if (!secureAccess || !photo.thumbnailKey) {
setSrc(photo.thumbnailUrl ?? null)
return
}
getDisplayAssetUrl({
objectKey: photo.thumbnailKey,
intent: 'thumbnail',
fallbackUrl: photo.thumbnailUrl ?? null,
})
.then((url) => {
if (!cancelled) {
setSrc(url)
}
})
.catch((error) => {
console.error('Failed to resolve secure thumbnail', error)
if (!cancelled) {
setSrc(photo.thumbnailUrl ?? null)
}
})
return () => {
cancelled = true
}
}, [photo?.id, photo?.thumbnailKey, photo?.thumbnailUrl, secureAccess])
return src
}

View File

@@ -103,7 +103,10 @@ export class ImageLoaderManager {
}
}
async loadImage(src: string, callbacks: LoadingCallbacks = {}): Promise<ImageLoadResult> {
async loadImage(
source: string | { url: string; headers?: Record<string, string> },
callbacks: LoadingCallbacks = {},
): Promise<ImageLoadResult> {
const { onProgress, onError, onLoadingStateUpdate } = callbacks
// Show loading indicator
@@ -114,7 +117,13 @@ export class ImageLoaderManager {
return new Promise((resolve, reject) => {
this.delayTimer = setTimeout(async () => {
const xhr = new XMLHttpRequest()
xhr.open('GET', src)
const target = typeof source === 'string' ? { url: source, headers: {} } : source
xhr.open('GET', target.url)
if (target.headers) {
for (const [key, value] of Object.entries(target.headers)) {
xhr.setRequestHeader(key, value)
}
}
xhr.responseType = 'blob'
xhr.onload = async () => {
@@ -131,11 +140,7 @@ export class ImageLoaderManager {
return
}
const result = await this.processImageBlob(
blob,
src, // 传递原始 URL
callbacks,
)
const result = await this.processImageBlob(blob, target.url, callbacks)
resolve(result)
} catch (error) {
onLoadingStateUpdate?.({

View File

@@ -0,0 +1,232 @@
import { injectConfig } from '~/config'
const SIGN_ENDPOINT = '/api/storage/sign'
const EXPIRATION_SKEW_MS = 5_000
export type AssetDescriptor =
| string
| {
objectKey?: string | null
intent: string
fallbackUrl?: string | null
}
export interface ResolvedAssetRequest {
url: string
headers: Record<string, string>
expiresAt: number
}
type CachedDisplayEntry = {
url: string
expiresAt: number
isObjectUrl: boolean
}
const signedRequestCache = new Map<string, ResolvedAssetRequest>()
const pendingSignedRequests = new Map<string, Promise<ResolvedAssetRequest>>()
const displayUrlCache = new Map<string, CachedDisplayEntry>()
let inferredSecureAccess: boolean | null = null
export const isSecureAccessRequired = (): boolean => {
if (typeof injectConfig.secureAccessEnabled === 'boolean') {
return injectConfig.secureAccessEnabled
}
if (inferredSecureAccess !== null) {
return inferredSecureAccess
}
if (typeof window !== 'undefined' && '__MANIFEST__' in window) {
try {
const manifest = (window as typeof window & { __MANIFEST__?: { data?: Array<{ thumbnailKey?: unknown }> } })
.__MANIFEST__
if (manifest?.data && Array.isArray(manifest.data)) {
inferredSecureAccess = manifest.data.some((entry) => Boolean(entry?.thumbnailKey))
return inferredSecureAccess
}
} catch {
// ignore
}
}
inferredSecureAccess = false
return inferredSecureAccess
}
const needsSecureAccess = (): boolean => isSecureAccessRequired()
const cacheKeyFor = (objectKey: string, intent: string, suffix = ''): string => {
return `${objectKey}::${intent}${suffix ? `::${suffix}` : ''}`
}
const parseExpiresAt = (value: string | undefined): number => {
if (!value) {
return Date.now() + 10 * 60 * 1000
}
const timestamp = Date.parse(value)
if (Number.isNaN(timestamp)) {
return Date.now() + 10 * 60 * 1000
}
return timestamp
}
const requestSignedUrl = async (objectKey: string, intent: string): Promise<ResolvedAssetRequest> => {
const params = new URLSearchParams()
params.set('objectKey', objectKey)
if (intent) {
params.set('intent', intent)
}
params.set('format', 'json')
const response = await fetch(`${SIGN_ENDPOINT}?${params.toString()}`, {
headers: {
accept: 'application/json',
},
credentials: 'include',
})
if (!response.ok) {
throw new Error(`Failed to sign storage asset (status ${response.status})`)
}
const payload = (await response.json()) as {
url: string
expiresAt: string
headers?: Record<string, string>
}
return {
url: payload.url,
headers: payload.headers ?? {},
expiresAt: parseExpiresAt(payload.expiresAt),
}
}
const ensureSignedRequest = async (objectKey: string, intent: string): Promise<ResolvedAssetRequest> => {
const key = cacheKeyFor(objectKey, intent)
const cached = signedRequestCache.get(key)
if (cached && cached.expiresAt - EXPIRATION_SKEW_MS > Date.now()) {
return cached
}
let pending = pendingSignedRequests.get(key)
if (!pending) {
pending = requestSignedUrl(objectKey, intent)
.then((result) => {
signedRequestCache.set(key, result)
return result
})
.finally(() => {
pendingSignedRequests.delete(key)
})
pendingSignedRequests.set(key, pending)
}
return await pending
}
export const resolveAssetRequest = async (descriptor: AssetDescriptor): Promise<ResolvedAssetRequest> => {
if (typeof descriptor === 'string') {
return {
url: descriptor,
headers: {},
expiresAt: Number.MAX_SAFE_INTEGER,
}
}
if (!needsSecureAccess()) {
const fallback = descriptor.fallbackUrl
if (!fallback) {
throw new Error('Missing fallback URL for non-secure asset descriptor')
}
return {
url: fallback,
headers: {},
expiresAt: Number.MAX_SAFE_INTEGER,
}
}
const objectKey = descriptor.objectKey?.trim()
if (!objectKey) {
throw new Error('Secure access is enabled but object key is missing')
}
return await ensureSignedRequest(objectKey, descriptor.intent)
}
const requiresCustomHeaders = (headers: Record<string, string>): boolean => {
return Object.keys(headers).length > 0
}
export const getDisplayAssetUrl = async (descriptor: AssetDescriptor): Promise<string> => {
if (typeof descriptor === 'string') {
return descriptor
}
if (!needsSecureAccess()) {
return descriptor.fallbackUrl ?? ''
}
const objectKey = descriptor.objectKey?.trim()
if (!objectKey) {
throw new Error('Secure asset descriptor is missing object key')
}
const cacheKey = cacheKeyFor(objectKey, descriptor.intent, 'display')
const cached = displayUrlCache.get(cacheKey)
if (cached && cached.expiresAt - EXPIRATION_SKEW_MS > Date.now()) {
return cached.url
}
const request = await resolveAssetRequest(descriptor)
let finalUrl = request.url
let isObjectUrl = false
if (requiresCustomHeaders(request.headers)) {
const response = await fetch(request.url, {
headers: request.headers,
credentials: 'omit',
})
if (!response.ok) {
throw new Error(`Failed to fetch secured asset (status ${response.status})`)
}
const blob = await response.blob()
finalUrl = URL.createObjectURL(blob)
isObjectUrl = true
}
displayUrlCache.set(cacheKey, {
url: finalUrl,
expiresAt: request.expiresAt,
isObjectUrl,
})
if (isObjectUrl) {
const ttl = Math.max(request.expiresAt - Date.now(), 0)
setTimeout(() => {
const entry = displayUrlCache.get(cacheKey)
if (entry && entry.url === finalUrl) {
displayUrlCache.delete(cacheKey)
try {
URL.revokeObjectURL(finalUrl)
} catch {
// ignore
}
}
}, ttl || EXPIRATION_SKEW_MS)
}
return finalUrl
}
export const invalidateDisplayAsset = (objectKey: string, intent: string) => {
const cacheKey = cacheKeyFor(objectKey, intent, 'display')
const entry = displayUrlCache.get(cacheKey)
if (entry?.isObjectUrl) {
try {
URL.revokeObjectURL(entry.url)
} catch {
// ignore
}
}
displayUrlCache.delete(cacheKey)
}

View File

@@ -4,6 +4,7 @@ import { m } from 'motion/react'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import {
CarbonIsoOutline,
@@ -29,6 +30,7 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes
const [isConvertingVideo, setIsConvertingVideo] = useState(false)
const [videoConvertionError, setVideoConversionError] = useState<unknown>(null)
const thumbnailSrc = usePhotoThumbnailSrc(data)
const imageRef = useRef<HTMLImageElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
@@ -223,10 +225,10 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes
{/* Blurhash 占位符 */}
{data.thumbHash && <Thumbhash thumbHash={data.thumbHash} className="absolute inset-0" />}
{!imageError && (
{!imageError && thumbnailSrc && (
<img
ref={imageRef}
src={data.thumbnailUrl}
src={thumbnailSrc}
alt={data.title}
loading="lazy"
className={clsx('absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105')}

View File

@@ -2,6 +2,8 @@ import { photoLoader } from '@afilmory/data'
import { Button, ScrollArea } from '@afilmory/ui'
import { useMemo, useState } from 'react'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
// JSON 语法高亮组件
const JsonHighlight = ({ data }: { data: any }) => {
const jsonString = JSON.stringify(data, null, 2)
@@ -95,82 +97,86 @@ const ManifestStats = ({ data }: { data: any[] }) => {
}
// 照片卡片组件
const PhotoCard = ({ photo, index }: { photo: any; index: number }) => (
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/50">
<div className="absolute inset-0 bg-linear-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10 opacity-0 transition-opacity group-hover:opacity-100" />
const PhotoCard = ({ photo, index }: { photo: any; index: number }) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
<div className="relative p-6">
<div className="flex items-start gap-4">
{/* 缩略图 */}
<div className="flex-shrink-0">
{photo.thumbnailUrl ? (
<div className="relative overflow-hidden rounded-lg">
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="h-16 w-16 object-cover transition-transform group-hover:scale-110"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-zinc-800 text-zinc-600">
<span className="text-xl">📷</span>
</div>
)}
</div>
return (
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/50">
<div className="absolute inset-0 bg-linear-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10 opacity-0 transition-opacity group-hover:opacity-100" />
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="inline-flex h-6 w-8 items-center justify-center rounded bg-zinc-800 font-mono text-xs text-zinc-400">
{index + 1}
</span>
<h3 className="truncate font-medium text-zinc-100">{photo.title}</h3>
<div className="relative p-6">
<div className="flex items-start gap-4">
{/* 缩略图 */}
<div className="flex-shrink-0">
{thumbnailSrc ? (
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailSrc}
alt={photo.title}
className="h-16 w-16 object-cover transition-transform group-hover:scale-110"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-zinc-800 text-zinc-600">
<span className="text-xl">📷</span>
</div>
)}
</div>
{/* 元数据网格 */}
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:grid-cols-3">
<div className="flex items-center gap-2">
<span className="text-zinc-500">📐</span>
<span className="text-zinc-300">
{photo.width} × {photo.height}
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="inline-flex h-6 w-8 items-center justify-center rounded bg-zinc-800 font-mono text-xs text-zinc-400">
{index + 1}
</span>
<h3 className="truncate font-medium text-zinc-100">{photo.title}</h3>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📦</span>
<span className="text-zinc-300">{(photo.size / (1024 * 1024)).toFixed(1)} MB</span>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📷</span>
<span className="truncate text-zinc-300">
{photo.exif?.Make} {photo.exif?.Model}
</span>
</div>
</div>
{/* 标签 */}
{photo.tags && photo.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => (
<span
key={tagIndex}
className="inline-flex items-center rounded-full bg-blue-500/10 px-2.5 py-1 text-xs font-medium text-blue-400 ring-1 ring-blue-500/20"
>
{tag}
{/* 元数据网格 */}
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:grid-cols-3">
<div className="flex items-center gap-2">
<span className="text-zinc-500">📐</span>
<span className="text-zinc-300">
{photo.width} × {photo.height}
</span>
))}
{photo.tags.length > 3 && (
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-400">
+{photo.tags.length - 3} more
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📦</span>
<span className="text-zinc-300">{(photo.size / (1024 * 1024)).toFixed(1)} MB</span>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📷</span>
<span className="truncate text-zinc-300">
{photo.exif?.Make} {photo.exif?.Model}
</span>
)}
</div>
</div>
)}
{/* 标签 */}
{photo.tags && photo.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => (
<span
key={tagIndex}
className="inline-flex items-center rounded-full bg-blue-500/10 px-2.5 py-1 text-xs font-medium text-blue-400 ring-1 ring-blue-500/20"
>
{tag}
</span>
))}
{photo.tags.length > 3 && (
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-400">
+{photo.tags.length - 3} more
</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
)
}
export const Component = () => {
const [searchTerm, setSearchTerm] = useState('')

View File

@@ -1,6 +1,8 @@
import { photoLoader } from '@afilmory/data'
import { ScrollArea, Thumbhash } from '@afilmory/ui'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
export const Component = () => {
const photos = photoLoader.getPhotos()
@@ -8,28 +10,39 @@ export const Component = () => {
<ScrollArea rootClassName="h-screen">
<div className="columns-4 gap-0">
{photos.map((photo) => (
<div
key={photo.id}
className="group relative m-2"
style={{
paddingBottom: `${(photo.height / photo.width) * 100}%`,
}}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
height={photo.height}
width={photo.width}
className="absolute inset-0"
/>
{photo.thumbHash && (
<div className="absolute inset-0 opacity-0 group-hover:opacity-100">
<Thumbhash thumbHash={photo.thumbHash} />
</div>
)}
</div>
<DebugPhotoTile key={photo.id} photo={photo} />
))}
</div>
</ScrollArea>
)
}
const DebugPhotoTile = ({ photo }: { photo: ReturnType<typeof photoLoader.getPhotos>[number] }) => {
const thumbnailSrc = usePhotoThumbnailSrc(photo)
return (
<div
className="group relative m-2"
style={{
paddingBottom: `${(photo.height / photo.width) * 100}%`,
}}
>
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt={photo.title}
height={photo.height}
width={photo.width}
className="absolute inset-0"
/>
) : (
<div className="bg-fill-secondary absolute inset-0" />
)}
{photo.thumbHash && (
<div className="absolute inset-0 opacity-0 group-hover:opacity-100">
<Thumbhash thumbHash={photo.thumbHash} />
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { RemoveScroll } from 'react-remove-scroll'
import { NotFound } from '~/components/common/NotFound'
import { PhotoViewer } from '~/components/ui/photo-viewer'
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import { useTitle } from '~/hooks/useTitle'
import { deriveAccentFromSources } from '~/lib/color'
@@ -23,18 +24,19 @@ export const Component = () => {
useTitle(photos[photoViewer.currentIndex]?.title || 'Not Found')
const [accentColor, setAccentColor] = useState<string | null>(null)
const currentPhoto = photos[photoViewer.currentIndex] ?? null
const thumbnailSrc = usePhotoThumbnailSrc(currentPhoto)
useEffect(() => {
const current = photos[photoViewer.currentIndex]
if (!current) return
if (!currentPhoto) return
let isCancelled = false
;(async () => {
try {
const color = await deriveAccentFromSources({
thumbHash: current.thumbHash,
thumbnailUrl: current.thumbnailUrl,
thumbHash: currentPhoto.thumbHash,
thumbnailUrl: thumbnailSrc,
})
if (!isCancelled) {
const $css = document.createElement('style')
@@ -59,7 +61,7 @@ export const Component = () => {
return () => {
isCancelled = true
}
}, [photoViewer.currentIndex, photos])
}, [photoViewer.currentIndex, photos, thumbnailSrc, currentPhoto])
if (!photos[photoViewer.currentIndex]) {
return <NotFound />

View File

@@ -1,20 +1,30 @@
import type { AfilmoryManifest, CameraInfo, LensInfo, PhotoManifestItem } from '@afilmory/builder'
import type {
AfilmoryManifest,
CameraInfo,
LensInfo,
ManagedStorageConfig,
PhotoManifestItem,
S3CompatibleConfig,
StorageConfig,
} from '@afilmory/builder'
import { DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY } from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db'
import { APP_GLOBAL_PREFIX } from 'core/app.constants'
import { DbAccessor } from 'core/database/database.provider'
import { normalizedBoolean } from 'core/helpers/normalize.helper'
import { SettingService } from 'core/modules/configuration/setting/setting.service'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { StorageAccessService } from 'core/modules/content/photo/access/storage-access.service'
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { and, eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
const DEFAULT_THUMBNAIL_EXTENSION = '.jpg'
@injectable()
export class ManifestService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly settingService: SettingService,
private readonly systemSettingService: SystemSettingService,
private readonly photoStorageService: PhotoStorageService,
private readonly storageAccessService: StorageAccessService,
) {}
async getManifest(): Promise<AfilmoryManifest> {
@@ -37,7 +47,11 @@ export class ManifestService {
}
}
const secureAccessEnabled = await this.isSecureAccessEnabled(tenant.tenant.id)
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference(
storageConfig,
tenant.tenant.id,
)
const items: PhotoManifestItem[] = []
for (const record of records) {
@@ -53,6 +67,9 @@ export class ManifestService {
if (normalized.video?.type === 'live-photo' && normalized.video.s3Key) {
normalized.video.videoUrl = this.createProxyUrl(normalized.video.s3Key, 'live-video')
}
const thumbnailKey = this.resolveThumbnailStorageKey(storageConfig, normalized.id)
normalized.thumbnailKey = thumbnailKey
normalized.thumbnailUrl = thumbnailKey ? this.createProxyUrl(thumbnailKey, 'thumbnail') : null
}
items.push(normalized)
}
@@ -140,15 +157,6 @@ export class ManifestService {
return Array.from(lensMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName))
}
private async isSecureAccessEnabled(tenantId: string): Promise<boolean> {
const activeProvider = await this.settingService.get('builder.storage.activeProvider', { tenantId })
if (activeProvider?.trim() === 'managed') {
return await this.systemSettingService.isManagedStorageSecureAccessEnabled()
}
const value = await this.settingService.get('photo.storage.secureAccess', { tenantId })
return normalizedBoolean(value ?? 'false')
}
private createProxyUrl(storageKey: string, intent = 'photo'): string {
const params = new URLSearchParams()
params.set('objectKey', storageKey)
@@ -157,4 +165,68 @@ export class ManifestService {
}
return `${APP_GLOBAL_PREFIX}/storage/sign?${params.toString()}`
}
private resolveThumbnailStorageKey(storageConfig: StorageConfig, photoId: string): string | null {
if (!photoId) {
return null
}
const fileName = `${photoId}${DEFAULT_THUMBNAIL_EXTENSION}`
const prefix = this.resolveThumbnailRemotePrefix(storageConfig)
if (!prefix) {
return fileName
}
return this.joinSegments(prefix, fileName)
}
private resolveThumbnailRemotePrefix(storageConfig: StorageConfig): string | null {
const directory = this.normalizeStorageSegment(DEFAULT_THUMBNAIL_DIRECTORY)
if (!directory) {
return null
}
switch (storageConfig.provider) {
case 'managed': {
const managedConfig = storageConfig as ManagedStorageConfig
const managedBase = this.extractManagedBasePrefix(managedConfig)
return this.joinSegments(managedBase, directory)
}
case 's3':
case 'oss':
case 'cos': {
const s3Config = storageConfig as S3CompatibleConfig
const base = this.normalizeStorageSegment(s3Config.prefix)
return this.joinSegments(base, directory)
}
default: {
return directory
}
}
}
private extractManagedBasePrefix(config: ManagedStorageConfig): string | null {
if (!config.basePrefix) {
return null
}
return this.normalizeStorageSegment(config.basePrefix)
}
private normalizeStorageSegment(value?: string | null): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (!trimmed) {
return null
}
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
return normalized.length > 0 ? normalized : null
}
private joinSegments(...segments: Array<string | null | undefined>): string | null {
const filtered = segments.filter((segment): segment is string => typeof segment === 'string' && segment.length > 0)
if (filtered.length === 0) {
return null
}
return filtered.map((segment) => segment.replaceAll(/^\/+|\/+$/g, '')).join('/')
}
}

View File

@@ -18,7 +18,7 @@ export class StorageAccessController {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '安全访问尚未启用' })
}
const storageKey = query.objectKey?.trim() || query.key?.trim() || ''
const { url, expiresAt } = await this.storageAccessService.issueSignedUrl({
const { url, expiresAt, headers } = await this.storageAccessService.issueSignedUrl({
storageKey,
intent: query.intent?.trim() || undefined,
ttlSeconds: query.ttl,
@@ -27,18 +27,10 @@ export class StorageAccessController {
referer: context.req.header('referer') ?? context.req.header('referrer') ?? null,
})
if (this.shouldReturnJson(context, query.format)) {
return { url, expiresAt }
return {
url,
expiresAt,
headers,
}
return context.redirect(url, 302)
}
private shouldReturnJson(context: Context, format?: string | null): boolean {
if (format === 'json') {
return true
}
const accept = context.req.header('accept')?.toLowerCase() ?? ''
return accept.includes('application/json')
}
}

View File

@@ -1,6 +1,6 @@
import type { B2Config, ManagedStorageConfig, S3CompatibleConfig, StorageConfig } from '@afilmory/builder'
import { createS3Client } from '@afilmory/builder/s3/client.js'
import { DATABASE_ONLY_PROVIDER, generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db'
import { generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db'
import { Sha256 } from '@aws-crypto/sha256-js'
import { HttpRequest } from '@smithy/protocol-http'
import { SignatureV4 } from '@smithy/signature-v4'
@@ -40,6 +40,7 @@ export type IssueSignedUrlResult = {
url: string
expiresAt: string
tokenId: string
headers: Record<string, string>
}
const DEFAULT_TTL_SECONDS = 600
@@ -89,6 +90,16 @@ export class StorageAccessService {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少有效的 storage key' })
}
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const secureAccessEnabled = await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id)
if (!secureAccessEnabled) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Secure access is not enabled.' })
}
const target = this.resolveRemoteTarget(storageConfig, normalizedKey, tenant.tenant.id)
const ttl = this.normalizeTtlSeconds(options.ttlSeconds)
const { url, expiresAt, headers } = await this.createProviderSignedUrl(target, ttl)
const tokenId = generateId()
const record = await db
.select({
id: photoAssets.id,
@@ -100,71 +111,56 @@ export class StorageAccessService {
.limit(1)
.then((rows) => rows[0])
if (!record) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到对应的图片资源' })
}
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前资源不支持生成访问链接' })
}
if (record) {
const now = new Date().toISOString()
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const secureAccessEnabled = await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id)
if (!secureAccessEnabled) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Secure access is not enabled.' })
}
const target = this.resolveRemoteTarget(storageConfig, normalizedKey, tenant.tenant.id)
const ttl = this.normalizeTtlSeconds(options.ttlSeconds)
const { url, expiresAt } = await this.createProviderSignedUrl(target, ttl)
const tokenId = generateId()
const now = new Date().toISOString()
await db.insert(photoAccessLogs).values({
id: generateId(),
tenantId: tenant.tenant.id,
photoAssetId: record.id,
photoId: record.photoId,
storageKey: normalizedKey,
provider: target.kind,
intent: options.intent?.trim() || 'original',
tokenId,
signedUrl: url,
status: 'issued',
clientIp: options.clientIp ?? null,
userAgent: options.userAgent ?? null,
referer: options.referer ?? null,
expiresAt,
createdAt: now,
updatedAt: now,
})
await db
.insert(photoAccessStats)
.values({
await db.insert(photoAccessLogs).values({
id: generateId(),
tenantId: tenant.tenant.id,
photoAssetId: record.id,
photoId: record.photoId,
viewCount: 1,
lastViewedAt: now,
storageKey: normalizedKey,
provider: target.kind,
intent: options.intent?.trim() || 'original',
tokenId,
signedUrl: url,
status: 'issued',
clientIp: options.clientIp ?? null,
userAgent: options.userAgent ?? null,
referer: options.referer ?? null,
expiresAt,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [photoAccessStats.tenantId, photoAccessStats.photoAssetId],
set: {
viewCount: sql`${photoAccessStats.viewCount} + 1`,
lastViewedAt: now,
updatedAt: now,
photoId: record.photoId,
},
})
return { url, expiresAt, tokenId }
await db
.insert(photoAccessStats)
.values({
tenantId: tenant.tenant.id,
photoAssetId: record.id,
photoId: record.photoId,
viewCount: 1,
lastViewedAt: now,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [photoAccessStats.tenantId, photoAccessStats.photoAssetId],
set: {
viewCount: sql`${photoAccessStats.viewCount} + 1`,
lastViewedAt: now,
updatedAt: now,
photoId: record.photoId,
},
})
}
return { url, expiresAt, tokenId, headers }
}
private async createProviderSignedUrl(
target: RemoteAccessTarget,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
if (target.kind === 's3') {
return await this.createS3SignedUrl(target.config, target.objectKey, ttlSeconds)
}
@@ -363,7 +359,7 @@ export class StorageAccessService {
config: S3CompatibleConfig,
key: string,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
if (!config.accessKeyId || !config.secretAccessKey) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'S3 存储配置缺少访问密钥' })
}
@@ -411,6 +407,7 @@ export class StorageAccessService {
return {
url: this.formatHttpRequestUrl(signed as HttpRequest),
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
headers: {},
}
}
}
@@ -423,17 +420,21 @@ class B2SigningClient {
config: B2Config,
remoteKey: string,
ttlSeconds: number,
): Promise<{ url: string; expiresAt: string }> {
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
const normalizedKey = this.encodeFileName(remoteKey)
const authorization = await this.authorize(config)
const bucketName = await this.resolveBucketName(config, authorization)
const validDuration = Math.min(Math.max(ttlSeconds, MIN_TTL_SECONDS), MAX_TTL_SECONDS)
const token = await this.getDownloadToken(config, authorization, remoteKey, validDuration)
const baseUrl = authorization.downloadUrl.replace(/\/+$/, '')
const url = `${baseUrl}/file/${bucketName}/${normalizedKey}?Authorization=${token}`
const url = `${config.customDomain || baseUrl}/file/${bucketName}/${normalizedKey}`
return {
url,
expiresAt: new Date(Date.now() + validDuration * 1000).toISOString(),
headers: {
Authorization: token,
},
}
}

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
import type { PhotoManifestItem } from '@afilmory/builder'
import { SiteSettingService } from 'core/modules/configuration/site-setting/site-setting.service'
import { ManifestService } from 'core/modules/content/manifest/manifest.service'
import { StorageAccessService } from 'core/modules/content/photo/access/storage-access.service'
import type { Context } from 'hono'
import { DOMParser } from 'linkedom'
import { injectable } from 'tsyringe'
@@ -56,6 +57,7 @@ export class StaticWebService extends StaticAssetService {
private readonly manifestService: ManifestService,
private readonly siteSettingService: SiteSettingService,
private readonly staticAssetHostService: StaticAssetHostService,
private readonly storageAccessService: StorageAccessService,
) {
super({
routeSegment: STATIC_WEB_ROUTE_SEGMENT,
@@ -68,7 +70,8 @@ export class StaticWebService extends StaticAssetService {
protected override async decorateDocument(document: StaticAssetDocument): Promise<void> {
const siteConfig = await this.siteSettingService.getSiteConfig()
this.injectConfigScript(document, siteConfig)
const secureAccessEnabled = await this.storageAccessService.isSecureAccessEnabled().catch(() => false)
this.injectConfigScript(document, siteConfig, secureAccessEnabled)
this.injectSiteMetadata(document, siteConfig)
await this.injectManifestScript(document)
}
@@ -146,7 +149,11 @@ export class StaticWebService extends StaticAssetService {
}
}
private injectConfigScript(document: StaticAssetDocument, siteConfig: TenantSiteConfig): void {
private injectConfigScript(
document: StaticAssetDocument,
siteConfig: TenantSiteConfig,
secureAccessEnabled: boolean,
): void {
const configScript = document.head?.querySelector('#config')
if (!configScript) {
return
@@ -154,6 +161,7 @@ export class StaticWebService extends StaticAssetService {
const payload = JSON.stringify({
useCloud: true,
secureAccessEnabled,
})
const siteConfigPayload = JSON.stringify(siteConfig)
configScript.textContent = `window.__CONFIG__ = ${payload};window.__SITE_CONFIG__ = ${siteConfigPayload}`

View File

@@ -59,7 +59,12 @@ export interface PhotoManifestItem extends PhotoInfo {
id: string
originalUrl: string
format: string
thumbnailUrl: string
thumbnailUrl: string | null
/**
* Optional object storage key for the generated thumbnail when hosted privately.
* Consumers can use this to request signed URLs.
*/
thumbnailKey?: string | null
thumbHash: string | null
width: number
height: number