diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index cab09c8..28125e5 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -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 => { +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("lokacije") - .findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } }); + const location = await dbClient.collection("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("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.' }; } } diff --git a/app/ui/ViewBillBadge.tsx b/app/ui/ViewBillBadge.tsx index 47a2c2c..15bb9c0 100644 --- a/app/ui/ViewBillBadge.tsx +++ b/app/ui/ViewBillBadge.tsx @@ -5,18 +5,22 @@ import { TicketIcon } from "@heroicons/react/24/outline"; import { useLocale } from "next-intl"; export interface ViewBillBadgeProps { - locationId: string, - bill: Bill + locationId: string; + shareId?: string; + bill: Bill; }; -export const ViewBillBadge: FC = ({ locationId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => { +export const ViewBillBadge: FC = ({ locationId, shareId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => { const currentLocale = useLocale(); const className = `badge badge-lg p-[1em] ${paid ? "badge-success" : " badge-outline"} ${!paid && !!attachment ? "btn-outline btn-success" : ""} cursor-pointer`; + // Use shareId if available (for shared views), otherwise use locationId (for owner views) + const billPageId = shareId || locationId; + return ( - + {name} { proofOfPayment?.uploadedAt ? diff --git a/app/ui/ViewBillCard.tsx b/app/ui/ViewBillCard.tsx index 7cddd1e..0fbc822 100644 --- a/app/ui/ViewBillCard.tsx +++ b/app/ui/ViewBillCard.tsx @@ -11,11 +11,12 @@ import { Pdf417Barcode } from "./Pdf417Barcode"; import { uploadProofOfPayment } from "../lib/actions/billActions"; export interface ViewBillCardProps { - location: BillingLocation, - bill: Bill, + location: BillingLocation; + bill: Bill; + shareId?: string; } -export const ViewBillCard: FC = ({ location, bill }) => { +export const ViewBillCard: FC = ({ location, bill, shareId }) => { const router = useRouter(); const t = useTranslations("bill-edit-form"); @@ -31,23 +32,28 @@ export const ViewBillCard: FC = ({ location, bill }) => { const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - - // Validate file type + + // Validate file type client-side (quick feedback) if (file.type !== 'application/pdf') { setUploadError('Only PDF files are accepted'); e.target.value = ''; // Reset input return; } - + + if (!shareId) { + setUploadError('Invalid upload link'); + return; + } + setIsUploading(true); setUploadError(null); - + try { const formData = new FormData(); formData.append('proofOfPayment', file); - - const result = await uploadProofOfPayment(locationID, billID as string, formData); - + + const result = await uploadProofOfPayment(shareId, billID as string, formData); + if (result.success) { setProofOfPaymentFilename(file.name); setProofOfPaymentUploadedAt(new Date()); @@ -161,7 +167,7 @@ export const ViewBillCard: FC = ({ location, bill }) => { }
- {t("back-button")} + {t("back-button")}
diff --git a/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx index b3c1cb8..c005a4b 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -17,9 +17,10 @@ import QRCode from "react-qr-code"; export interface ViewLocationCardProps { location: BillingLocation; userSettings: UserSettings | null; + shareId?: string; } -export const ViewLocationCard: FC = ({ location, userSettings }) => { +export const ViewLocationCard: FC = ({ location, userSettings, shareId }) => { const { _id, @@ -47,13 +48,18 @@ export const ViewLocationCard: FC = ({ location, userSett const file = e.target.files?.[0]; if (!file) return; - // Validate file type + // Validate file type client-side (quick feedback) if (file.type !== 'application/pdf') { setUploadError('Only PDF files are accepted'); e.target.value = ''; // Reset input return; } + if (!shareId) { + setUploadError('Invalid upload link'); + return; + } + setIsUploading(true); setUploadError(null); @@ -61,7 +67,7 @@ export const ViewLocationCard: FC = ({ location, userSett const formData = new FormData(); formData.append('utilBillsProofOfPayment', file); - const result = await uploadUtilBillsProofOfPayment(_id, formData); + const result = await uploadUtilBillsProofOfPayment(shareId, formData); if (result.success) { setAttachmentFilename(file.name); @@ -121,7 +127,7 @@ export const ViewLocationCard: FC = ({ location, userSett

{formatYearMonth(yearMonth)} {locationName}

{ - bills.filter(bill => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant).map(bill => ) + bills.filter(bill => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant).map(bill => ) }
{