feat: secure combined uploads and update UI components

Changes:
- Secure uploadUtilBillsProofOfPayment with checksum validation
- Update ViewLocationCard to accept and use shareId prop
- Update ViewBillCard to accept shareId and use it for uploads
- Update ViewBillBadge to pass shareId to bill detail pages
- Add client-side validation check for shareId before upload
- Update back button links to use shareId

Security improvements:
- Both per-bill and combined uploads now validate checksum and TTL
- IP-based rate limiting applied to both upload types
- PDF magic bytes validation for both upload types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-12-08 00:25:26 +01:00
parent 844e386e18
commit 81dddb526a
4 changed files with 102 additions and 49 deletions

View File

@@ -11,6 +11,8 @@ import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum';
import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter';
export type State = {
errors?: {
@@ -638,66 +640,101 @@ const serializeAttachment = async (file: File | null):Promise<FileAttachment | n
/**
* Uploads a single proof of payment for all utility bills in a location
* @param locationID - The ID of the location
* @param formData - FormData containing the file
* SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP
*
* @param shareId - Combined location ID + checksum (40 chars)
* @param formData - FormData containing the PDF file
* @param ipAddress - Optional IP address for rate limiting
* @returns Promise with success status
*/
export const uploadUtilBillsProofOfPayment = async (locationID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => {
export const uploadUtilBillsProofOfPayment = async (
shareId: string,
formData: FormData,
ipAddress?: string
): Promise<{ success: boolean; error?: string }> => {
unstable_noStore();
try {
// First validate that the file is acceptable
const file = formData.get('utilBillsProofOfPayment') as File;
// validate max file size from env variable
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
const maxFileSizeBytes = maxFileSizeKB * 1024;
if (file && file.size > maxFileSizeBytes) {
return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` };
// 1. EXTRACT AND VALIDATE CHECKSUM (stateless, fast)
const extracted = extractShareId(shareId);
if (!extracted) {
return { success: false, error: 'Invalid share link' };
}
// Validate file type
if (file && file.size > 0 && file.type !== 'application/pdf') {
return { success: false, error: 'Only PDF files are accepted' };
const { locationId: locationID, checksum } = extracted;
if (!validateShareChecksum(locationID, checksum)) {
return { success: false, error: 'Invalid share link' };
}
// check if attachment already exists for the location
// 2. RATE LIMITING (per IP)
if (ipAddress) {
const rateLimit = checkUploadRateLimit(ipAddress);
if (!rateLimit.allowed) {
return {
success: false,
error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.`
};
}
}
// 3. DATABASE VALIDATION
const dbClient = await getDbClient();
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } });
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { userId: 1, utilBillsProofOfPayment: 1, shareTTL: 1 } });
if (existingLocation?.utilBillsProofOfPayment) {
return { success: false, error: 'An attachment already exists for this location' };
if (!location || !location.userId) {
return { success: false, error: 'Invalid request' };
}
// Check sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
return { success: false, error: 'This content is no longer shared' };
}
// Check if proof of payment already uploaded
if (location.utilBillsProofOfPayment) {
return { success: false, error: 'Proof of payment already uploaded for this location' };
}
// 4. FILE VALIDATION
const file = formData.get('utilBillsProofOfPayment') as File;
if (!file || file.size === 0) {
return { success: false, error: 'No file provided' };
}
// Validate PDF content (magic bytes, not just MIME type)
const pdfValidation = await validatePdfFile(file);
if (!pdfValidation.valid) {
return { success: false, error: pdfValidation.error };
}
// 5. SERIALIZE & STORE FILE
const attachment = await serializeAttachment(file);
if (!attachment) {
return { success: false, error: 'Invalid file' };
return { success: false, error: 'Failed to process file' };
}
// Update the location with the attachment
// 6. UPDATE DATABASE
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: {
utilBillsProofOfPayment: {
...attachment
},
utilBillsProofOfPayment: attachment
} }
);
// Invalidate the location view cache
revalidatePath(`/share/location/${locationID}`, 'page');
// 7. REVALIDATE CACHE
revalidatePath(`/share/location/${shareId}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Error uploading util bills proof of payment:', error);
return { success: false, error: error.message || 'Upload failed' };
console.error('Upload error:', error);
return { success: false, error: 'Upload failed. Please try again.' };
}
}