refactor: use combined shareId (locationId + checksum) in URL

Changes:
- Add generateShareId() and extractShareId() helpers
- Share URLs now use single parameter: /share/location/{shareId}
- shareId = locationId (24 chars) + checksum (16 chars) = 40 chars total
- Update validateShareAccess() to extract locationId from shareId
- Update uploadProofOfPayment() to accept combined shareId
- Update LocationViewPage to validate and extract locationId from shareId

Benefits:
- Simpler URL structure (one parameter instead of two)
- Checksum extraction by length (deterministic, no parsing needed)
- Same security properties (HMAC-SHA256 validation)

🤖 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:22:59 +01:00
parent e497ad1da6
commit 844e386e18
5 changed files with 104 additions and 29 deletions

View File

@@ -1,14 +1,28 @@
import { ViewLocationCard } from '@/app/ui/ViewLocationCard'; 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 { getUserSettingsByUserId } from '@/app/lib/actions/userSettingsActions';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { myAuth } from '@/app/lib/auth'; 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 (
<div className="alert alert-error">
<p>{accessValidation.error || 'This content is no longer shared'}</p>
</div>
);
}
const locationId = accessValidation.locationId;
// Fetch location
const location = await fetchLocationById(locationId); const location = await fetchLocationById(locationId);
if (!location) { if (!location) {
return(notFound()); return notFound();
} }
// Fetch user settings for the location owner // Fetch user settings for the location owner
@@ -23,5 +37,11 @@ export default async function LocationViewPage({ locationId }: { locationId:stri
await setSeenByTenantAt(locationId); await setSeenByTenantAt(locationId);
} }
return (<ViewLocationCard location={location} userSettings={userSettings} />); return (
<ViewLocationCard
location={location}
userSettings={userSettings}
shareId={shareId}
/>
);
} }

View File

@@ -4,11 +4,10 @@ import { Main } from '@/app/ui/Main';
import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; 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 ( return (
<Main> <Main>
<Suspense fallback={<LocationEditFormSkeleton />}> <Suspense fallback={<LocationEditFormSkeleton />}>
<LocationViewPage locationId={id} /> <LocationViewPage shareId={id} />
</Suspense> </Suspense>
</Main> </Main>
); );

View File

@@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions';
import { getTranslations, getLocale } from "next-intl/server"; import { getTranslations, getLocale } from "next-intl/server";
import { IntlTemplateFn } from '@/app/i18n'; import { IntlTemplateFn } from '@/app/i18n';
import { unstable_noStore, revalidatePath } from 'next/cache'; import { unstable_noStore, revalidatePath } from 'next/cache';
import { validateShareChecksum } from '../shareChecksum'; import { extractShareId, validateShareChecksum } from '../shareChecksum';
import { validatePdfFile } from '../validators/pdfValidator'; import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter'; import { checkUploadRateLimit } from '../uploadRateLimiter';
@@ -492,11 +492,15 @@ export const deleteBillById = withUser(async (user: AuthenticatedUser, locationI
/** /**
* Uploads proof of payment for the given bill * Uploads proof of payment for the given bill
* SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP * 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 ( export const uploadProofOfPayment = async (
locationID: string, shareId: string,
billID: string, billID: string,
checksum: string,
formData: FormData, formData: FormData,
ipAddress?: string ipAddress?: string
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
@@ -504,7 +508,14 @@ export const uploadProofOfPayment = async (
unstable_noStore(); unstable_noStore();
try { 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)) { if (!validateShareChecksum(locationID, checksum)) {
return { success: false, error: 'Invalid share link' }; return { success: false, error: 'Invalid share link' };
} }
@@ -584,7 +595,7 @@ export const uploadProofOfPayment = async (
await cleanupExpiredShares(dbClient); await cleanupExpiredShares(dbClient);
// 8. REVALIDATE CACHE // 8. REVALIDATE CACHE
revalidatePath(`/share/location/${locationID}/${checksum}`, 'page'); revalidatePath(`/share/location/${shareId}`, 'page');
return { success: true }; return { success: true };

View File

@@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore, revalidatePath } from 'next/cache'; import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n'; import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server"; import { getTranslations, getLocale } from "next-intl/server";
import { generateShareChecksum, validateShareChecksum } from '../shareChecksum'; import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum';
export type State = { export type State = {
errors?: { errors?: {
@@ -735,12 +735,12 @@ export const generateShareLink = withUser(
} }
); );
// Generate checksum // Generate combined share ID (locationId + checksum)
const checksum = generateShareChecksum(locationId); const shareId = generateShareId(locationId);
// Build share URL // Build share URL
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const shareUrl = `${baseUrl}/share/location/${locationId}/${checksum}`; const shareUrl = `${baseUrl}/share/location/${shareId}`;
return { shareUrl }; return { shareUrl };
} }
@@ -751,21 +751,32 @@ export const generateShareLink = withUser(
* Called when tenant visits share link * Called when tenant visits share link
* *
* SECURITY: * SECURITY:
* 1. Validates checksum (stateless, prevents enumeration) * 1. Extracts locationId and checksum from combined shareId
* 2. Checks TTL in database (time-based access control) * 2. Validates checksum (stateless, prevents enumeration)
* 3. Marks first visit and resets TTL to 1 hour * 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( export async function validateShareAccess(
locationId: string, shareId: string
checksum: string ): Promise<{ valid: boolean; locationId?: string; error?: string }> {
): Promise<{ valid: boolean; 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)) { if (!validateShareChecksum(locationId, checksum)) {
return { valid: false, error: 'Invalid share link' }; return { valid: false, error: 'Invalid share link' };
} }
// 2. Check TTL in database // 3. Check TTL in database
const dbClient = await getDbClient(); const dbClient = await getDbClient();
const location = await dbClient.collection<BillingLocation>("lokacije").findOne( const location = await dbClient.collection<BillingLocation>("lokacije").findOne(
{ _id: locationId }, { _id: locationId },
@@ -776,12 +787,12 @@ export async function validateShareAccess(
return { valid: false, error: 'Invalid share link' }; return { valid: false, error: 'Invalid share link' };
} }
// 3. Check if sharing is enabled // 4. Check if sharing is enabled
if (!location.shareTTL) { if (!location.shareTTL) {
return { valid: false, error: 'This content is no longer shared' }; return { valid: false, error: 'This content is no longer shared' };
} }
// 4. Check if TTL expired // 5. Check if TTL expired
const now = new Date(); const now = new Date();
if (now > location.shareTTL) { if (now > location.shareTTL) {
// Clean up expired share // Clean up expired share
@@ -793,7 +804,7 @@ export async function validateShareAccess(
return { valid: false, error: 'This content is no longer shared' }; 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) { if (!location.shareFirstVisitedAt) {
const visitHours = parseInt(process.env.SHARE_TTL_AFTER_VISIT_HOURS || '1', 10); const visitHours = parseInt(process.env.SHARE_TTL_AFTER_VISIT_HOURS || '1', 10);
const newTTL = new Date(Date.now() + visitHours * 60 * 60 * 1000); 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 };
} }

View File

@@ -1,5 +1,10 @@
import crypto from 'crypto'; 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 * Generate share link checksum for location
* Uses HMAC-SHA256 for cryptographic integrity * Uses HMAC-SHA256 for cryptographic integrity
@@ -17,7 +22,7 @@ export function generateShareChecksum(locationId: string): string {
.createHmac('sha256', secret) .createHmac('sha256', secret)
.update(locationId) .update(locationId)
.digest('hex') .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; 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 };
}