Merge branch 'feature/improving-security' into develop

This commit is contained in:
Knee Cola
2025-12-08 01:23:56 +01:00
21 changed files with 755 additions and 173 deletions

View File

@@ -13,7 +13,10 @@
"mcp__ide__getDiagnostics",
"mcp__serena__execute_shell_command",
"mcp__serena__check_onboarding_performed",
"Bash(npm run build:*)"
"Bash(npm run build:*)",
"Bash(openssl rand:*)",
"Bash(ls:*)",
"Bash(find:*)"
]
},
"enableAllProjectMcpServers": true,

13
.env
View File

@@ -9,4 +9,15 @@ LINKEDIN_SECRET=ugf61aJ2iyErLK40
USE_MOCK_AUTH=true
MAX_BILL_ATTACHMENT_UPLOAD_SIZE_KB=1024
MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024
MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024
# Share link security
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
# Share link TTL configuration
SHARE_TTL_INITIAL_DAYS=10
SHARE_TTL_AFTER_VISIT_HOURS=1
# Rate limiting for uploads
UPLOAD_RATE_LIMIT_PER_IP=5
UPLOAD_RATE_LIMIT_WINDOW_MS=3600000

View File

@@ -1,12 +1,49 @@
import { fetchBillById } from '@/app/lib/actions/billActions';
import { notFound } from 'next/navigation';
import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum';
import { getDbClient } from '@/app/lib/dbClient';
import { BillingLocation } from '@/app/lib/db-types';
export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) {
const [locationID, billID] = id.split('-');
export async function GET(request: Request, { params: { id } }: { params: { id: string } }) {
// Parse shareId-billID format
// shareId = 40 chars (locationId 24 + checksum 16)
const shareId = id.substring(0, 40);
const billID = id.substring(41); // Skip the '-' separator
const [location, bill] = await fetchBillById(locationID, billID, true) ?? [];
if (!shareId || !billID) {
notFound();
}
if(!bill?.attachment) {
// Validate shareId and extract locationId
const extracted = extractShareId(shareId);
if (!extracted) {
notFound();
}
const { locationId: locationID, checksum } = extracted;
// Validate checksum
if (!validateShareChecksum(locationID, checksum)) {
notFound();
}
// Check TTL before fetching bill
const dbClient = await getDbClient();
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { shareTTL: 1 } });
if (!location) {
notFound();
}
// Check if sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
notFound();
}
const [_, bill] = await fetchBillById(locationID, billID, true) ?? [];
if (!bill?.attachment) {
notFound();
}

View File

