Skip to content

Commit d2d625b

Browse files
authored
refactor: server-side QR extraction for institution submissions (#560)
1 parent b38e59f commit d2d625b

File tree

3 files changed

+131
-124
lines changed

3 files changed

+131
-124
lines changed

app/(admin)/admin/institutions/_lib/upload-qr-replacement.ts

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
"use server";
22

3-
import {
4-
BinaryBitmap,
5-
HybridBinarizer,
6-
QRCodeReader,
7-
RGBLuminanceSource,
8-
} from "@zxing/library";
93
import { eq } from "drizzle-orm";
104
import { revalidatePath } from "next/cache";
11-
import sharp from "sharp";
125
import { db } from "@/db";
136
import { institutions } from "@/db/institutions";
147
import { requireAdminSession } from "@/lib/auth-helpers";
158
import { r2Storage } from "@/lib/integrations/r2-client";
9+
import { decodeQrFromBuffer } from "@/lib/qr-decode";
1610

1711
export type UploadQrReplacementResult = {
1812
success: boolean;
@@ -45,41 +39,8 @@ export async function uploadQrReplacement(
4539
// Upload to R2 storage
4640
const qrImageUrl = await r2Storage.uploadFile(buffer, qrImageFile.name);
4741

48-
// Attempt QR code extraction
49-
let qrContent: string | undefined;
50-
try {
51-
const { data, info } = await sharp(buffer)
52-
.ensureAlpha()
53-
.raw()
54-
.toBuffer({ resolveWithObject: true });
55-
56-
// Convert RGBA to RGB for @zxing/library
57-
const rgbData = new Uint8ClampedArray(info.width * info.height * 3);
58-
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
59-
rgbData[j] = data[i]; // R
60-
rgbData[j + 1] = data[i + 1]; // G
61-
rgbData[j + 2] = data[i + 2]; // B
62-
// Skip alpha channel
63-
}
64-
65-
const luminanceSource = new RGBLuminanceSource(
66-
rgbData,
67-
info.width,
68-
info.height,
69-
);
70-
const binaryBitmap = new BinaryBitmap(
71-
new HybridBinarizer(luminanceSource),
72-
);
73-
const reader = new QRCodeReader();
74-
75-
const result = reader.decode(binaryBitmap);
76-
if (result) {
77-
qrContent = result.getText();
78-
}
79-
} catch (err) {
80-
console.error("QR decode failed:", err);
81-
// Don't fail the upload if QR extraction fails
82-
}
42+
// Attempt QR code extraction using shared server-side utility
43+
const qrContent = (await decodeQrFromBuffer(buffer)) ?? undefined;
8344

8445
return {
8546
success: true,

app/(user)/contribute/_lib/submit-institution.ts

Lines changed: 73 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
logInstitutionSubmissionFailure,
1818
logNewInstitution,
1919
} from "@/lib/integrations/telegram";
20+
import { decodeQrFromBuffer } from "@/lib/qr-decode";
2021
import { isToyyibpay } from "@/lib/qr-utils";
2122
import { getUserById } from "@/lib/queries/users";
2223
import { slugify } from "@/lib/utils";
@@ -179,31 +180,6 @@ export async function submitInstitution(
179180
};
180181
}
181182

182-
// --- Duplicate QR content check
183-
const qrContentRaw = rawFromForm.qrContent;
184-
if (
185-
qrContentRaw &&
186-
typeof qrContentRaw === "string" &&
187-
qrContentRaw.trim() !== ""
188-
) {
189-
const [existingQr] = await db
190-
.select({ id: institutions.id })
191-
.from(institutions)
192-
.where(eq(institutions.qrContent, qrContentRaw.trim()))
193-
.limit(1);
194-
195-
if (existingQr) {
196-
return {
197-
status: "error",
198-
errors: {
199-
general: [
200-
"QR code ini telah pun wujud dalam sistem. Sila semak semula.",
201-
],
202-
},
203-
};
204-
}
205-
}
206-
207183
const socialMedia = {
208184
facebook: formData.get("facebook") || undefined,
209185
instagram: formData.get("instagram") || undefined,
@@ -261,10 +237,10 @@ export async function submitInstitution(
261237

262238
console.log("Validation passed, proceeding with submission");
263239

264-
// --- Handle QR image (optional)
240+
// --- Handle QR image upload + server-side QR extraction
265241
let qrImageUrl: string | undefined;
266-
// We get qrContent from the form data now, no more backend processing
267-
const qrContent = formData.get("qrContent") as string | null;
242+
let qrContent: string | null = null;
243+
let qrBuffer: Buffer | undefined;
268244

269245
try {
270246
if (qrImageFile && qrImageFile.size > 0) {
@@ -290,65 +266,21 @@ export async function submitInstitution(
290266
}
291267

292268
const arrayBuffer = await qrImageFile.arrayBuffer();
293-
const buffer = Buffer.from(arrayBuffer);
269+
qrBuffer = Buffer.from(arrayBuffer);
294270

295-
// Upload to R2
296-
try {
297-
qrImageUrl = await r2Storage.uploadFile(buffer, qrImageFile.name);
298-
} catch (uploadError) {
299-
console.error("Failed to upload QR image to R2:", uploadError);
300-
301-
// Log to Telegram with error details
302-
try {
303-
await logInstitutionSubmissionFailure({
304-
error:
305-
uploadError instanceof Error
306-
? uploadError.message
307-
: String(uploadError),
308-
institutionName: parsed.data.name,
309-
category: parsed.data.category,
310-
state: parsed.data.state,
311-
city: parsed.data.city,
312-
contributorName: user?.name || undefined,
313-
contributorEmail: user?.email,
314-
errorType: "R2 image upload failure",
315-
});
316-
} catch (telegramError) {
317-
console.error(
318-
"Failed to log upload failure to Telegram:",
319-
telegramError,
320-
);
321-
}
271+
// Server-side QR extraction (more reliable than browser-side canvas decode)
272+
qrContent = await decodeQrFromBuffer(qrBuffer);
322273

323-
return {
324-
status: "error",
325-
errors: {
326-
qrImage: ["Gagal memuat naik imej QR. Sila cuba lagi."],
327-
},
328-
};
274+
// Fall back to client-provided value if server extraction fails
275+
if (!qrContent) {
276+
const clientQrContent = formData.get("qrContent") as string | null;
277+
if (clientQrContent?.trim()) {
278+
qrContent = clientQrContent.trim();
279+
}
329280
}
330281
}
331282
} catch (error) {
332-
console.error("Error handling QR image upload:", error);
333-
334-
// Log to Telegram with error details
335-
try {
336-
await logInstitutionSubmissionFailure({
337-
error: error instanceof Error ? error.message : String(error),
338-
institutionName: parsed?.data?.name,
339-
category: parsed?.data?.category,
340-
state: parsed?.data?.state,
341-
city: parsed?.data?.city,
342-
contributorName: user?.name || undefined,
343-
contributorEmail: user?.email,
344-
errorType: "QR image processing failure",
345-
});
346-
} catch (telegramError) {
347-
console.error(
348-
"Failed to log QR processing failure to Telegram:",
349-
telegramError,
350-
);
351-
}
283+
console.error("Error handling QR image processing:", error);
352284

353285
return {
354286
status: "error",
@@ -358,6 +290,64 @@ export async function submitInstitution(
358290
};
359291
}
360292

293+
// --- Duplicate QR check (before R2 upload to avoid orphaned objects)
294+
if (qrContent) {
295+
const [existingQr] = await db
296+
.select({ id: institutions.id })
297+
.from(institutions)
298+
.where(eq(institutions.qrContent, qrContent))
299+
.limit(1);
300+
301+
if (existingQr) {
302+
return {
303+
status: "error",
304+
errors: {
305+
general: [
306+
"QR code ini telah pun wujud dalam sistem. Sila semak semula.",
307+
],
308+
},
309+
};
310+
}
311+
}
312+
313+
// --- Upload to R2 (only after duplicate check passes)
314+
if (qrBuffer && qrImageFile) {
315+
try {
316+
qrImageUrl = await r2Storage.uploadFile(qrBuffer, qrImageFile.name);
317+
} catch (uploadError) {
318+
console.error("Failed to upload QR image to R2:", uploadError);
319+
320+
// Log to Telegram with error details
321+
try {
322+
await logInstitutionSubmissionFailure({
323+
error:
324+
uploadError instanceof Error
325+
? uploadError.message
326+
: String(uploadError),
327+
institutionName: parsed.data.name,
328+
category: parsed.data.category,
329+
state: parsed.data.state,
330+
city: parsed.data.city,
331+
contributorName: user?.name || undefined,
332+
contributorEmail: user?.email,
333+
errorType: "R2 image upload failure",
334+
});
335+
} catch (telegramError) {
336+
console.error(
337+
"Failed to log upload failure to Telegram:",
338+
telegramError,
339+
);
340+
}
341+
342+
return {
343+
status: "error",
344+
errors: {
345+
qrImage: ["Gagal memuat naik imej QR. Sila cuba lagi."],
346+
},
347+
};
348+
}
349+
}
350+
361351
// Geocode if coords not provided
362352
if (!coords) {
363353
const geocoded = await geocodeInstitutionWithFallback(
@@ -398,6 +388,7 @@ export async function submitInstitution(
398388
state: parsed.data.state as (typeof states)[number],
399389
coords: coords, // Coords may be updated by geocoding
400390
qrImage: qrImageUrl,
391+
qrContent: qrContent || null,
401392
contributorId: contributorId, // Include the contributor ID
402393
status: "pending", // Always pending for new submissions
403394
supportedPayment: [isToyyibpay(qrContent) ? "toyyibpay" : "duitnow"],

lib/qr-decode.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
BinaryBitmap,
3+
HybridBinarizer,
4+
QRCodeReader,
5+
RGBLuminanceSource,
6+
} from "@zxing/library";
7+
import sharp from "sharp";
8+
9+
/**
10+
* Decode QR code content from an image buffer using sharp + @zxing/library.
11+
*
12+
* Converts the image to sRGB colorspace first (handles grayscale inputs that
13+
* would otherwise produce 2-channel GA data), then adds alpha to guarantee
14+
* 4-channel RGBA output. Strips alpha to produce RGB data compatible with
15+
* @zxing/library's RGBLuminanceSource.
16+
*
17+
* @returns The decoded QR text, or null if decoding fails.
18+
*/
19+
export const decodeQrFromBuffer = async (
20+
buffer: Buffer,
21+
): Promise<string | null> => {
22+
try {
23+
const { data, info } = await sharp(buffer)
24+
.toColorspace("srgb")
25+
.ensureAlpha()
26+
.raw()
27+
.toBuffer({ resolveWithObject: true });
28+
29+
if (info.channels !== 4) {
30+
return null;
31+
}
32+
33+
// Convert RGBA to RGB for @zxing/library
34+
const rgbData = new Uint8ClampedArray(info.width * info.height * 3);
35+
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
36+
rgbData[j] = data[i]; // R
37+
rgbData[j + 1] = data[i + 1]; // G
38+
rgbData[j + 2] = data[i + 2]; // B
39+
}
40+
41+
const luminanceSource = new RGBLuminanceSource(
42+
rgbData,
43+
info.width,
44+
info.height,
45+
);
46+
const binaryBitmap = new BinaryBitmap(new HybridBinarizer(luminanceSource));
47+
const reader = new QRCodeReader();
48+
const result = reader.decode(binaryBitmap);
49+
50+
return result?.getText() ?? null;
51+
} catch (err) {
52+
console.warn("QR decode failed:", err);
53+
return null;
54+
}
55+
};

0 commit comments

Comments
 (0)