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:
@@ -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?: {
|
||||||
@@ -698,4 +699,118 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
|
|||||||
console.error('Error uploading util bills proof of payment:', error);
|
console.error('Error uploading util bills proof of payment:', error);
|
||||||
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 };
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user