@@ -2,19 +2,40 @@ import { fetchBillById } from '@/app/lib/actions/billActions';
import { ViewBillCard } from '@/app/ui/ViewBillCard';
import { Main } from '@/app/ui/Main';
import { notFound } from 'next/navigation';
import { validateShareAccess } from '@/app/lib/actions/locationActions';
export default async function Page({ params:{ id } }: { params: { id:string } }) {
export default async function Page({ params: { id } }: { params: { id: string } }) {
const [locationID, billID] = id.split('-');
// Split combined ID: shareId (40 chars) + '-' + billID (24 chars)
// ShareId = locationId (24) + checksum (16) = 40 chars
const shareId = id.substring(0, 40);
const billID = id.substring(41); // Skip the '-' separator
// Validate share access (checks checksum + TTL, extracts locationId)
const accessValidation = await validateShareAccess(shareId);
if (!accessValidation.valid || !accessValidation.locationId) {
return (
<Main>
<div className="alert alert-error">
<p>{accessValidation.error || 'This content is no longer shared'}</p>
</div>
</Main>
);
}
const locationID = accessValidation.locationId;
// Fetch bill data
const [location, bill] = await fetchBillById(locationID, billID) ?? [];
if (!bill || !location) {
return(notFound());
return notFound();
}
return (
<Main>
<ViewBillCard location={location} bill={bill} />
<ViewBillCard location={location} bill={bill} shareId={shareId} />
</Main>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);

View File

@@ -1,19 +1,39 @@
import { getDbClient } from '@/app/lib/dbClient';
import { BillingLocation } from '@/app/lib/db-types';
import { notFound } from 'next/navigation';
import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum';
export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) {
const locationID = id;
export async function GET(request: Request, { params: { id } }: { params: { id: string } }) {
const shareId = id;
// Validate shareId and extract locationId
const extracted = extractShareId(shareId);
if (!extracted) {
notFound();
}
const { locationId: locationID, checksum } = extracted;
// Validate checksum
if (!validateShareChecksum(locationID, checksum)) {
notFound();
}
const dbClient = await getDbClient();
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, {
projection: {
utilBillsProofOfPayment: 1,
shareTTL: 1,
}
});
if(!location?.utilBillsProofOfPayment) {
if (!location?.utilBillsProofOfPayment) {
notFound();
}
// Check if sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
notFound();
}

View File

@@ -1,12 +1,28 @@
import { getDbClient } from '@/app/lib/dbClient';
import { BillingLocation } from '@/app/lib/db-types';
import { notFound } from 'next/navigation';
import { extractShareId, validateShareChecksum } from '@/app/lib/shareChecksum';
export async function GET(_request: Request, { params:{ id } }: { params: { id:string } }) {
// Parse locationID-billID format
const [locationID, billID] = id.split('-');
export async function GET(_request: Request, { params: { id } }: { params: { id: string } }) {
// Parse shareId-billID format
// shareId = 40 chars (locationId 24 + checksum 16)
const shareId = id.substring(0, 40);
const billID = id.substring(41); // Skip the '-' separator
if (!locationID || !billID) {
if (!shareId || !billID) {
notFound();
}
// Validate shareId and extract locationId
const extracted = extractShareId(shareId);
if (!extracted) {
notFound();
}
const { locationId: locationID, checksum } = extracted;
// Validate checksum
if (!validateShareChecksum(locationID, checksum)) {
notFound();
}
@@ -14,13 +30,19 @@ export async function GET(_request: Request, { params:{ id } }: { params: { id:s
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, {
projection: {
// Don't load bill attachments, only proof of payment
// Don't load bill attachments, only proof of payment and shareTTL
"bills._id": 1,
"bills.proofOfPayment": 1,
"shareTTL": 1,
}
});
if(!location) {
if (!location) {
notFound();
}
// Check if sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
notFound();
}

View File

@@ -10,6 +10,9 @@ import { gotoHomeWithMessage } from './navigationActions';
import { getTranslations, getLocale } from "next-intl/server";
import { IntlTemplateFn } from '@/app/i18n';
import { unstable_noStore, revalidatePath } from 'next/cache';
import { extractShareId, validateShareChecksum } from '../shareChecksum';
import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter';
export type State = {
errors?: {
@@ -488,94 +491,129 @@ export const deleteBillById = withUser(async (user: AuthenticatedUser, locationI
/**
* Uploads proof of payment for the given bill
* @param locationID - The ID of the location
* @param formData - FormData containing the file
* @returns Promise with success status
* 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, billID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => {
export const uploadProofOfPayment = async (
shareId: string,
billID: string,
formData: FormData,
ipAddress?: string
): Promise<{ success: boolean; error?: string }> => {
unstable_noStore();
unstable_noStore();
try {
// First validate that the file is acceptable
const file = formData.get('proofOfPayment') as File;
// validate max file size from env variable
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
const maxFileSizeBytes = maxFileSizeKB * 1024;
if (file && file.size > maxFileSizeBytes) {
return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` };
}
// Validate file type
if (file && file.size > 0 && file.type !== 'application/pdf') {
return { success: false, error: 'Only PDF files are accepted' };
}
// update the bill in the mongodb
const dbClient = await getDbClient();
const projection = {
// attachment is not required in this context - this will reduce data transfer
"bills.attachment": 0,
// ommit file content - not needed here - this will reduce data transfer
"bills.proofOfPayment.fileContentsBase64": 0,
};
// Checking if proof of payment already exists
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije").findOne(
{
_id: locationID,
},
{
projection
})
if (!billLocation) {
console.log(`Location ${locationID} not found - Proof of payment upload failed`);
return { success: false, error: "Location not found - Proof of payment upload failed" };
}
// find a bill with the given billID
const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID);
if (bill?.proofOfPayment?.uploadedAt) {
return { success: false, error: 'Proof payment already uploaded for this bill' };
}
const attachment = await serializeAttachment(file);
if (!attachment) {
return { success: false, error: 'Invalid file' };
}
// Add proof of payment to the bill
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationID // find a location with the given locationID
},
{
$set: {
"bills.$[elem].proofOfPayment": {
...attachment
}
}
}, {
arrayFilters: [
{ "elem._id": { $eq: billID } } // find a bill with the given billID
]
});
// Invalidate the location view cache
revalidatePath(`/share/location/${locationID}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Error uploading proof of payment for a bill:', error);
return { success: false, error: error.message || 'Upload failed' };
try {
// 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' };
}
// 2. RATE LIMITING (per IP)
if (ipAddress) {
const rateLimit = checkUploadRateLimit(ipAddress);
if (!rateLimit.allowed) {
return {
success: false,
error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.`
};
}
}
// 3. DATABASE VALIDATION
const dbClient = await getDbClient();
const location = await dbClient.collection<BillingLocation>("lokacije").findOne(
{ _id: locationID },
{ projection: { userId: 1, bills: 1, shareTTL: 1 } }
);
if (!location || !location.userId) {
return { success: false, error: 'Invalid request' };
}
// Check sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
return { success: false, error: 'This content is no longer shared' };
}
// Verify bill exists in location
const bill = location.bills.find(b => b._id === billID);
if (!bill) {
return { success: false, error: 'Invalid request' };
}
// Check if proof of payment already uploaded
if (bill.proofOfPayment?.uploadedAt) {
return { success: false, error: 'Proof of payment already uploaded for this bill' };
}
// 4. FILE VALIDATION
const file = formData.get('proofOfPayment') as File;
if (!file || file.size === 0) {
return { success: false, error: 'No file provided' };
}
// Validate PDF content (magic bytes, not just MIME type)
const pdfValidation = await validatePdfFile(file);
if (!pdfValidation.valid) {
return { success: false, error: pdfValidation.error };
}
// 5. SERIALIZE & STORE FILE
const attachment = await serializeAttachment(file);
if (!attachment) {
return { success: false, error: 'Failed to process file' };
}
// 6. UPDATE DATABASE
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{ _id: locationID },
{
$set: {
"bills.$[elem].proofOfPayment": attachment
}
},
{
arrayFilters: [{ "elem._id": { $eq: billID } }]
}
);
// 7. CLEANUP EXPIRED SHARES (integrated, no cron needed)
await cleanupExpiredShares(dbClient);
// 8. REVALIDATE CACHE
revalidatePath(`/share/location/${shareId}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Upload error:', error);
return { success: false, error: 'Upload failed. Please try again.' };
}
};
/**
* Clean up expired shares during upload processing
* Removes shareTTL and shareFirstVisitedAt from expired locations
*/
async function cleanupExpiredShares(dbClient: any) {
const now = new Date();
await dbClient.collection("lokacije").updateMany(
{ shareTTL: { $lt: now } },
{ $unset: { shareTTL: "", shareFirstVisitedAt: "" } }
);
}

View File

@@ -10,6 +10,9 @@ import { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum';
import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter';
export type State = {
errors?: {
@@ -637,65 +640,230 @@ const serializeAttachment = async (file: File | null):Promise<FileAttachment | n
/**
* Uploads a single proof of payment for all utility bills in a location
* @param locationID - The ID of the location
* @param formData - FormData containing the file
* SECURITY: Validates checksum, TTL, PDF content, and rate limits by IP
*
* @param shareId - Combined location ID + checksum (40 chars)
* @param formData - FormData containing the PDF file
* @param ipAddress - Optional IP address for rate limiting
* @returns Promise with success status
*/
export const uploadUtilBillsProofOfPayment = async (locationID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => {
export const uploadUtilBillsProofOfPayment = async (
shareId: string,
formData: FormData,
ipAddress?: string
): Promise<{ success: boolean; error?: string }> => {
unstable_noStore();
try {
// First validate that the file is acceptable
const file = formData.get('utilBillsProofOfPayment') as File;
// validate max file size from env variable
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
const maxFileSizeBytes = maxFileSizeKB * 1024;
if (file && file.size > maxFileSizeBytes) {
return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` };
// 1. EXTRACT AND VALIDATE CHECKSUM (stateless, fast)
const extracted = extractShareId(shareId);
if (!extracted) {
console.log('shareID extraction failed');
return { success: false, error: 'Invalid share link' };
}
// Validate file type
if (file && file.size > 0 && file.type !== 'application/pdf') {
return { success: false, error: 'Only PDF files are accepted' };
const { locationId: locationID, checksum } = extracted;
if (!validateShareChecksum(locationID, checksum)) {
console.log('shareID checksum validation failed');
return { success: false, error: 'Invalid share link' };
}
// check if attachment already exists for the location
// 2. RATE LIMITING (per IP)
if (ipAddress) {
const rateLimit = checkUploadRateLimit(ipAddress);
if (!rateLimit.allowed) {
return {
success: false,
error: `Too many uploads. Try again in ${Math.ceil(rateLimit.resetIn / 60)} minutes.`
};
}
}
// 3. DATABASE VALIDATION
const dbClient = await getDbClient();
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } });
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { userId: 1, utilBillsProofOfPayment: 1, shareTTL: 1 } });
if (existingLocation?.utilBillsProofOfPayment) {
return { success: false, error: 'An attachment already exists for this location' };
if (!location || !location.userId) {
return { success: false, error: 'Invalid request' };
}
// Check sharing is active and not expired
if (!location.shareTTL || new Date() > location.shareTTL) {
return { success: false, error: 'This content is no longer shared' };
}
// Check if proof of payment already uploaded
if (location.utilBillsProofOfPayment) {
return { success: false, error: 'Proof of payment already uploaded for this location' };
}
// 4. FILE VALIDATION
const file = formData.get('utilBillsProofOfPayment') as File;
if (!file || file.size === 0) {
return { success: false, error: 'No file provided' };
}
// Validate PDF content (magic bytes, not just MIME type)
const pdfValidation = await validatePdfFile(file);
if (!pdfValidation.valid) {
return { success: false, error: pdfValidation.error };
}
// 5. SERIALIZE & STORE FILE
const attachment = await serializeAttachment(file);
if (!attachment) {
return { success: false, error: 'Invalid file' };
return { success: false, error: 'Failed to process file' };
}
// Update the location with the attachment
// 6. UPDATE DATABASE
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: {
utilBillsProofOfPayment: {
...attachment
},
utilBillsProofOfPayment: attachment
} }
);
// Invalidate the location view cache
revalidatePath(`/share/location/${locationID}`, 'page');
// 7. REVALIDATE CACHE
revalidatePath(`/share/location/${shareId}`, 'page');
return { success: true };
} catch (error: any) {
console.error('Error uploading util bills proof of payment:', error);
return { success: false, error: error.message || 'Upload failed' };
console.error('Upload error:', error);
return { success: false, error: 'Upload failed. Please try again.' };
}
}
/**
* 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 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/${shareId}`;
return { shareUrl };
}
);
/**
* Validate share link and update TTL on first visit
* Called when tenant visits share link
*
* SECURITY:
* 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(
shareId: string
): Promise<{ valid: boolean; locationId?: string; error?: string }> {
// 1. Extract locationId and checksum from combined ID
const extracted = extractShareId(shareId);
if (!extracted) {
console.log('shareID extraction failed');
return { valid: false, error: 'Invalid share link' };
}
const { locationId, checksum } = extracted;
// 2. Validate checksum FIRST (before DB query - stateless validation)
if (!validateShareChecksum(locationId, checksum)) {
console.log('shareID checksum validation failed');
return { valid: false, error: 'Invalid share link' };
}
// 3. 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) {
console.log('Location not found for shareID');
return { valid: false, error: 'Invalid share link' };
}
// 4. Check if sharing is enabled
if (!location.shareTTL) {
return { valid: false, error: 'This content is no longer shared' };
}
// 5. 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' };
}
// 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);
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, locationId };
}

View File

@@ -81,6 +81,10 @@ export interface BillingLocation {
utilBillsProofOfPayment?: FileAttachment|null;
/** (optional) rent proof of payment attachment */
rentProofOfPayment?: FileAttachment|null;
/** (optional) share link expiry timestamp */
shareTTL?: Date;
/** (optional) when tenant first visited the share link */
shareFirstVisitedAt?: Date | null;
};
export enum BilledTo {

86
app/lib/shareChecksum.ts Normal file
View File

@@ -0,0 +1,86 @@
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
*
* SECURITY: Prevents location ID enumeration while allowing stateless validation
*/
export function generateShareChecksum(locationId: string): string {
const secret = process.env.SHARE_LINK_SECRET;
if (!secret) {
throw new Error('SHARE_LINK_SECRET environment variable not configured');
}
return crypto
.createHmac('sha256', secret)
.update(locationId)
.digest('hex')
.substring(0, CHECKSUM_LENGTH);
}
/**
* Validate share link checksum
* Uses constant-time comparison to prevent timing attacks
*
* @param locationId - The location ID from URL
* @param providedChecksum - The checksum from URL
* @returns true if checksum is valid
*/
export function validateShareChecksum(
locationId: string,
providedChecksum: string
): boolean {
try {
const expectedChecksum = generateShareChecksum(locationId);
// Convert to buffers for timing-safe comparison
const expected = Buffer.from(expectedChecksum);
const provided = Buffer.from(providedChecksum);
// Length check (prevents timing attack on different lengths)
if (expected.length !== provided.length) {
return false;
}
// Constant-time comparison (prevents timing attacks)
return crypto.timingSafeEqual(expected, provided);
} catch {
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 };
}

View File

@@ -0,0 +1,71 @@
/**
* Simple in-memory rate limiter for upload attempts
* Tracks by IP address
*/
interface RateLimitEntry {
count: number;
resetAt: number; // Unix timestamp
}
// In-memory store (use Redis for production multi-instance setups)
const rateLimitStore = new Map<string, RateLimitEntry>();
/**
* Check if IP address is rate limited
* @returns { allowed: boolean, remaining: number }
*/
export function checkUploadRateLimit(ipAddress: string): { allowed: boolean; remaining: number; resetIn: number } {
const maxUploads = parseInt(process.env.UPLOAD_RATE_LIMIT_PER_IP || '5', 10);
const windowMs = parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || '3600000', 10); // 1 hour
const now = Date.now();
const key = `upload:${ipAddress}`;
let entry = rateLimitStore.get(key);
// Clean up expired entry or create new one
if (!entry || now > entry.resetAt) {
entry = {
count: 0,
resetAt: now + windowMs
};
rateLimitStore.set(key, entry);
}
// Check if limit exceeded
if (entry.count >= maxUploads) {
return {
allowed: false,
remaining: 0,
resetIn: Math.ceil((entry.resetAt - now) / 1000) // seconds
};
}
// Increment counter
entry.count++;
rateLimitStore.set(key, entry);
return {
allowed: true,
remaining: maxUploads - entry.count,
resetIn: Math.ceil((entry.resetAt - now) / 1000)
};
}
/**
* Periodic cleanup of expired entries (prevent memory leak)
* Call this occasionally (e.g., every hour)
*/
export function cleanupRateLimitStore() {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetAt) {
rateLimitStore.delete(key);
}
}
}
// Auto-cleanup every 10 minutes
setInterval(cleanupRateLimitStore, 10 * 60 * 1000);

View File

@@ -0,0 +1,46 @@
/**
* Validate that uploaded file is a legitimate PDF
* Checks magic bytes, not just MIME type
*/
export async function validatePdfFile(file: File): Promise<{ valid: boolean; error?: string }> {
// Check file size first (quick rejection)
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
const maxFileSizeBytes = maxFileSizeKB * 1024;
if (file.size === 0) {
return { valid: false, error: 'File is empty' };
}
if (file.size > maxFileSizeBytes) {
return { valid: false, error: `File size exceeds ${maxFileSizeKB} KB limit` };
}
// Read file content
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Check PDF magic bytes (header signature)
// PDF files must start with "%PDF-" (bytes: 25 50 44 46 2D)
const header = buffer.toString('utf-8', 0, 5);
if (!header.startsWith('%PDF-')) {
return { valid: false, error: 'Invalid PDF file format' };
}
// Optional: Check for PDF version (1.0 to 2.0)
const version = buffer.toString('utf-8', 5, 8); // e.g., "1.4", "1.7", "2.0"
const versionMatch = version.match(/^(\d+\.\d+)/);
if (!versionMatch) {
return { valid: false, error: 'Invalid PDF version' };
}
// Optional: Verify PDF EOF marker (should end with %%EOF)
// Note: Some PDFs have trailing data, so this is lenient
const endSection = buffer.toString('utf-8', Math.max(0, buffer.length - 1024));
if (!endSection.includes('%%EOF')) {
console.warn('PDF missing EOF marker - may be corrupted');
// Don't reject, just warn (some valid PDFs have non-standard endings)
}
return { valid: true };
}

View File

@@ -253,7 +253,7 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 text-teal-500" />
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.2em] text-teal-500" />
{decodeURIComponent(proofOfPayment.fileName)}
</Link>
</div>

View File

@@ -10,6 +10,7 @@ import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "react-toastify";
import { get } from "http";
import { generateShareLink } from "../lib/actions/locationActions";
export interface LocationCardProps {
location: BillingLocation;
@@ -33,13 +34,17 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
// sum all the paid bill amounts (regardless of who pays)
const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
const handleCopyLinkClick = () => {
const handleCopyLinkClick = async () => {
// copy URL to clipboard
const url = `${window.location.origin}/${currentLocale}/share/location/${_id}`;
navigator.clipboard.writeText(url);
const shareLink = await generateShareLink(_id);
if(shareLink.error) {
toast.error(shareLink.error, { theme: "dark" });
} else {
navigator.clipboard.writeText(shareLink.shareUrl as string);
toast.success(t("link-copy-message"), { theme: "dark" });
}
// use NextJS toast to notiy user that the link was copied
toast.success(t("link-copy-message"), { theme: "dark" });
}
return (

View File

@@ -5,18 +5,22 @@ import { TicketIcon } from "@heroicons/react/24/outline";
import { useLocale } from "next-intl";
export interface ViewBillBadgeProps {
locationId: string,
bill: Bill
locationId: string;
shareId?: string;
bill: Bill;
};
export const ViewBillBadge: FC<ViewBillBadgeProps> = ({ locationId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => {
export const ViewBillBadge: FC<ViewBillBadgeProps> = ({ locationId, shareId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => {
const currentLocale = useLocale();
const className = `badge badge-lg p-[1em] ${paid ? "badge-success" : " badge-outline"} ${!paid && !!attachment ? "btn-outline btn-success" : ""} cursor-pointer`;
// Use shareId if available (for shared views), otherwise use locationId (for owner views)
const billPageId = shareId || locationId;
return (
<Link href={`/${currentLocale}//share/bill/${locationId}-${billId}`} className={className}>
<Link href={`/${currentLocale}//share/bill/${billPageId}-${billId}`} className={className}>
{name}
{
proofOfPayment?.uploadedAt ?

View File

@@ -11,11 +11,12 @@ import { Pdf417Barcode } from "./Pdf417Barcode";
import { uploadProofOfPayment } from "../lib/actions/billActions";
export interface ViewBillCardProps {
location: BillingLocation,
bill: Bill,
location: BillingLocation;
bill: Bill;
shareId?: string;
}
export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill, shareId }) => {
const router = useRouter();
const t = useTranslations("bill-edit-form");
@@ -31,23 +32,28 @@ export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
// Validate file type client-side (quick feedback)
if (file.type !== 'application/pdf') {
setUploadError('Only PDF files are accepted');
e.target.value = ''; // Reset input
return;
}
if (!shareId) {
setUploadError('Invalid upload link');
return;
}
setIsUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('proofOfPayment', file);
const result = await uploadProofOfPayment(locationID, billID as string, formData);
const result = await uploadProofOfPayment(shareId, billID as string, formData);
if (result.success) {
setProofOfPaymentFilename(file.name);
setProofOfPaymentUploadedAt(new Date());
@@ -94,7 +100,7 @@ export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
attachment ?
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
<p className="font-bold uppercase">{t("attachment")}</p>
<Link href={`/share/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
<Link href={`/share/attachment/${shareId || locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachment.fileName)}
</Link>
@@ -124,11 +130,11 @@ export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
proofOfPaymentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
href={`/share/proof-of-payment/per-bill/${shareId || locationID}-${billID}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.1em] text-teal-500" />
{ decodeURIComponent(proofOfPaymentFilename) }
</Link>
</div>
@@ -161,7 +167,7 @@ export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
}
<div className="text-right">
<Link className="btn btn-neutral ml-3" href={`/share/location/${locationID}`}>{t("back-button")}</Link>
<Link className="btn btn-neutral ml-3" href={`/share/location/${shareId || locationID}`}>{t("back-button")}</Link>
</div>
</div>

View File

@@ -10,16 +10,18 @@ import { ViewBillBadge } from "./ViewBillBadge";
import { Pdf417Barcode } from "./Pdf417Barcode";
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link";
import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline";
import { LinkIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
import QRCode from "react-qr-code";
import { TicketIcon } from "@heroicons/react/24/solid";
export interface ViewLocationCardProps {
location: BillingLocation;
userSettings: UserSettings | null;
shareId?: string;
}
export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSettings }) => {
export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSettings, shareId }) => {
const {
_id,
@@ -47,13 +49,18 @@ export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSett
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
// Validate file type client-side (quick feedback)
if (file.type !== 'application/pdf') {
setUploadError('Only PDF files are accepted');
e.target.value = ''; // Reset input
return;
}
if (!shareId) {
setUploadError('Invalid upload link');
return;
}
setIsUploading(true);
setUploadError(null);
@@ -61,7 +68,7 @@ export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSett
const formData = new FormData();
formData.append('utilBillsProofOfPayment', file);
const result = await uploadUtilBillsProofOfPayment(_id, formData);
const result = await uploadUtilBillsProofOfPayment(shareId, formData);
if (result.success) {
setAttachmentFilename(file.name);
@@ -121,7 +128,7 @@ export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSett
<h2 className="card-title mr-[2em] text-[1.3rem]">{formatYearMonth(yearMonth)} {locationName}</h2>
<div className="card-actions mt-[1em] mb-[1em]">
{
bills.filter(bill => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant).map(bill => <ViewBillBadge key={`${_id}-${bill._id}`} locationId={_id} bill={bill} />)
bills.filter(bill => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant).map(bill => <ViewBillBadge key={`${_id}-${bill._id}`} locationId={_id} shareId={shareId} bill={bill} />)
}
</div>
{
@@ -189,11 +196,11 @@ export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSett
attachmentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/combined/${_id}/`}
href={`/share/proof-of-payment/combined/${shareId || _id}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.1em] text-teal-500" />
{decodeURIComponent(attachmentFilename)}
</Link>
</div>

View File

@@ -29,6 +29,13 @@ services:
HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses)
NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME)
PORT: ${PORT:-80}
# Share link security
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
SHARE_TTL_INITIAL_DAYS: 10
SHARE_TTL_AFTER_VISIT_HOURS: 1
# Upload rate limiting
UPLOAD_RATE_LIMIT_PER_IP: 5
UPLOAD_RATE_LIMIT_WINDOW_MS: 3600000
container_name: evidencija-rezija__web-app
restart: unless-stopped # u slučaju rušenja containera pokušavaj ga pokrenuti dok ne uspije = BESKONAČNO
depends_on:

View File

@@ -29,6 +29,13 @@ services:
HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses)
NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME)
PORT: ${PORT:-80}
# Share link security
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
SHARE_TTL_INITIAL_DAYS: 10
SHARE_TTL_AFTER_VISIT_HOURS: 1
# Upload rate limiting
UPLOAD_RATE_LIMIT_PER_IP: 5
UPLOAD_RATE_LIMIT_WINDOW_MS: 3600000
deploy:
# u slucaju rušenja kontejnera čekamo 5s i dižemo novi kontejner => ako se i on sruši opet ceka 5s i pokusava ponovno (tako 5 puta)
restart_policy: