44} from "@aws-sdk/client-s3" ;
55import { eq } from "drizzle-orm" ;
66import { type NextRequest , NextResponse } from "next/server" ;
7+ import sharp from "sharp" ;
78import { verifySessionToken } from "@/lib/auth" ;
89import { getUserContext } from "@/lib/auth-api" ;
910import { db } from "@/lib/db" ;
@@ -26,6 +27,47 @@ async function streamToBuffer(stream: AsyncIterable<Uint8Array>) {
2627 return Buffer . concat ( chunks ) ;
2728}
2829
30+ async function isValidThumbnailBuffer ( buffer : Buffer ) {
31+ if ( buffer . length < 32 ) return false ;
32+ try {
33+ const metadata = await sharp ( buffer , { failOn : "none" } ) . metadata ( ) ;
34+ return Boolean ( metadata . width && metadata . height && metadata . format ) ;
35+ } catch {
36+ return false ;
37+ }
38+ }
39+
40+ async function fetchMediaObject (
41+ key : string ,
42+ request : NextRequest ,
43+ range ?: string ,
44+ ) {
45+ return await s3Client . send (
46+ new GetObjectCommand ( {
47+ Bucket : S3_BUCKET_NAME ,
48+ Key : key ,
49+ Range : range ,
50+ IfNoneMatch : request . headers . get ( "if-none-match" ) ?? undefined ,
51+ IfModifiedSince : request . headers . get ( "if-modified-since" )
52+ ? new Date ( request . headers . get ( "if-modified-since" ) as string )
53+ : undefined ,
54+ } ) ,
55+ ) ;
56+ }
57+
58+ async function fetchValidThumbnail (
59+ key : string ,
60+ request : NextRequest ,
61+ ) : Promise < { response : GetObjectCommandOutput ; buffer : Buffer } | null > {
62+ const response = await fetchMediaObject ( key , request ) ;
63+ if ( ! response . Body ) return null ;
64+ const buffer = await streamToBuffer (
65+ response . Body as AsyncIterable < Uint8Array > ,
66+ ) ;
67+ if ( ! ( await isValidThumbnailBuffer ( buffer ) ) ) return null ;
68+ return { response, buffer } ;
69+ }
70+
2971async function generateMissingImageThumbnail (
3072 mediaItem : typeof media . $inferSelect ,
3173) {
@@ -65,6 +107,16 @@ async function generateMissingImageThumbnail(
65107 }
66108}
67109
110+ async function regenerateAndFetchValidThumbnail (
111+ mediaItem : typeof media . $inferSelect ,
112+ request : NextRequest ,
113+ ) {
114+ const regeneratedThumbnailKey =
115+ await generateMissingImageThumbnail ( mediaItem ) ;
116+ if ( ! regeneratedThumbnailKey ) return null ;
117+ return await fetchValidThumbnail ( regeneratedThumbnailKey , request ) ;
118+ }
119+
68120export async function GET (
69121 request : NextRequest ,
70122 {
@@ -186,61 +238,73 @@ export async function GET(
186238 s3Key = getThumbnailS3Key ( mediaItem . id ) ;
187239 }
188240 }
189- const command = new GetObjectCommand ( {
190- Bucket : S3_BUCKET_NAME ,
191- Key : s3Key ,
192- Range : requestRange ,
193- IfNoneMatch : request . headers . get ( "if-none-match" ) ?? undefined ,
194- IfModifiedSince : request . headers . get ( "if-modified-since" )
195- ? new Date ( request . headers . get ( "if-modified-since" ) as string )
196- : undefined ,
197- } ) ;
198241 let s3Response : GetObjectCommandOutput ;
242+ let responseBody : BodyInit | ReadableStream ;
243+ let responseBodyLength : number | undefined ;
244+ const shouldValidateThumbnail =
245+ variant === "thumbnail" && mediaItem . mimeType . startsWith ( "image/" ) ;
199246 try {
200- s3Response = await s3Client . send ( command ) ;
201- if (
202- variant === "thumbnail" &&
203- mediaItem . mimeType . startsWith ( "image/" ) &&
204- ! mediaItem . thumbnailS3Key &&
205- s3Key === getThumbnailS3Key ( mediaItem . id )
206- ) {
207- await db
208- . update ( media )
209- . set ( { thumbnailS3Key : s3Key } )
210- . where ( eq ( media . id , mediaItem . id ) ) ;
247+ if ( shouldValidateThumbnail ) {
248+ const validThumbnail = await fetchValidThumbnail ( s3Key , request ) ;
249+ if ( validThumbnail ) {
250+ s3Response = validThumbnail . response ;
251+ responseBody = validThumbnail . buffer as unknown as BodyInit ;
252+ responseBodyLength = validThumbnail . buffer . length ;
253+ if (
254+ ! mediaItem . thumbnailS3Key &&
255+ s3Key === getThumbnailS3Key ( mediaItem . id )
256+ ) {
257+ await db
258+ . update ( media )
259+ . set ( { thumbnailS3Key : s3Key } )
260+ . where ( eq ( media . id , mediaItem . id ) ) ;
261+ }
262+ } else {
263+ const regeneratedThumbnail = await regenerateAndFetchValidThumbnail (
264+ mediaItem ,
265+ request ,
266+ ) ;
267+ if ( ! regeneratedThumbnail ) {
268+ return new NextResponse ( "Failed to generate thumbnail" , {
269+ status : 502 ,
270+ } ) ;
271+ }
272+ s3Response = regeneratedThumbnail . response ;
273+ responseBody = regeneratedThumbnail . buffer as unknown as BodyInit ;
274+ responseBodyLength = regeneratedThumbnail . buffer . length ;
275+ }
276+ } else {
277+ s3Response = await fetchMediaObject ( s3Key , request , requestRange ) ;
278+ responseBody = s3Response . Body as ReadableStream ;
211279 }
212280 } catch ( error : any ) {
213281 if ( error ?. $metadata ?. httpStatusCode === 304 ) {
214282 return new NextResponse ( null , { status : 304 } ) ;
215283 }
216- if ( variant === "thumbnail" && mediaItem . mimeType . startsWith ( "image/" ) ) {
217- const regeneratedThumbnailKey =
218- await generateMissingImageThumbnail ( mediaItem ) ;
219- if ( regeneratedThumbnailKey ) {
220- try {
221- s3Response = await s3Client . send (
222- new GetObjectCommand ( {
223- Bucket : S3_BUCKET_NAME ,
224- Key : regeneratedThumbnailKey ,
225- Range : requestRange ,
226- IfNoneMatch : request . headers . get ( "if-none-match" ) ?? undefined ,
227- IfModifiedSince : request . headers . get ( "if-modified-since" )
228- ? new Date ( request . headers . get ( "if-modified-since" ) as string )
229- : undefined ,
230- } ) ,
231- ) ;
232- } catch ( retryError : any ) {
233- if ( retryError ?. $metadata ?. httpStatusCode === 304 ) {
234- return new NextResponse ( null , { status : 304 } ) ;
235- }
236- logger . error (
237- "Failed to fetch regenerated thumbnail from S3:" ,
238- retryError ,
239- ) ;
240- return new NextResponse ( "Failed to fetch media" , { status : 502 } ) ;
284+ if ( shouldValidateThumbnail ) {
285+ try {
286+ const regeneratedThumbnail = await regenerateAndFetchValidThumbnail (
287+ mediaItem ,
288+ request ,
289+ ) ;
290+ if ( regeneratedThumbnail ) {
291+ s3Response = regeneratedThumbnail . response ;
292+ responseBody = regeneratedThumbnail . buffer as unknown as BodyInit ;
293+ responseBodyLength = regeneratedThumbnail . buffer . length ;
294+ } else {
295+ logger . error ( `Failed to fetch or regenerate thumbnail:` , error ) ;
296+ return new NextResponse ( "Failed to generate thumbnail" , {
297+ status : 502 ,
298+ } ) ;
241299 }
242- } else {
243- logger . error ( `Failed to fetch from S3:` , error ) ;
300+ } catch ( retryError : any ) {
301+ if ( retryError ?. $metadata ?. httpStatusCode === 304 ) {
302+ return new NextResponse ( null , { status : 304 } ) ;
303+ }
304+ logger . error (
305+ "Failed to fetch regenerated thumbnail from S3:" ,
306+ retryError ,
307+ ) ;
244308 return new NextResponse ( "Failed to fetch media" , { status : 502 } ) ;
245309 }
246310 } else {
@@ -255,7 +319,9 @@ export async function GET(
255319 ( variant === "thumbnail" ? "image/jpeg" : mediaItem . mimeType ) ,
256320 ) ;
257321 headers . set ( "X-Content-Type-Options" , "nosniff" ) ;
258- if ( s3Response . ContentLength ) {
322+ if ( responseBodyLength ) {
323+ headers . set ( "Content-Length" , String ( responseBodyLength ) ) ;
324+ } else if ( s3Response . ContentLength ) {
259325 headers . set ( "Content-Length" , String ( s3Response . ContentLength ) ) ;
260326 }
261327 if ( s3Response . ETag ) {
@@ -297,7 +363,7 @@ export async function GET(
297363 } else {
298364 headers . set ( "Content-Disposition" , `inline; filename="${ filename } "` ) ;
299365 }
300- return new NextResponse ( s3Response . Body as ReadableStream , {
366+ return new NextResponse ( responseBody , {
301367 status : s3Response . ContentRange ? 206 : 200 ,
302368 headers,
303369 } ) ;
0 commit comments