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 };
+}