Merge branch 'release/2.13.0'
This commit is contained in:
@@ -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
13
.env
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "" } }
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
86
app/lib/shareChecksum.ts
Normal 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 };
|
||||
}
|
||||
71
app/lib/uploadRateLimiter.ts
Normal file
71
app/lib/uploadRateLimiter.ts
Normal 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);
|
||||
46
app/lib/validators/pdfValidator.ts
Normal file
46
app/lib/validators/pdfValidator.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "evidencija-rezija",
|
||||
"version": "2.12.1",
|
||||
"version": "2.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"version": "2.12.1",
|
||||
"version": "2.13.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -59,5 +59,5 @@
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"version": "2.12.1"
|
||||
"version": "2.13.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user