Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,945 changes: 1,178 additions & 767 deletions api/package-lock.json

Large diffs are not rendered by default.

61 changes: 54 additions & 7 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,11 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
activity,
operational,
detailed,
includeZeroAttendance
includeZeroAttendance,
roles
} = req.body;
try {
const MAX_SPAN = 1095 * 24 * 60 * 60 * 1000; // 1 year ms
const MAX_SPAN = 1095 * 24 * 60 * 60 * 1000; // 3 years ms
if (endEpoch - startEpoch > MAX_SPAN) {
res.status(400).json({ message: 'Date range too large (max 3 years)' });
return;
Expand All @@ -470,6 +471,9 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
if (name) query.name = name;
if (activity) query.activity = activity;
if (operational) query.operational = operational;
if(Array.isArray(roles) && roles.length === 1){
query.roles = roles[0]
}
const MAX_ROWS = 50000;
const recordsCursor = recordsCollection.find(query).limit(MAX_ROWS + 1);
const records = await recordsCursor.toArray();
Expand Down Expand Up @@ -557,13 +561,43 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
const reportExport: RequestHandler = async (req, res) => {
const authedReq = req as AuthedRequest;
authedReq.user = authedReq.session.user;
function isEmptyCellValue(v: unknown): boolean{
if (v == null) return true;
if (typeof v === "string") return v.trim() === ""
if (typeof v === "object"){
const obj = v as any;
if (obj.rechText) return obj.richText.length === 0;
if (obj.text) return String(obj.text).trim() === "";
if (obj.formula) return false;
if (obj.result != null) return false;
}
return false
}
function deleteColumnIfEmpty(
worksheet: ExcelJS.Worksheet,
colNumber: number,
headerRowNumber = 1
) {
let hasData = false;

worksheet.eachRow({includeEmpty: true}, (row, rowNumber) => {
if (rowNumber <= headerRowNumber) return;

const cellValue = row.getCell(colNumber).value;
if(!isEmptyCellValue(cellValue)) hasData = true;
});
if (!hasData){
worksheet.spliceColumns(colNumber, 1);
}
}
const {
startEpoch,
endEpoch,
name,
activity,
operational,
includeZeroAttendance,
roles,
detailed,
formattedStart,
formattedEnd
Expand All @@ -577,7 +611,9 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
if (name) query.name = name;
if (activity) query.activity = activity;
if (operational) query.operational = operational;

if(Array.isArray(roles) && roles.length === 1){
query.roles = roles[0]
};
const MAX_ROWS = 50000;
const recordsCursor = recordsCollection.find(query).limit(MAX_ROWS + 1);
const records = await recordsCursor.toArray();
Expand Down Expand Up @@ -620,6 +656,7 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
...(record.deploymentType && { deploymentType: record.deploymentType }),
...(record.otherType && { otherType: record.otherType }),
...(record.deploymentLocation && { deploymentLocation: record.deploymentLocation }),
...(record.roles && { roles: record.roles})
});

if (record.operational === "Operational") userStats.operationalActivities++;
Expand Down Expand Up @@ -652,6 +689,7 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
...(record.deploymentType && { deploymentType: record.deploymentType }),
...(record.otherType && { otherType: record.otherType }),
...(record.deploymentLocation && { deploymentLocation: record.deploymentLocation }),
...(record.roles && { roles: record.roles})
});
}
}
Expand Down Expand Up @@ -698,6 +736,7 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
'Activity',
'Activity Detail',
'Activity Location',
'Roles'
];
worksheet.addRow(header);
}
Expand Down Expand Up @@ -729,6 +768,10 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
activityType = record.deploymentType || "";
activityLocation = record.deploymentLocation || "";
}
const roles =
Array.isArray(record.roles) && record.roles.length > 0
? record.roles.join(", ")
: ""
const row = [
record.timestampLocal,
user.name,
Expand All @@ -739,7 +782,8 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
record.operational,
record.activity,
activityType,
activityLocation
activityLocation,
roles
];
worksheet.addRow(row);
}
Expand All @@ -757,7 +801,9 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
]
worksheet.addRow(row)
})

for(let i = 11; i > 1; i--){
deleteColumnIfEmpty(worksheet, i)
}
const fallbackFormat = (epoch: number) => new Date(epoch).toISOString().slice(0, 10).replace(/-/g, '');
const fileStart = formattedStart || fallbackFormat(startEpoch);
const fileEnd = formattedEnd || fallbackFormat(endEpoch);
Expand Down Expand Up @@ -787,7 +833,6 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(

const exists = await usersCollection.findOne({ username });
if (!exists){
console.log(username)
res.status(404).json({ ok: false });
return;}
req.session.validUsername = username; // 🔑 remember validation in this session
Expand Down Expand Up @@ -818,12 +863,14 @@ const tokenData = await fetchOrThrow<AzureTokenResponse>(
chainsawType,
deploymentType,
deploymentLocation,
otherType
otherType,
roles
} = req.body;
const record: any = {
name,
operational,
activity,
roles,
epochTimestamp
};
// Conditional data fields based on activity type
Expand Down
77 changes: 70 additions & 7 deletions api/src/middleware/sanitiseInputs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
import { Request, Response, NextFunction } from 'express';
import moment from 'moment';
import validator from 'validator';

const allowedRoles = new Set([
"Crew Leader",
"Driver",
"Pump Operator",
"BA Operator",
"BACO",
"Hose Operator",
"Chainsaw Operator",
]);

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}

