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:
@@ -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 (
|
||||
<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);
|
||||
|
||||
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 (<ViewLocationCard location={location} userSettings={userSettings} />);
|
||||
return (
|
||||
<ViewLocationCard
|
||||
location={location}
|
||||
userSettings={userSettings}
|
||||
shareId={shareId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Main>
|
||||
<Suspense fallback={<LocationEditFormSkeleton />}>
|
||||
<LocationViewPage locationId={id} />
|
||||
<LocationViewPage shareId={id} />
|
||||
</Suspense>
|
||||
</Main>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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<BillingLocation>("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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user