feat: secure combined uploads and update UI components

Changes:
- Secure uploadUtilBillsProofOfPayment with checksum validation
- Update ViewLocationCard to accept and use shareId prop
- Update ViewBillCard to accept shareId and use it for uploads
- Update ViewBillBadge to pass shareId to bill detail pages
- Add client-side validation check for shareId before upload
- Update back button links to use shareId

Security improvements:
- Both per-bill and combined uploads now validate checksum and TTL
- IP-based rate limiting applied to both upload types
- PDF magic bytes validation for both upload types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-12-08 00:25:26 +01:00
parent 844e386e18
commit 81dddb526a
4 changed files with 102 additions and 49 deletions

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

@@ -17,9 +17,10 @@ import QRCode from "react-qr-code";
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 +48,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 +67,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 +127,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>
{