feat: add share link generation and validation functions

- Add generateShareLink() for owners to create share URLs with checksums
- Add validateShareAccess() to validate checksum and TTL on tenant visits
- Implement automatic TTL reset (10 days → 1 hour after first visit)
- Include automatic cleanup of expired shares

🤖 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:15:07 +01:00
parent a6ab35a959
commit 1cf1806955

View File

@@ -10,6 +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';
export type State = { export type State = {
errors?: { errors?: {
@@ -699,3 +700,117 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
return { success: false, error: error.message || 'Upload failed' }; return { success: false, error: error.message || 'Upload failed' };
} }
} }
/**
* Generate/activate share link for location
* Called when owner clicks "Share" button
* Sets shareTTL to 10 days from now
*/
export const generateShareLink = withUser(
async (user: AuthenticatedUser, locationId: string) => {
const { id: userId } = user;
const dbClient = await getDbClient();
// Verify ownership
const location = await dbClient.collection<BillingLocation>("lokacije").findOne({
_id: locationId,
userId
});
if (!location) {
return { error: 'Location not found' };
}
// Calculate TTL (10 days from now, configurable)
const initialDays = parseInt(process.env.SHARE_TTL_INITIAL_DAYS || '10', 10);
const shareTTL = new Date(Date.now() + initialDays * 24 * 60 * 60 * 1000);
// Activate sharing by setting TTL
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{ _id: locationId },
{
$set: { shareTTL },
$unset: { shareFirstVisitedAt: "" } // Reset first visit tracking
}
);
// Generate checksum
const checksum = generateShareChecksum(locationId);
// Build share URL
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const shareUrl = `${baseUrl}/share/location/${locationId}/${checksum}`;
return { shareUrl };
}
);
/**
* Validate share link and update TTL on first visit
* 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
*/
export async function validateShareAccess(
locationId: string,
checksum: string
): Promise<{ valid: boolean; error?: string }> {
// 1. Validate checksum FIRST (before DB query - stateless validation)
if (!validateShareChecksum(locationId, checksum)) {
return { valid: false, error: 'Invalid share link' };
}
// 2. Check TTL in database
const dbClient = await getDbClient();
const location = await dbClient.collection<BillingLocation>("lokacije").findOne(
{ _id: locationId },
{ projection: { shareTTL: 1, shareFirstVisitedAt: 1 } }
);
if (!location) {
return { valid: false, error: 'Invalid share link' };
}
// 3. Check if sharing is enabled
if (!location.shareTTL) {
return { valid: false, error: 'This content is no longer shared' };
}
// 4. Check if TTL expired
const now = new Date();
if (now > location.shareTTL) {
// Clean up expired share
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{ _id: locationId },
{ $unset: { shareTTL: "", shareFirstVisitedAt: "" } }
);
return { valid: false, error: 'This content is no longer shared' };
}
// 5. 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);
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId,
shareFirstVisitedAt: null // Only update if not already set
},
{
$set: {
shareFirstVisitedAt: new Date(),
shareTTL: newTTL
}
}
);
}
return { valid: true };
}