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/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 4211425..922cdaa 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; import { unstable_noStore, revalidatePath } from 'next/cache'; -import { validateShareChecksum } from '../shareChecksum'; +import { extractShareId, validateShareChecksum } from '../shareChecksum'; import { validatePdfFile } from '../validators/pdfValidator'; import { checkUploadRateLimit } from '../uploadRateLimiter'; @@ -492,11 +492,15 @@ export const deleteBillById = withUser(async (user: AuthenticatedUser, locationI /** * Uploads proof of payment for the given bill * 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, + shareId: string, billID: string, - checksum: string, formData: FormData, ipAddress?: string ): Promise<{ success: boolean; error?: string }> => { @@ -504,7 +508,14 @@ export const uploadProofOfPayment = async ( unstable_noStore(); try { - // 1. VALIDATE CHECKSUM (stateless, fast) + // 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' }; } @@ -584,7 +595,7 @@ export const uploadProofOfPayment = async ( await cleanupExpiredShares(dbClient); // 8. REVALIDATE CACHE - revalidatePath(`/share/location/${locationID}/${checksum}`, 'page'); + revalidatePath(`/share/location/${shareId}`, 'page'); return { success: true }; diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 07d7a9e..cab09c8 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions'; import { unstable_noStore, revalidatePath } from 'next/cache'; import { IntlTemplateFn } from '@/app/i18n'; import { getTranslations, getLocale } from "next-intl/server"; -import { generateShareChecksum, validateShareChecksum } from '../shareChecksum'; +import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum'; export type State = { errors?: { @@ -735,12 +735,12 @@ export const generateShareLink = withUser( } ); - // Generate checksum - const checksum = generateShareChecksum(locationId); + // 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/${locationId}/${checksum}`; + const shareUrl = `${baseUrl}/share/location/${shareId}`; return { shareUrl }; } @@ -751,21 +751,32 @@ export const generateShareLink = withUser( * Called when tenant visits share link * * SECURITY: - * 1. Validates checksum (stateless, prevents enumeration) - * 2. Checks TTL in database (time-based access control) - * 3. Marks first visit and resets TTL to 1 hour + * 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( - locationId: string, - checksum: string -): Promise<{ valid: boolean; error?: string }> { + shareId: string +): Promise<{ valid: boolean; locationId?: string; error?: string }> { - // 1. Validate checksum FIRST (before DB query - stateless validation) + // 1. Extract locationId and checksum from combined ID + const extracted = extractShareId(shareId); + if (!extracted) { + return { valid: false, error: 'Invalid share link' }; + } + + const { locationId, checksum } = extracted; + + // 2. Validate checksum FIRST (before DB query - stateless validation) if (!validateShareChecksum(locationId, checksum)) { return { valid: false, error: 'Invalid share link' }; } - // 2. Check TTL in database + // 3. Check TTL in database const dbClient = await getDbClient(); const location = await dbClient.collection("lokacije").findOne( { _id: locationId }, @@ -776,12 +787,12 @@ export async function validateShareAccess( return { valid: false, error: 'Invalid share link' }; } - // 3. Check if sharing is enabled + // 4. Check if sharing is enabled if (!location.shareTTL) { return { valid: false, error: 'This content is no longer shared' }; } - // 4. Check if TTL expired + // 5. Check if TTL expired const now = new Date(); if (now > location.shareTTL) { // Clean up expired share @@ -793,7 +804,7 @@ export async function validateShareAccess( return { valid: false, error: 'This content is no longer shared' }; } - // 5. Mark first visit if applicable (resets TTL to 1 hour) + // 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); @@ -812,5 +823,5 @@ export async function validateShareAccess( ); } - return { valid: true }; + return { valid: true, locationId }; } \ No newline at end of file diff --git a/app/lib/shareChecksum.ts b/app/lib/shareChecksum.ts index 54c2eab..aef9874 100644 --- a/app/lib/shareChecksum.ts +++ b/app/lib/shareChecksum.ts @@ -1,5 +1,10 @@ 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 @@ -17,7 +22,7 @@ export function generateShareChecksum(locationId: string): string { .createHmac('sha256', secret) .update(locationId) .digest('hex') - .substring(0, 16); // 64 bits of entropy (sufficient for share links) + .substring(0, CHECKSUM_LENGTH); } /** @@ -50,3 +55,32 @@ export function validateShareChecksum( 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 }; +}