function sanitiseRoles(value: unknown, opts?: { max?: number }): string[] | null {
if (value == null) return [];
if (!isStringArray(value)) return null;

const max = opts?.max ?? 20;
if (value.length > max) return null;

const sanitised = value
.map((r) => validator.trim(r).replace(/\0/g, ""))
.filter((r) => r.length > 0);

for (const r of sanitised) {
if (!allowedRoles.has(r)) return null;
}

return Array.from(new Set(sanitised));
}

export function sanitizeAttendanceInput(req: Request, res: Response, next: NextFunction) {
const {
name,
Expand All @@ -11,9 +44,18 @@ export function sanitizeAttendanceInput(req: Request, res: Response, next: NextF
chainsawType,
deploymentType,
deploymentLocation,
otherType
otherType,
roles
} = req.body;

const sanitisedRoles = sanitiseRoles(roles);
if (sanitisedRoles === null){
res.status(400).json({
message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`,
});
return
}

const sanitized = {
name: validator.trim(name || ''),
operational: validator.trim(operational || ''),
Expand All @@ -22,7 +64,8 @@ export function sanitizeAttendanceInput(req: Request, res: Response, next: NextF
chainsawType: validator.trim(chainsawType || ''),
deploymentType: validator.trim(deploymentType || ''),
deploymentLocation: validator.trim(deploymentLocation || ''),
otherType: validator.trim(otherType || '')
otherType: validator.trim(otherType || ''),
roles: sanitisedRoles
};

const validators = [
Expand Down Expand Up @@ -132,17 +175,27 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex
activity,
operational,
detailed,
includeZeroAttendance
includeZeroAttendance,
roles
} = req.body ?? {};

const sanitisedRoles = sanitiseRoles(roles);
if (sanitisedRoles === null){
res.status(400).json({
message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`,
});
return
}

const asTrimmedString = (v: unknown) => validator.trim(String(v ?? ''));

const sanitized = {
name: asTrimmedString(name),
operational: asTrimmedString(operational),
activity: asTrimmedString(activity),
detailed: parseBoolean(detailed),
includeZeroAttendance: parseBoolean(includeZeroAttendance)
includeZeroAttendance: parseBoolean(includeZeroAttendance),
roles: sanitisedRoles
};

const validators = [
Expand All @@ -151,7 +204,7 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex
{ value: sanitized.activity, pattern: /^[a-zA-Z0-9\s-]+$/, field: 'activity' },
] as const;

const minMS = moment.tz('2000-01-01 00:00:00', 'Australia/Sydney').valueOf();
const minMS = moment.tz('2023-01-01 00:00:00', 'Australia/Sydney').valueOf();
const maxMS = moment.tz('2100-12-31 23:59:59.999', 'Australia/Sydney').valueOf();
function isEpochMS(n: unknown): n is number{
return typeof n === 'number'
Expand All @@ -168,7 +221,7 @@ export function sanitizeReportingRunInput(req: Request, res: Response, next: Nex

const startEpochMS = Number(startEpoch)
const endEpochMS = Number(endEpoch)
if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be be after Jan 1 2000'})}
if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be after Jan 1 2023'})}
if (!isEpochMS(endEpochMS)){return res.status(400).json({message: 'End time must be before Dec 31 2100'})}
req.body = {
...sanitized,
Expand Down Expand Up @@ -199,11 +252,20 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next:
activity,
operational,
includeZeroAttendance,
roles,
detailed,
formattedStart,
formattedEnd
} = req.body ?? {};

const sanitisedRoles = sanitiseRoles(roles);
if (sanitisedRoles === null){
res.status(400).json({
message: `Invalid roles. Must be an array containing only: ${Array.from(allowedRoles).join(", ")}`,
});
return
}

const sanitized = {
name: validator.trim(String(name ?? '')),
operational: validator.trim(String(operational ?? '')),
Expand All @@ -213,6 +275,7 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next:

includeZeroAttendance: parseBoolean(includeZeroAttendance),
detailed: parseBoolean(detailed),
roles: sanitisedRoles
};

function runRule(rule: { value: any; field: string; pattern?: RegExp; validate?: (v: any) => boolean }) {
Expand Down Expand Up @@ -247,7 +310,7 @@ export function sanitizeReportingExportInput(req: Request, res: Response, next:
}
const startEpochMS = Number(startEpoch)
const endEpochMS = Number(endEpoch)
if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be after Jan 1 2023'})}
if (!isEpochMS(startEpochMS)) {return res.status(400).json({message: 'Start time must be bafter Jan 1 2023'})}
if (!isEpochMS(endEpochMS)){return res.status(400).json({message: 'End time must be before Dec 31 2100'})}

const errors: string[] = [];
Expand Down
Loading
Loading