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 { 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user