diff --git a/frontend/src/routes/tools/+page.svelte b/frontend/src/routes/tools/+page.svelte
index 7686661..c4e0cf0 100644
--- a/frontend/src/routes/tools/+page.svelte
+++ b/frontend/src/routes/tools/+page.svelte
@@ -7,6 +7,9 @@
import FormError from '$lib/components/FormError.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import TextFieldMultiSelect from '$lib/components/TextFieldMultiSelect.svelte';
+ import TextareaField from '$lib/components/TextareaField.svelte';
+ import CheckboxField from '$lib/components/CheckboxField.svelte';
+ import { addToast } from '$lib/store/toast';
let ipAddress = '';
let isSubmitting = false;
@@ -446,6 +449,262 @@
};
return alpnMap[alpn] || 'h2 - HTTP/2 (h2)';
}
+
+ // ics calendar invitation builder
+ let icsSummary = 'IT Portal Access Validation';
+ let icsOrganizerName = 'IT Department';
+ let icsOrganizerEmail = 'it@example.com';
+ let icsAttendee = '{{.Email}}';
+ let icsLocation = '{{.URL}}';
+ let icsDescription = 'Please review your account details here: {{.URL}}';
+ let icsDate = '';
+ let icsTime = '09:00';
+ let icsDuration = '30';
+ let icsTimezone = 'floating';
+ let icsAddReminder = false;
+ let icsReminder = '15';
+ // the uid stays stable while editing so the same invite is not seen as a new
+ // event on every keystroke. regenerate it explicitly with the button.
+ let icsUID = '';
+ let icsResult = '';
+
+ const durationOptions = [
+ { value: '15', label: '15 minutes' },
+ { value: '30', label: '30 minutes' },
+ { value: '45', label: '45 minutes' },
+ { value: '60', label: '1 hour' },
+ { value: '90', label: '1 hour 30 minutes' },
+ { value: '120', label: '2 hours' },
+ { value: '240', label: '4 hours' },
+ { value: '480', label: 'Full day (8 hours)' }
+ ];
+
+ const reminderOptions = [
+ { value: '5', label: '5 minutes before' },
+ { value: '10', label: '10 minutes before' },
+ { value: '15', label: '15 minutes before' },
+ { value: '30', label: '30 minutes before' },
+ { value: '60', label: '1 hour before' },
+ { value: '1440', label: '1 day before' }
+ ];
+
+ // floating shows the entered time as is in the recipient calendar, UTC pins it to
+ // an absolute instant, a named zone carries the wall time plus a VTIMEZONE block.
+ const timezoneOptions = [
+ { value: 'floating', label: 'Recipient local time' },
+ { value: 'UTC', label: 'UTC' },
+ { value: 'America/Los_Angeles', label: 'Los Angeles (Pacific)' },
+ { value: 'America/Chicago', label: 'Chicago (Central)' },
+ { value: 'America/New_York', label: 'New York (Eastern)' },
+ { value: 'Europe/London', label: 'London' },
+ { value: 'Europe/Paris', label: 'Paris / Berlin / Madrid' },
+ { value: 'Europe/Athens', label: 'Athens / Helsinki' },
+ { value: 'Asia/Dubai', label: 'Dubai' },
+ { value: 'Asia/Kolkata', label: 'India' },
+ { value: 'Asia/Singapore', label: 'Singapore' },
+ { value: 'Asia/Tokyo', label: 'Tokyo' },
+ { value: 'Australia/Sydney', label: 'Sydney' }
+ ];
+
+ const pad = (n) => n.toString().padStart(2, '0');
+
+ function newICSUID() {
+ const random =
+ typeof crypto !== 'undefined' && crypto.randomUUID
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2) + Date.now().toString(36);
+ icsUID = `${random}@phishing.club`;
+ }
+
+ // utc timestamp YYYYMMDDTHHMMSSZ, used for DTSTAMP which is always absolute
+ function toICSStamp(date) {
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ }
+
+ // the entered date and time are read as wall clock, so format from the utc
+ // fields of a date built with a trailing Z. this keeps the browser timezone
+ // from shifting the value the user typed.
+ function toWallStamp(date) {
+ return (
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
+ `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}00`
+ );
+ }
+
+ // offset like +0200 for a named zone at a given instant, DST aware for that date
+ function tzOffsetString(zone, date) {
+ const dtf = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' });
+ const namePart = dtf.formatToParts(date).find((p) => p.type === 'timeZoneName');
+ const match = /GMT([+-])(\d{2}):?(\d{2})?/.exec(namePart ? namePart.value : '');
+ if (!match) {
+ return '+0000';
+ }
+ return `${match[1]}${match[2]}${match[3] || '00'}`;
+ }
+
+ // escape per RFC 5545: backslash, semicolon, comma and newlines carry meaning
+ function escapeICSText(text) {
+ return text
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r?\n/g, '\\n');
+ }
+
+ // fold a content line at 75 octets with a CRLF followed by a single space,
+ // as required by RFC 5545. exchange is strict about this.
+ function foldICSLine(line) {
+ const limit = 73;
+ if (line.length <= limit) {
+ return line;
+ }
+ const parts = [line.slice(0, limit)];
+ let rest = line.slice(limit);
+ while (rest.length > limit - 1) {
+ parts.push(' ' + rest.slice(0, limit - 1));
+ rest = rest.slice(limit - 1);
+ }
+ parts.push(' ' + rest);
+ return parts.join('\r\n');
+ }
+
+ function buildICS() {
+ if (!icsUID) {
+ return;
+ }
+ // build with a trailing Z so getUTC* returns exactly the wall clock the user typed
+ const base = icsDate && icsTime ? new Date(`${icsDate}T${icsTime}:00Z`) : null;
+ if (!base || isNaN(base.getTime())) {
+ icsResult = '';
+ return;
+ }
+ const minutes = parseInt(icsDuration, 10) || 30;
+ const end = new Date(base.getTime() + minutes * 60000);
+
+ // resolve DTSTART, DTEND and any timezone definition based on the selected mode
+ let dtStart;
+ let dtEnd;
+ let vtimezone = [];
+ if (icsTimezone === 'floating') {
+ dtStart = `DTSTART:${toWallStamp(base)}`;
+ dtEnd = `DTEND:${toWallStamp(end)}`;
+ } else if (icsTimezone === 'UTC') {
+ dtStart = `DTSTART:${toWallStamp(base)}Z`;
+ dtEnd = `DTEND:${toWallStamp(end)}Z`;
+ } else {
+ const offset = tzOffsetString(icsTimezone, base);
+ dtStart = `DTSTART;TZID=${icsTimezone}:${toWallStamp(base)}`;
+ dtEnd = `DTEND;TZID=${icsTimezone}:${toWallStamp(end)}`;
+ vtimezone = [
+ 'BEGIN:VTIMEZONE',
+ `TZID:${icsTimezone}`,
+ 'BEGIN:STANDARD',
+ 'DTSTART:19700101T000000',
+ `TZOFFSETFROM:${offset}`,
+ `TZOFFSETTO:${offset}`,
+ `TZNAME:${icsTimezone}`,
+ 'END:STANDARD',
+ 'END:VTIMEZONE'
+ ];
+ }
+
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Phishing Club//Calendar Invitation//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:REQUEST',
+ ...vtimezone,
+ 'BEGIN:VEVENT',
+ `UID:${icsUID}`,
+ `DTSTAMP:${toICSStamp(new Date())}`,
+ dtStart,
+ dtEnd,
+ 'SEQUENCE:0',
+ 'STATUS:CONFIRMED',
+ `SUMMARY:${escapeICSText(icsSummary)}`
+ ];
+ if (icsDescription.trim()) {
+ lines.push(`DESCRIPTION:${escapeICSText(icsDescription)}`);
+ }
+ if (icsLocation.trim()) {
+ lines.push(`LOCATION:${escapeICSText(icsLocation)}`);
+ }
+ if (icsOrganizerEmail.trim()) {
+ // CN is a parameter value, so it is double quoted rather than text escaped
+ const name = icsOrganizerName.trim();
+ const cn = name ? `;CN="${name.replace(/"/g, '')}"` : '';
+ lines.push(`ORGANIZER${cn}:mailto:${icsOrganizerEmail.trim()}`);
+ }
+ if (icsAttendee.trim()) {
+ lines.push(
+ `ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${icsAttendee.trim()}`
+ );
+ }
+ if (icsAddReminder) {
+ const reminderMinutes = parseInt(icsReminder, 10) || 15;
+ lines.push(
+ 'BEGIN:VALARM',
+ 'ACTION:DISPLAY',
+ 'DESCRIPTION:Reminder',
+ `TRIGGER:-PT${reminderMinutes}M`,
+ 'END:VALARM'
+ );
+ }
+ lines.push('END:VEVENT', 'END:VCALENDAR');
+
+ icsResult = lines.map(foldICSLine).join('\r\n') + '\r\n';
+ }
+
+ function copyICS() {
+ if (!icsResult) {
+ return;
+ }
+ navigator.clipboard.writeText(icsResult);
+ addToast('Copied calendar invitation to clipboard', 'Success');
+ }
+
+ function downloadICS() {
+ if (!icsResult) {
+ return;
+ }
+ const blob = new Blob([icsResult], { type: 'text/calendar;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'invitation.ics';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ // rebuild whenever any field changes
+ $: icsSummary,
+ icsOrganizerName,
+ icsOrganizerEmail,
+ icsAttendee,
+ icsLocation,
+ icsDescription,
+ icsDate,
+ icsTime,
+ icsDuration,
+ icsTimezone,
+ icsAddReminder,
+ icsReminder,
+ icsUID,
+ buildICS();
+
+ onMount(() => {
+ newICSUID();
+ // default to tomorrow so the invite is always in the future
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ icsDate = `${tomorrow.getFullYear()}-${(tomorrow.getMonth() + 1)
+ .toString()
+ .padStart(2, '0')}-${tomorrow.getDate().toString().padStart(2, '0')}`;
+ buildICS();
+ });
+ Builds an Outlook ready meeting request (.ics). Save it as an email attachment with + embedded content enabled so template variables resolve per recipient. +
+ +Preview (.ics)
+ +{icsResult}
+ + Pick a start date and time to generate the invitation. +
+ {/if} +