Skip to content

Commit 41b3369

Browse files
committed
Harden HEIC thumbnail recovery
1 parent 847fe17 commit 41b3369

6 files changed

Lines changed: 272 additions & 73 deletions

File tree

app/api/cron/repair-thumbnails/route.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
22
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5-
import { GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
5+
import { GetObjectCommand } from "@aws-sdk/client-s3";
66
import { eq, gt } from "drizzle-orm";
77
import ffmpeg from "fluent-ffmpeg";
88
import { type NextRequest, NextResponse } from "next/server";
@@ -20,12 +20,12 @@ import { recordCronJob } from "@/lib/telemetry";
2020
const BATCH_SIZE = 500;
2121
const REPAIR_CONCURRENCY = 8;
2222

23-
async function objectExists(key: string) {
23+
async function thumbnailIsValid(key: string) {
2424
try {
25-
await s3Client.send(
26-
new HeadObjectCommand({ Bucket: process.env.S3_BUCKET_NAME, Key: key }),
27-
);
28-
return true;
25+
const buffer = await getObjectBuffer([key]);
26+
if (buffer.length < 32) return false;
27+
const metadata = await sharp(buffer, { failOn: "none" }).metadata();
28+
return Boolean(metadata.width && metadata.height && metadata.format);
2929
} catch {
3030
return false;
3131
}
@@ -197,14 +197,14 @@ export async function GET(request: NextRequest) {
197197
});
198198
const results = await mapLimit(rows, REPAIR_CONCURRENCY, async (item) => {
199199
const hasThumbnail = item.thumbnailS3Key
200-
? await objectExists(item.thumbnailS3Key)
200+
? await thumbnailIsValid(item.thumbnailS3Key)
201201
: false;
202202
if (hasThumbnail) return { repaired: 0, failed: 0 };
203203
try {
204204
const canonicalThumbnailS3Key = getThumbnailS3Key(item.id);
205205
if (
206206
item.thumbnailS3Key !== canonicalThumbnailS3Key &&
207-
(await objectExists(canonicalThumbnailS3Key))
207+
(await thumbnailIsValid(canonicalThumbnailS3Key))
208208
) {
209209
await db
210210
.update(media)

app/media/[mediaId]/[[...variant]]/route.ts

Lines changed: 115 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
} from "@aws-sdk/client-s3";
55
import { eq } from "drizzle-orm";
66
import { type NextRequest, NextResponse } from "next/server";
7+
import sharp from "sharp";
78
import { verifySessionToken } from "@/lib/auth";
89
import { getUserContext } from "@/lib/auth-api";
910
import { 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+
2971
async 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+
68120
export 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
});

app/search/SearchGallery.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const PhotoDetailModal = dynamic(
3838

3939
const INITIAL_VISIBLE_ITEMS = 60;
4040
const VISIBLE_ITEMS_INCREMENT = 60;
41+
const THUMBNAIL_AUTO_RETRIES = 5;
4142

4243
function getMediaProxyUrl(
4344
mediaId: string,
@@ -51,7 +52,9 @@ function getMediaProxyUrl(
5152
function getThumbnailUrl(item: MediaItem) {
5253
return (
5354
item.thumbnailUrl ||
54-
(item.thumbnailS3Key ? getMediaProxyUrl(item.id, "thumbnail") : undefined)
55+
(item.thumbnailS3Key || item.mimeType.startsWith("image/")
56+
? getMediaProxyUrl(item.id, "thumbnail")
57+
: undefined)
5558
);
5659
}
5760

@@ -87,6 +90,7 @@ function LazySearchGalleryImage({ src, alt }: { src?: string; alt: string }) {
8790
const ref = useRef<HTMLDivElement>(null);
8891
const [isVisible, setIsVisible] = useState(false);
8992
const [isLoaded, setIsLoaded] = useState(false);
93+
const [retryCount, setRetryCount] = useState(0);
9094

9195
useEffect(() => {
9296
const element = ref.current;
@@ -118,7 +122,11 @@ function LazySearchGalleryImage({ src, alt }: { src?: string; alt: string }) {
118122
{isVisible && src && (
119123
<img
120124
key={src}
121-
src={src}
125+
src={
126+
retryCount > 0
127+
? `${src}${src.includes("?") ? "&" : "?"}retry=${retryCount}`
128+
: src
129+
}
122130
alt={alt}
123131
loading="eager"
124132
decoding="async"
@@ -127,6 +135,14 @@ function LazySearchGalleryImage({ src, alt }: { src?: string; alt: string }) {
127135
isLoaded ? "opacity-100" : "opacity-0"
128136
}`}
129137
onLoad={() => setIsLoaded(true)}
138+
onError={() => {
139+
setIsLoaded(false);
140+
if (retryCount >= THUMBNAIL_AUTO_RETRIES) return;
141+
window.setTimeout(
142+
() => setRetryCount((count) => count + 1),
143+
350 * (retryCount + 1),
144+
);
145+
}}
130146
/>
131147
)}
132148
</div>
@@ -614,7 +630,11 @@ export default function SearchGallery({
614630
}}
615631
aria-label={`View ${item.filename}`}
616632
>
617-
<LazySearchGalleryImage src={url} alt={item.filename} />
633+
<LazySearchGalleryImage
634+
key={url ?? item.id}
635+
src={url}
636+
alt={item.filename}
637+
/>
618638

619639
{isVideo && url && <VideoIndicator size="lg" />}
620640

components/media/MediaGallery.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const PhotoDetailModal = dynamic(() => import("./PhotoDetailModal"), {
2929

3030
const INITIAL_VISIBLE_ITEMS = 60;
3131
const VISIBLE_ITEMS_INCREMENT = 60;
32+
const THUMBNAIL_AUTO_RETRIES = 5;
3233

3334
let galleryImageObserver: IntersectionObserver | null = null;
3435

@@ -53,6 +54,7 @@ function LazyGalleryImage({ src, alt }: { src?: string; alt: string }) {
5354
const ref = useRef<HTMLDivElement>(null);
5455
const [isVisible, setIsVisible] = useState(false);
5556
const [isLoaded, setIsLoaded] = useState(false);
57+
const [retryCount, setRetryCount] = useState(0);
5658

5759
useEffect(() => {
5860
const element = ref.current;
@@ -82,16 +84,28 @@ function LazyGalleryImage({ src, alt }: { src?: string; alt: string }) {
8284
)}
8385
{isVisible && src && (
8486
<img
85-
src={src}
87+
src={
88+
retryCount > 0
89+
? `${src}${src.includes("?") ? "&" : "?"}retry=${retryCount}`
90+
: src
91+
}
8692
alt={alt}
8793
decoding="async"
8894
loading="eager"
8995
fetchPriority="low"
90-
key={src}
96+
key={`${src}-${retryCount}`}
9197
className={`h-full w-full object-cover transition-opacity duration-700 ease-out ${
9298
isLoaded ? "opacity-100" : "opacity-0"
9399
}`}
94100
onLoad={() => setIsLoaded(true)}
101+
onError={() => {
102+
setIsLoaded(false);
103+
if (retryCount >= THUMBNAIL_AUTO_RETRIES) return;
104+
window.setTimeout(
105+
() => setRetryCount((count) => count + 1),
106+
350 * (retryCount + 1),
107+
);
108+
}}
95109
/>
96110
)}
97111
</div>
@@ -536,7 +550,11 @@ export default function MediaGallery({
536550
}}
537551
aria-label={`View ${item.filename}`}
538552
>
539-
<LazyGalleryImage src={url} alt={item.filename} />
553+
<LazyGalleryImage
554+
key={url ?? item.id}
555+
src={url}
556+
alt={item.filename}
557+
/>
540558

541559
{isVideo && url && <VideoIndicator size="lg" />}
542560

0 commit comments

Comments
 (0)