diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67777e7..afef7dc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,10 @@ "mcp__ide__getDiagnostics", "mcp__serena__execute_shell_command", "mcp__serena__check_onboarding_performed", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(openssl rand:*)", + "Bash(ls:*)", + "Bash(find:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/.env b/.env index 6364b54..49e90e0 100644 --- a/.env +++ b/.env @@ -9,4 +9,15 @@ LINKEDIN_SECRET=ugf61aJ2iyErLK40 USE_MOCK_AUTH=true MAX_BILL_ATTACHMENT_UPLOAD_SIZE_KB=1024 -MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024 \ No newline at end of file +MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024 + +# Share link security +SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654 + +# Share link TTL configuration +SHARE_TTL_INITIAL_DAYS=10 +SHARE_TTL_AFTER_VISIT_HOURS=1 + +# Rate limiting for uploads +UPLOAD_RATE_LIMIT_PER_IP=5 +UPLOAD_RATE_LIMIT_WINDOW_MS=3600000 \ No newline at end of file diff --git a/app/[locale]/share/attachment/[id]/route.tsx b/app/[locale]/share/attachment/[id]/route.tsx index b8099f8..a2e711c 100644 --- a/app/[locale]/share/attachment/[id]/route.tsx +++ b/app/[locale]/share/attachment/[id]/route.tsx @@ -1,12 +1,49 @@ import { fetchBillById } from '@/app/lib/actions/billActions'; import { notFound } from 'next/navigation'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; +import { getDbClient } from '@/app/lib/dbClient'; +import { BillingLocation } from '@/app/lib/db-types'; -export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) { - const [locationID, billID] = id.split('-'); +export async function GET(request: Request, { params: { id } }: { params: { id: string } }) { + // Parse shareId-billID format + // shareId = 40 chars (locationId 24 + checksum 16) + const shareId = id.substring(0, 40); + const billID = id.substring(41); // Skip the '-' separator - const [location, bill] = await fetchBillById(locationID, billID, true) ?? []; + if (!shareId || !billID) { + notFound(); + } - if(!bill?.attachment) { + // Validate shareId and extract locationId + const extracted = extractShareId(shareId); + if (!extracted) { + notFound(); + } + + const { locationId: locationID, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationID, checksum)) { + notFound(); + } + + // Check TTL before fetching bill + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne({ _id: locationID }, { projection: { shareTTL: 1 } }); + + if (!location) { + notFound(); + } + + // Check if sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { + notFound(); + } + + const [_, bill] = await fetchBillById(locationID, billID, true) ?? []; + + if (!bill?.attachment) { notFound(); } diff --git a/app/[locale]/share/bill/[id]/page.tsx b/app/[locale]/share/bill/[id]/page.tsx index 7576a78..6cce06a 100644 --- a/app/[locale]/share/bill/[id]/page.tsx +++ b/app/[locale]/share/bill/[id]/page.tsx @@ -2,19 +2,40 @@ import { fetchBillById } from '@/app/lib/actions/billActions'; import { ViewBillCard } from '@/app/ui/ViewBillCard'; import { Main } from '@/app/ui/Main'; import { notFound } from 'next/navigation'; +import { validateShareAccess } from '@/app/lib/actions/locationActions'; -export default async function Page({ params:{ id } }: { params: { id:string } }) { +export default async function Page({ params: { id } }: { params: { id: string } }) { - const [locationID, billID] = id.split('-'); + // Split combined ID: shareId (40 chars) + '-' + billID (24 chars) + // ShareId = locationId (24) + checksum (16) = 40 chars + const shareId = id.substring(0, 40); + const billID = id.substring(41); // Skip the '-' separator + // Validate share access (checks checksum + TTL, extracts locationId) + const accessValidation = await validateShareAccess(shareId); + + if (!accessValidation.valid || !accessValidation.locationId) { + return ( +
+
+

{accessValidation.error || 'This content is no longer shared'}

+
+
+ ); + } + + const locationID = accessValidation.locationId; + + // Fetch bill data const [location, bill] = await fetchBillById(locationID, billID) ?? []; if (!bill || !location) { - return(notFound()); + return notFound(); } + return (
- +
); } \ No newline at end of file diff --git a/app/[locale]/share/location/[id]/LocationViewPage.tsx b/app/[locale]/share/location/[id]/LocationViewPage.tsx index aacae50..5fb5245 100644 --- a/app/[locale]/share/location/[id]/LocationViewPage.tsx +++ b/app/[locale]/share/location/[id]/LocationViewPage.tsx @@ -1,14 +1,28 @@ import { ViewLocationCard } from '@/app/ui/ViewLocationCard'; -import { fetchLocationById, setSeenByTenantAt } from '@/app/lib/actions/locationActions'; +import { fetchLocationById, setSeenByTenantAt, validateShareAccess } from '@/app/lib/actions/locationActions'; import { getUserSettingsByUserId } from '@/app/lib/actions/userSettingsActions'; import { notFound } from 'next/navigation'; import { myAuth } from '@/app/lib/auth'; -export default async function LocationViewPage({ locationId }: { locationId:string }) { +export default async function LocationViewPage({ shareId }: { shareId: string }) { + // Validate share access (checks checksum + TTL, extracts locationId) + const accessValidation = await validateShareAccess(shareId); + + if (!accessValidation.valid || !accessValidation.locationId) { + return ( +
+

{accessValidation.error || 'This content is no longer shared'}

+
+ ); + } + + const locationId = accessValidation.locationId; + + // Fetch location const location = await fetchLocationById(locationId); if (!location) { - return(notFound()); + return notFound(); } // Fetch user settings for the location owner @@ -23,5 +37,11 @@ export default async function LocationViewPage({ locationId }: { locationId:stri await setSeenByTenantAt(locationId); } - return (); + return ( + + ); } \ No newline at end of file diff --git a/app/[locale]/share/location/[id]/page.tsx b/app/[locale]/share/location/[id]/page.tsx index bdbf265..7c3ef86 100644 --- a/app/[locale]/share/location/[id]/page.tsx +++ b/app/[locale]/share/location/[id]/page.tsx @@ -3,12 +3,11 @@ import LocationViewPage from './LocationViewPage'; import { Main } from '@/app/ui/Main'; import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; -export default async function Page({ params:{ id } }: { params: { id:string } }) { - +export default async function Page({ params: { id } }: { params: { id: string } }) { return (
}> - +
); diff --git a/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx index 820566c..bfa232d 100644 --- a/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx +++ b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx @@ -1,19 +1,39 @@ import { getDbClient } from '@/app/lib/dbClient'; import { BillingLocation } from '@/app/lib/db-types'; import { notFound } from 'next/navigation'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; -export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) { - const locationID = id; +export async function GET(request: Request, { params: { id } }: { params: { id: string } }) { + const shareId = id; + + // Validate shareId and extract locationId + const extracted = extractShareId(shareId); + if (!extracted) { + notFound(); + } + + const { locationId: locationID, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationID, checksum)) { + notFound(); + } const dbClient = await getDbClient(); const location = await dbClient.collection("lokacije") .findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1, + shareTTL: 1, } }); - if(!location?.utilBillsProofOfPayment) { + if (!location?.utilBillsProofOfPayment) { + notFound(); + } + + // Check if sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { notFound(); } diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx index df1c1b4..7b82301 100644 --- a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx +++ b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx @@ -1,12 +1,28 @@ import { getDbClient } from '@/app/lib/dbClient'; import { BillingLocation } from '@/app/lib/db-types'; import { notFound } from 'next/navigation'; +import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum'; -export async function GET(_request: Request, { params:{ id } }: { params: { id:string } }) { - // Parse locationID-billID format - const [locationID, billID] = id.split('-'); +export async function GET(_request: Request, { params: { id } }: { params: { id: string } }) { + // Parse shareId-billID format + // shareId = 40 chars (locationId 24 + checksum 16) + const shareId = id.substring(0, 40); + const billID = id.substring(41); // Skip the '-' separator - if (!locationID || !billID) { + if (!shareId || !billID) { + notFound(); + } + + // Validate shareId and extract locationId + const extracted = extractShareId(shareId); + if (!extracted) { + notFound(); + } + + const { locationId: locationID, checksum } = extracted; + + // Validate checksum + if (!validateShareChecksum(locationID, checksum)) { notFound(); } @@ -14,13 +30,19 @@ export async function GET(_request: Request, { params:{ id } }: { params: { id:s const location = await dbClient.collection("lokacije") .findOne({ _id: locationID }, { projection: { - // Don't load bill attachments, only proof of payment + // Don't load bill attachments, only proof of payment and shareTTL "bills._id": 1, "bills.proofOfPayment": 1, + "shareTTL": 1, } }); - if(!location) { + if (!location) { + notFound(); + } + + // Check if sharing is active and not expired + if (!location.shareTTL || new Date() > location.shareTTL) { notFound(); } diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index dfb1d52..922cdaa 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -10,6 +10,9 @@ import { gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; import { unstable_noStore, revalidatePath } from 'next/cache'; +import { extractShareId, validateShareChecksum } from '../shareChecksum'; +import { validatePdfFile } from '../validators/pdfValidator'; +import { checkUploadRateLimit } from '../uploadRateLimiter'; export type State = { errors?: { @@ -488,94 +491,129 @@ export const deleteBillById = withUser(async (user: AuthenticatedUser, locationI /** * Uploads proof of payment for the given bill - * @param locationID - The ID of the location - * @param formData - FormData containing the file - * @returns Promise with success status + * SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP + * + * @param shareId - Combined location ID + checksum (40 chars) + * @param billID - The bill ID to attach proof of payment to + * @param formData - Form data containing the PDF file + * @param ipAddress - Optional IP address for rate limiting */ -export const uploadProofOfPayment = async (locationID: string, billID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => { +export const uploadProofOfPayment = async ( + shareId: string, + billID: string, + formData: FormData, + ipAddress?: string +): Promise<{ success: boolean; error?: string }> => { - unstable_noStore(); + unstable_noStore(); - try { - // First validate that the file is acceptable - const file = formData.get('proofOfPayment') 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` }; - } - - // Validate file type - if (file && file.size > 0 && file.type !== 'application/pdf') { - return { success: false, error: 'Only PDF files are accepted' }; - } - - // update the bill in the mongodb - const dbClient = await getDbClient(); - - const projection = { - // attachment is not required in this context - this will reduce data transfer - "bills.attachment": 0, - // ommit file content - not needed here - this will reduce data transfer - "bills.proofOfPayment.fileContentsBase64": 0, - }; - - // Checking if proof of payment already exists - - // find a location with the given locationID - const billLocation = await dbClient.collection("lokacije").findOne( - { - _id: locationID, - }, - { - projection - }) - - if (!billLocation) { - console.log(`Location ${locationID} not found - Proof of payment upload failed`); - return { success: false, error: "Location not found - Proof of payment upload failed" }; - } - - // find a bill with the given billID - const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); - - - if (bill?.proofOfPayment?.uploadedAt) { - return { success: false, error: 'Proof payment already uploaded for this bill' }; - } - - const attachment = await serializeAttachment(file); - - if (!attachment) { - return { success: false, error: 'Invalid file' }; - } - - // Add proof of payment to the bill - await dbClient.collection("lokacije").updateOne( - { - _id: locationID // find a location with the given locationID - }, - { - $set: { - "bills.$[elem].proofOfPayment": { - ...attachment - } - } - }, { - arrayFilters: [ - { "elem._id": { $eq: billID } } // find a bill with the given billID - ] - }); - - // Invalidate the location view cache - revalidatePath(`/share/location/${locationID}`, 'page'); - - return { success: true }; - } catch (error: any) { - console.error('Error uploading proof of payment for a bill:', error); - return { success: false, error: error.message || 'Upload failed' }; + try { + // 1. EXTRACT AND VALIDATE CHECKSUM (stateless, fast) + const extracted = extractShareId(shareId); + if (!extracted) { + return { success: false, error: 'Invalid share link' }; } + + const { locationId: locationID, checksum } = extracted; + + if (!validateShareChecksum(locationID, checksum)) { + return { success: false, error: 'Invalid share link' }; + } + + // 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 location = await dbClient.collection("lokacije").findOne( + { _id: locationID }, + { projection: { userId: 1, bills: 1, shareTTL: 1 } } + ); + + 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' }; + } + + // Verify bill exists in location + const bill = location.bills.find(b => b._id === billID); + if (!bill) { + return { success: false, error: 'Invalid request' }; + } + + // Check if proof of payment already uploaded + if (bill.proofOfPayment?.uploadedAt) { + return { success: false, error: 'Proof of payment already uploaded for this bill' }; + } + + // 4. FILE VALIDATION + const file = formData.get('proofOfPayment') 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: 'Failed to process file' }; + } + + // 6. UPDATE DATABASE + await dbClient.collection("lokacije").updateOne( + { _id: locationID }, + { + $set: { + "bills.$[elem].proofOfPayment": attachment + } + }, + { + arrayFilters: [{ "elem._id": { $eq: billID } }] + } + ); + + // 7. CLEANUP EXPIRED SHARES (integrated, no cron needed) + await cleanupExpiredShares(dbClient); + + // 8. REVALIDATE CACHE + revalidatePath(`/share/location/${shareId}`, 'page'); + + return { success: true }; + + } catch (error: any) { + console.error('Upload error:', error); + return { success: false, error: 'Upload failed. Please try again.' }; + } +}; + +/** + * Clean up expired shares during upload processing + * Removes shareTTL and shareFirstVisitedAt from expired locations + */ +async function cleanupExpiredShares(dbClient: any) { + const now = new Date(); + + await dbClient.collection("lokacije").updateMany( + { shareTTL: { $lt: now } }, + { $unset: { shareTTL: "", shareFirstVisitedAt: "" } } + ); } \ No newline at end of file diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 88b7686..489b787 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -10,6 +10,9 @@ import { gotoHomeWithMessage } from './navigationActions'; 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?: { @@ -637,65 +640,230 @@ 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) { + console.log('shareID extraction failed'); + 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)) { + console.log('shareID checksum validation failed'); + 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.' }; } +} + +/** + * Generate/activate share link for location + * Called when owner clicks "Share" button + * Sets shareTTL to 10 days from now + */ +export const generateShareLink = withUser( + async (user: AuthenticatedUser, locationId: string) => { + + const { id: userId } = user; + const dbClient = await getDbClient(); + + // Verify ownership + const location = await dbClient.collection("lokacije").findOne({ + _id: locationId, + userId + }); + + if (!location) { + return { error: 'Location not found' }; + } + + // Calculate TTL (10 days from now, configurable) + const initialDays = parseInt(process.env.SHARE_TTL_INITIAL_DAYS || '10', 10); + const shareTTL = new Date(Date.now() + initialDays * 24 * 60 * 60 * 1000); + + // Activate sharing by setting TTL + await dbClient.collection("lokacije").updateOne( + { _id: locationId }, + { + $set: { shareTTL }, + $unset: { shareFirstVisitedAt: "" } // Reset first visit tracking + } + ); + + // Generate combined share ID (locationId + checksum) + const shareId = generateShareId(locationId); + + // Build share URL + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const shareUrl = `${baseUrl}/share/location/${shareId}`; + + return { shareUrl }; + } +); + +/** + * Validate share link and update TTL on first visit + * Called when tenant visits share link + * + * SECURITY: + * 1. Extracts locationId and checksum from combined shareId + * 2. Validates checksum (stateless, prevents enumeration) + * 3. Checks TTL in database (time-based access control) + * 4. Marks first visit and resets TTL to 1 hour + * + * @param shareId - Combined ID (locationId + checksum, 40 chars) + * @returns Object with validation result and extracted locationId + */ +export async function validateShareAccess( + shareId: string +): Promise<{ valid: boolean; locationId?: string; error?: string }> { + + // 1. Extract locationId and checksum from combined ID + const extracted = extractShareId(shareId); + if (!extracted) { + console.log('shareID extraction failed'); + return { valid: false, error: 'Invalid share link' }; + } + + const { locationId, checksum } = extracted; + + // 2. Validate checksum FIRST (before DB query - stateless validation) + if (!validateShareChecksum(locationId, checksum)) { + console.log('shareID checksum validation failed'); + return { valid: false, error: 'Invalid share link' }; + } + + // 3. Check TTL in database + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije").findOne( + { _id: locationId }, + { projection: { shareTTL: 1, shareFirstVisitedAt: 1 } } + ); + + if (!location) { + console.log('Location not found for shareID'); + return { valid: false, error: 'Invalid share link' }; + } + + // 4. Check if sharing is enabled + if (!location.shareTTL) { + return { valid: false, error: 'This content is no longer shared' }; + } + + // 5. Check if TTL expired + const now = new Date(); + if (now > location.shareTTL) { + // Clean up expired share + await dbClient.collection("lokacije").updateOne( + { _id: locationId }, + { $unset: { shareTTL: "", shareFirstVisitedAt: "" } } + ); + + return { valid: false, error: 'This content is no longer shared' }; + } + + // 6. Mark first visit if applicable (resets TTL to 1 hour) + if (!location.shareFirstVisitedAt) { + const visitHours = parseInt(process.env.SHARE_TTL_AFTER_VISIT_HOURS || '1', 10); + const newTTL = new Date(Date.now() + visitHours * 60 * 60 * 1000); + + await dbClient.collection("lokacije").updateOne( + { + _id: locationId, + shareFirstVisitedAt: null // Only update if not already set + }, + { + $set: { + shareFirstVisitedAt: new Date(), + shareTTL: newTTL + } + } + ); + } + + return { valid: true, locationId }; } \ No newline at end of file diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index 341ed62..87a93ef 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -81,6 +81,10 @@ export interface BillingLocation { utilBillsProofOfPayment?: FileAttachment|null; /** (optional) rent proof of payment attachment */ rentProofOfPayment?: FileAttachment|null; + /** (optional) share link expiry timestamp */ + shareTTL?: Date; + /** (optional) when tenant first visited the share link */ + shareFirstVisitedAt?: Date | null; }; export enum BilledTo { diff --git a/app/lib/shareChecksum.ts b/app/lib/shareChecksum.ts new file mode 100644 index 0000000..aef9874 --- /dev/null +++ b/app/lib/shareChecksum.ts @@ -0,0 +1,86 @@ +import crypto from 'crypto'; + +/** + * Checksum length in hex characters (16 chars = 64 bits of entropy) + */ +export const CHECKSUM_LENGTH = 16; + +/** + * Generate share link checksum for location + * Uses HMAC-SHA256 for cryptographic integrity + * + * SECURITY: Prevents location ID enumeration while allowing stateless validation + */ +export function generateShareChecksum(locationId: string): string { + const secret = process.env.SHARE_LINK_SECRET; + + if (!secret) { + throw new Error('SHARE_LINK_SECRET environment variable not configured'); + } + + return crypto + .createHmac('sha256', secret) + .update(locationId) + .digest('hex') + .substring(0, CHECKSUM_LENGTH); +} + +/** + * Validate share link checksum + * Uses constant-time comparison to prevent timing attacks + * + * @param locationId - The location ID from URL + * @param providedChecksum - The checksum from URL + * @returns true if checksum is valid + */ +export function validateShareChecksum( + locationId: string, + providedChecksum: string +): boolean { + try { + const expectedChecksum = generateShareChecksum(locationId); + + // Convert to buffers for timing-safe comparison + const expected = Buffer.from(expectedChecksum); + const provided = Buffer.from(providedChecksum); + + // Length check (prevents timing attack on different lengths) + if (expected.length !== provided.length) { + return false; + } + + // Constant-time comparison (prevents timing attacks) + return crypto.timingSafeEqual(expected, provided); + } catch { + return false; + } +} + +/** + * Generate combined location ID with checksum appended + * @param locationId - The MongoDB location ID (24 chars) + * @returns Combined ID: locationId + checksum (40 chars total) + */ +export function generateShareId(locationId: string): string { + const checksum = generateShareChecksum(locationId); + return locationId + checksum; +} + +/** + * Extract location ID and checksum from combined share ID + * @param shareId - Combined ID (locationId + checksum) + * @returns Object with locationId and checksum, or null if invalid format + */ +export function extractShareId(shareId: string): { locationId: string; checksum: string } | null { + // MongoDB ObjectID is 24 chars, checksum is 16 chars = 40 total + const expectedLength = 24 + CHECKSUM_LENGTH; + + if (shareId.length !== expectedLength) { + return null; + } + + const locationId = shareId.substring(0, 24); + const checksum = shareId.substring(24); + + return { locationId, checksum }; +} diff --git a/app/lib/uploadRateLimiter.ts b/app/lib/uploadRateLimiter.ts new file mode 100644 index 0000000..62b2ba1 --- /dev/null +++ b/app/lib/uploadRateLimiter.ts @@ -0,0 +1,71 @@ +/** + * Simple in-memory rate limiter for upload attempts + * Tracks by IP address + */ + +interface RateLimitEntry { + count: number; + resetAt: number; // Unix timestamp +} + +// In-memory store (use Redis for production multi-instance setups) +const rateLimitStore = new Map(); + +/** + * Check if IP address is rate limited + * @returns { allowed: boolean, remaining: number } + */ +export function checkUploadRateLimit(ipAddress: string): { allowed: boolean; remaining: number; resetIn: number } { + + const maxUploads = parseInt(process.env.UPLOAD_RATE_LIMIT_PER_IP || '5', 10); + const windowMs = parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || '3600000', 10); // 1 hour + + const now = Date.now(); + const key = `upload:${ipAddress}`; + + let entry = rateLimitStore.get(key); + + // Clean up expired entry or create new one + if (!entry || now > entry.resetAt) { + entry = { + count: 0, + resetAt: now + windowMs + }; + rateLimitStore.set(key, entry); + } + + // Check if limit exceeded + if (entry.count >= maxUploads) { + return { + allowed: false, + remaining: 0, + resetIn: Math.ceil((entry.resetAt - now) / 1000) // seconds + }; + } + + // Increment counter + entry.count++; + rateLimitStore.set(key, entry); + + return { + allowed: true, + remaining: maxUploads - entry.count, + resetIn: Math.ceil((entry.resetAt - now) / 1000) + }; +} + +/** + * Periodic cleanup of expired entries (prevent memory leak) + * Call this occasionally (e.g., every hour) + */ +export function cleanupRateLimitStore() { + const now = Date.now(); + for (const [key, entry] of rateLimitStore.entries()) { + if (now > entry.resetAt) { + rateLimitStore.delete(key); + } + } +} + +// Auto-cleanup every 10 minutes +setInterval(cleanupRateLimitStore, 10 * 60 * 1000); diff --git a/app/lib/validators/pdfValidator.ts b/app/lib/validators/pdfValidator.ts new file mode 100644 index 0000000..8036c4c --- /dev/null +++ b/app/lib/validators/pdfValidator.ts @@ -0,0 +1,46 @@ +/** + * Validate that uploaded file is a legitimate PDF + * Checks magic bytes, not just MIME type + */ +export async function validatePdfFile(file: File): Promise<{ valid: boolean; error?: string }> { + + // Check file size first (quick rejection) + const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10); + const maxFileSizeBytes = maxFileSizeKB * 1024; + + if (file.size === 0) { + return { valid: false, error: 'File is empty' }; + } + + if (file.size > maxFileSizeBytes) { + return { valid: false, error: `File size exceeds ${maxFileSizeKB} KB limit` }; + } + + // Read file content + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Check PDF magic bytes (header signature) + // PDF files must start with "%PDF-" (bytes: 25 50 44 46 2D) + const header = buffer.toString('utf-8', 0, 5); + if (!header.startsWith('%PDF-')) { + return { valid: false, error: 'Invalid PDF file format' }; + } + + // Optional: Check for PDF version (1.0 to 2.0) + const version = buffer.toString('utf-8', 5, 8); // e.g., "1.4", "1.7", "2.0" + const versionMatch = version.match(/^(\d+\.\d+)/); + if (!versionMatch) { + return { valid: false, error: 'Invalid PDF version' }; + } + + // Optional: Verify PDF EOF marker (should end with %%EOF) + // Note: Some PDFs have trailing data, so this is lenient + const endSection = buffer.toString('utf-8', Math.max(0, buffer.length - 1024)); + if (!endSection.includes('%%EOF')) { + console.warn('PDF missing EOF marker - may be corrupted'); + // Don't reject, just warn (some valid PDFs have non-standard endings) + } + + return { valid: true }; +} diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index ab68d3e..3ef6355 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -253,7 +253,7 @@ export const BillEditForm: FC = ({ location, bill }) => { target="_blank" className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block' > - + {decodeURIComponent(proofOfPayment.fileName)} diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index 2a6c4a8..c2e88d9 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -10,6 +10,7 @@ import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { toast } from "react-toastify"; import { get } from "http"; +import { generateShareLink } from "../lib/actions/locationActions"; export interface LocationCardProps { location: BillingLocation; @@ -33,13 +34,17 @@ export const LocationCard: FC = ({ location, currency }) => { // sum all the paid bill amounts (regardless of who pays) const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); - const handleCopyLinkClick = () => { + const handleCopyLinkClick = async () => { // copy URL to clipboard - const url = `${window.location.origin}/${currentLocale}/share/location/${_id}`; - navigator.clipboard.writeText(url); + const shareLink = await generateShareLink(_id); + + if(shareLink.error) { + toast.error(shareLink.error, { theme: "dark" }); + } else { + navigator.clipboard.writeText(shareLink.shareUrl as string); + toast.success(t("link-copy-message"), { theme: "dark" }); + } - // use NextJS toast to notiy user that the link was copied - toast.success(t("link-copy-message"), { theme: "dark" }); } return ( 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..8a6e1ef 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()); @@ -94,7 +100,7 @@ export const ViewBillCard: FC = ({ location, bill }) => { attachment ?

{t("attachment")}

- + {decodeURIComponent(attachment.fileName)} @@ -124,11 +130,11 @@ export const ViewBillCard: FC = ({ location, bill }) => { proofOfPaymentFilename ? (
- + { decodeURIComponent(proofOfPaymentFilename) }
@@ -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..d7ed6cc 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -10,16 +10,18 @@ import { ViewBillBadge } from "./ViewBillBadge"; import { Pdf417Barcode } from "./Pdf417Barcode"; import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder"; import Link from "next/link"; -import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline"; +import { LinkIcon } from "@heroicons/react/24/outline"; import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions"; import QRCode from "react-qr-code"; +import { TicketIcon } from "@heroicons/react/24/solid"; 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 +49,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 +68,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 +128,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 => ) }
{ @@ -189,11 +196,11 @@ export const ViewLocationCard: FC = ({ location, userSett attachmentFilename ? (
- + {decodeURIComponent(attachmentFilename)}
diff --git a/docker-compose-standalone.yaml b/docker-compose-standalone.yaml index 3af0c4b..55bfd31 100644 --- a/docker-compose-standalone.yaml +++ b/docker-compose-standalone.yaml @@ -29,6 +29,13 @@ services: HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses) NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME) PORT: ${PORT:-80} + # Share link security + SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5 + SHARE_TTL_INITIAL_DAYS: 10 + SHARE_TTL_AFTER_VISIT_HOURS: 1 + # Upload rate limiting + UPLOAD_RATE_LIMIT_PER_IP: 5 + UPLOAD_RATE_LIMIT_WINDOW_MS: 3600000 container_name: evidencija-rezija__web-app restart: unless-stopped # u slučaju rušenja containera pokušavaj ga pokrenuti dok ne uspije = BESKONAČNO depends_on: diff --git a/docker-compose-swarm.yml b/docker-compose-swarm.yml index 87decb0..bd6d34f 100644 --- a/docker-compose-swarm.yml +++ b/docker-compose-swarm.yml @@ -29,6 +29,13 @@ services: HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses) NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME) PORT: ${PORT:-80} + # Share link security + SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5 + SHARE_TTL_INITIAL_DAYS: 10 + SHARE_TTL_AFTER_VISIT_HOURS: 1 + # Upload rate limiting + UPLOAD_RATE_LIMIT_PER_IP: 5 + UPLOAD_RATE_LIMIT_WINDOW_MS: 3600000 deploy: # u slucaju rušenja kontejnera čekamo 5s i dižemo novi kontejner => ako se i on sruši opet ceka 5s i pokusava ponovno (tako 5 puta) restart_policy: