Feature: Calendars are now exportable shareable so Third Party Calendar services can read them, also added recoccurence to calendar, and all day along with sperating the time fields and click in a box to add calendar event is possible

This commit is contained in:
johnnyq
2026-07-30 02:18:30 -04:00
parent 3c8f812a16
commit f2d5ac4a29
15 changed files with 1233 additions and 62 deletions

View File

@@ -80,6 +80,51 @@ This file documents all notable changes made to ITFlow.
- Added an **Urgent** ticket priority.
- **Multiple notes per asset**, mirroring the existing contact notes, with categorized note types
(Maintenance, Repair, Configuration, Upgrade, Inspection, Note).
- Fixed the last day of a multi-day all-day event not being drawn on the calendar or published
to subscribed feeds. `event_end` holds the last day the event covers, which is what the event
modal asks for, but both FullCalendar and iCalendar treat an all-day end as exclusive.
- Selecting a day in month view creates an all-day event; selecting a time range in a week or
day view fills the time fields in and clears All day. Unchecking All day after a month-view
click reveals the time fields with the dates left in place, so a timed event can still be
created from month view. The end date follows the start date, and the end time defaults to an
hour after the start, in both cases leaving a longer span alone if one is already set.
- **Clicking empty space on the calendar creates an event there.** Clicking a day or a time
slot - or dragging across several - opens the New Event modal with the start and end already
filled in from the selection, and the All day switch set to match. Dragging out a range in a
week or day view carries the length of the selection through, and the automatic
end-time-follows-start behaviour no longer overwrites a range that was dragged out or
lengthened by hand.
- Repeating events are now marked on the calendar with a repeat icon and a hover note saying
how often they recur, and the edit modal states that saving or deleting affects every
occurrence. The delete action on a repeating event reads **Delete series** and now asks for
confirmation first — as does every other `confirm-link` inside an ajax modal, which
previously did nothing because the handler was bound only to links present at page load.
- **Repeating calendar events now work.** The Repeat field on the add and edit event modals
was present but disabled, and the stored value was never rendered. It is now selectable
(daily, weekly, monthly, yearly) and occurrences are drawn on the calendar. Monthly and
yearly series skip dates that do not exist in a given period rather than sliding into the
next month, so an event on the 31st appears only in months that have one, and a 29 February
event only in leap years. Recurrence is series-wide: editing any occurrence edits the whole
series, and individual occurrences cannot yet be moved or cancelled. Subscribed calendar
feeds publish the recurrence rule and let the subscribing client expand it.
- **Calendar events can be marked all day, and the date and time are now separate fields.**
Previously `calendar_events` had no all-day column and the calendar inferred it from a start
time of midnight, which made a genuine midnight appointment indistinguishable from an all-day
event. The add and edit event modals now carry an **All day** switch above four fields - date
from, date to, time from, time to - and the two time fields are hidden while All day is on.
All day is selected by default on a new event. Existing events are backfilled by the database
update using the old rule, so nothing in an existing calendar changes appearance.
- **Calendars can be published as a read-only subscription feed.** Any calendar can be shared as
an iCalendar (ICS) link from its menu on the Calendar page and subscribed to in Google Calendar,
Nextcloud, Apple Calendar, Thunderbird, or anything else that accepts a feed URL. The link
carries a secret key and requires no login, so it can be added to a phone or handed to a
colleague without an ITFlow account. It can be regenerated or revoked at any time, and a shared
calendar is marked with an icon in the calendar list. A **busy only** option publishes time
blocks without titles, descriptions, or locations. Feeds are read-only: events added or edited
in the subscribing client never reach ITFlow. Refresh timing belongs to the client — Google
refreshes on its own schedule, often 12-24 hours, and cannot be forced, while Nextcloud defaults
to once a week unless `calendarSubscriptionRefreshRate` is lowered, and will refuse a feed URL
that resolves to a private IP address.
- **Ticket templates on recurring tickets.** A recurring ticket can now be assigned a ticket
template. Picking one fills in the subject and details, and the template's task list is stamped
onto every ticket the schedule raises - from the nightly cron run and from a forced run alike.

View File

@@ -0,0 +1,27 @@
<?php
/*
* ITFlow - Database update to version 2.5.8 (from 2.5.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Calendars can now be published as a read-only ICS feed for Google Calendar,
// Nextcloud and any other subscription client. The key is stored in cleartext,
// matching invoice_url_key and shared_items.item_key - hashing it would buy
// nothing here (anyone holding the database already holds the events the key
// grants access to) and would make the URL impossible to re-copy for a second
// device without breaking every existing subscriber.
//
// The UNIQUE key is on a nullable column, so unshared calendars all keep NULL.
// The column is explicitly utf8mb4_bin: the table default is utf8mb4_general_ci,
// which compares case-insensitively, and the key alphabet is mixed-case
// base64url - a _ci column would throw away entropy on lookup and make the
// UNIQUE index blind to case.
mysqli_query($mysqli, "ALTER TABLE `calendars`
ADD COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL AFTER `calendar_color`,
ADD COLUMN `calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0 AFTER `calendar_feed_key`,
ADD COLUMN `calendar_feed_created_at` datetime DEFAULT NULL AFTER `calendar_feed_busy_only`,
ADD COLUMN `calendar_feed_accessed_at` datetime DEFAULT NULL AFTER `calendar_feed_created_at`,
ADD UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`)");

View File

@@ -0,0 +1,28 @@
<?php
/*
* ITFlow - Database update to version 2.5.9 (from 2.5.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Events can now be marked all-day explicitly. Until now calendar_events had
// no all-day column at all - FullCalendar inferred it from a start value with
// no time component, which meant a genuine midnight appointment was
// indistinguishable from an all-day event.
mysqli_query($mysqli, "ALTER TABLE `calendar_events`
ADD COLUMN `event_all_day` tinyint(1) NOT NULL DEFAULT 0 AFTER `event_end`");
// Backfill using the same rule the calendar already rendered by, so existing
// events keep displaying exactly as they do today: a midnight start, and
// either no end or a midnight end.
mysqli_query($mysqli, "UPDATE `calendar_events`
SET `event_all_day` = 1
WHERE TIME(`event_start`) = '00:00:00'
AND (`event_end` IS NULL OR TIME(`event_end`) = '00:00:00')");
// Corrects the collation on installs that already applied 2.5.8 before the
// column was pinned to utf8mb4_bin. Harmless to re-run.
mysqli_query($mysqli, "ALTER TABLE `calendars`
MODIFY COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL");

View File

@@ -43,9 +43,13 @@ if (isset($_GET['calendar_id'])) {
$calendar_id = intval($row['calendar_id']);
$calendar_name = escapeHtml($row['calendar_name']);
$calendar_color = escapeHtml($row['calendar_color']);
$calendar_feed_key = escapeHtml($row['calendar_feed_key'] ?? null);
?>
<div class="form-group d-flex align-items-center">
<i class="fas fa-fw fa-circle mr-2" style="color:<?= $calendar_color ?>;"></i><?= $calendar_name ?>
<?php if (!empty($calendar_feed_key)) { ?>
<i class="fas fa-fw fa-share-alt text-info ml-2" title="Published as a subscription link"></i>
<?php } ?>
<div class="dropdown dropright ml-auto">
<button class="btn btn-tool" type="button" data-toggle="dropdown">
@@ -56,6 +60,12 @@ if (isset($_GET['calendar_id'])) {
data-modal-url="modals/calendar/calendar_edit.php?id=<?= $calendar_id ?>">
<i class="fas fa-fw fa-pencil-alt mr-2"></i>Rename
</a>
<?php if ($session_is_admin) { ?>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/calendar/calendar_share.php?id=<?= $calendar_id ?>">
<i class="fas fa-fw fa-share-alt mr-2"></i><?= empty($calendar_feed_key) ? 'Share' : 'Manage sharing' ?>
</a>
<?php } ?>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_calendar=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
@@ -153,6 +163,19 @@ while ($row = mysqli_fetch_assoc($sql)) {
<script src='/libs/fullcalendar/themes/classic/global.js'></script>
<script>
// Local-time formatters for the date and datetime-local inputs.
// Date.toISOString() would convert to UTC and silently shift the event by the
// browser's offset.
function formatLocalDate(date) {
const pad = (n) => String(n).padStart(2, "0");
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
}
function formatLocalTime(date) {
const pad = (n) => String(n).padStart(2, "0");
return pad(date.getHours()) + ":" + pad(date.getMinutes());
}
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
@@ -163,6 +186,13 @@ while ($row = mysqli_fetch_assoc($sql)) {
text: 'New Event',
iconClass: 'fas fa-plus',
click: function() {
// Reset to the all-day default; without this the modal keeps
// whatever state the last calendar selection left it in
const allDayToggle = document.getElementById("event_add_all_day");
if (allDayToggle) {
allDayToggle.checked = true;
$(allDayToggle).trigger("change");
}
$("#addCalendarEventModal").modal();
}
}
@@ -185,6 +215,65 @@ while ($row = mysqli_fetch_assoc($sql)) {
eventDidMount: function(info) {
// Always show full title when hovering
info.el.setAttribute('title', info.event.title);
// Mark occurrences of a repeating event, so a series is recognisable
// without opening it. Every occurrence carries the parent event id, so
// this is the only cue that a click will edit the whole series.
const repeat = info.event.extendedProps.repeat;
if (repeat) {
info.el.setAttribute('title', info.event.title + ' (repeats every ' + repeat.toLowerCase() + ')');
const titleEl = info.el.querySelector('.fc-event-title') || info.el.querySelector('.fc-list-event-title');
if (titleEl) {
const icon = document.createElement('i');
icon.className = 'fas fa-redo fa-xs mr-1';
titleEl.prepend(icon);
}
}
},
// Clicking - or dragging across - empty calendar space opens the New Event
// modal prefilled with whatever was selected. Month view yields an all-day
// range; the time grid views yield a slot range.
select: function(selectionInfo) {
const allDayToggle = document.getElementById("event_add_all_day");
const startDate = document.getElementById("event_add_start_date");
const endDate = document.getElementById("event_add_end_date");
const startTime = document.getElementById("event_add_start_time");
const endTime = document.getElementById("event_add_end_time");
if (!startDate || !endDate) {
return;
}
// FullCalendar reports an exclusive end for an all-day range, but the form
// asks for the last day the event covers - step back a day so a single-day
// click does not come back as a two-day event. A time-grid selection is not
// exclusive in the same way, so its end date is used as-is (a slot running
// to midnight legitimately ends on the next day).
const lastDay = new Date(selectionInfo.end.getTime());
if (selectionInfo.allDay) {
lastDay.setDate(lastDay.getDate() - 1);
}
startDate.value = formatLocalDate(selectionInfo.start);
endDate.value = formatLocalDate(lastDay);
// A time-grid selection carries a real time; a month-view click does not,
// so its time fields are left for the user to fill in if they uncheck
if (!selectionInfo.allDay && startTime && endTime) {
startTime.value = formatLocalTime(selectionInfo.start);
endTime.value = formatLocalTime(selectionInfo.end);
}
// Last, so the handler in app.js shows or hides the time row to match
if (allDayToggle) {
allDayToggle.checked = selectionInfo.allDay;
$(allDayToggle).trigger("change");
}
calendar.unselect();
$("#addCalendarEventModal").modal();
},
eventClick: function(editEvent) {
var eventId = editEvent.event.id;
@@ -215,16 +304,40 @@ while ($row = mysqli_fetch_assoc($sql)) {
events: [
<?php
$sql = mysqli_query($mysqli, "SELECT * FROM calendar_events LEFT JOIN calendars ON event_calendar_id = calendar_id $client_event_query");
// Repeating events are stored as a single row, so the occurrences have to
// be materialised here - the bundled FullCalendar build has no rrule
// plugin. Every occurrence keeps the parent event_id, so clicking any of
// them opens the series in the edit modal.
$recur_window_start = date('Y-m-d H:i:s', strtotime('-6 months'));
$recur_window_end = date('Y-m-d H:i:s', strtotime('+18 months'));
while ($row = mysqli_fetch_assoc($sql)) {
$event_id = intval($row['event_id']);
$event_title = json_encode($row['event_title']);
$event_start = json_encode($row['event_start']);
$event_end = json_encode($row['event_end']);
$calendar_id = intval($row['calendar_id']);
$calendar_name = json_encode($row['calendar_name']);
$calendar_color = json_encode($row['calendar_color']);
$event_is_all_day = !empty($row['event_all_day'] ?? 0);
$event_all_day = $event_is_all_day ? 'true' : 'false';
$event_repeat = json_encode($row['event_repeat'] ?? '');
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, color: $calendar_color },";
foreach (expandRecurringEvent($row, $recur_window_start, $recur_window_end) as $occurrence) {
$occurrence_end = $occurrence['end'];
// event_end holds the last day an all-day event covers, but
// FullCalendar's all-day end is exclusive - without this the final
// day of a multi-day event is not drawn
if ($event_is_all_day && !empty($occurrence_end)) {
$occurrence_end = date('Y-m-d H:i:s', strtotime($occurrence_end . ' +1 day'));
}
$event_start = json_encode($occurrence['start']);
$event_end = json_encode($occurrence_end);
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, allDay: $event_all_day, color: $calendar_color, extendedProps: { repeat: $event_repeat } },";
}
}
// Invoices Created
@@ -384,31 +497,3 @@ while ($row = mysqli_fetch_assoc($sql)) {
calendar.render();
});
</script>
<!-- Automatically set new event end date to 1 hr after start date -->
<script>
// Function - called when user leaves field (onblur)
function updateIncrementEndTime() {
// Get the start date
let start = document.getElementById("event_add_start").value;
// Create a date object
let new_end = new Date(start);
// Get the time zone offset in minutes, convert it to milliseconds
let offsetInMilliseconds = new_end.getTimezoneOffset() * 60 * 1000;
// Adjust the date by the time zone offset before adding an hour
new_end = new Date(new_end.getTime() - offsetInMilliseconds);
// Set the end date to 1 hr in the future
new_end.setHours(new_end.getHours() + 1);
// Get the date back as a string, with the milliseconds trimmed off
new_end = new_end.toISOString().replace(/.\d+Z$/g, "");
// Update the end date field
document.getElementById("event_add_end").value = new_end;
}
</script>

View File

@@ -62,22 +62,56 @@
</div>
</div>
<div class="form-group">
<label>Start / End <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
</div>
<input type="datetime-local" class="form-control" id="event_add_start" name="start" required onblur="updateIncrementEndTime()">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input event-all-day-toggle" id="event_add_all_day" name="all_day" value="1" checked>
<label class="custom-control-label" for="event_add_all_day">All day</label>
</div>
</div>
<div class="form-group">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
<div class="form-row">
<div class="form-group col-md-6">
<label>Date from <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
</div>
<input type="date" class="form-control event-start-date" id="event_add_start_date" name="start_date" required>
</div>
</div>
<div class="form-group col-md-6">
<label>Date to <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
</div>
<input type="date" class="form-control" id="event_add_end_date" name="end_date" required>
</div>
</div>
</div>
<!-- Hidden while All day is on. The toggle handler in app.js also
adds and removes required, because a hidden required field
blocks submission with an unfocusable-element error. -->
<div class="form-row d-none" id="event_add_time_fields">
<div class="form-group col-md-6">
<label>Time from <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
</div>
<input type="time" class="form-control event-start-time" id="event_add_start_time" name="start_time">
</div>
</div>
<div class="form-group col-md-6">
<label>Time to <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
</div>
<input type="time" class="form-control" id="event_add_end_time" name="end_time">
</div>
<input type="datetime-local" class="form-control" id="event_add_end" name="end" required>
</div>
</div>
@@ -87,7 +121,7 @@
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-recycle"></i></span>
</div>
<select class="form-control select2" name="repeat" disabled>
<select class="form-control select2" name="repeat">
<option value="">Never</option>
<option>Day</option>
<option>Week</option>

View File

@@ -13,6 +13,18 @@ $event_location = escapeHtml($row['event_location']);
$event_start = escapeHtml($row['event_start']);
$event_end = escapeHtml($row['event_end']);
$event_repeat = escapeHtml($row['event_repeat']);
$event_all_day = intval($row['event_all_day'] ?? 0);
// Split the stored datetimes into the four fields the form now uses. An empty
// event_end previously fed strtotime('') and rendered as 1970 - fall back to the
// start instead.
$event_start_ts = strtotime($event_start) ?: time();
$event_end_ts = !empty($row['event_end']) ? (strtotime($event_end) ?: $event_start_ts) : $event_start_ts;
$event_start_date = date('Y-m-d', $event_start_ts);
$event_start_time = date('H:i', $event_start_ts);
$event_end_date = date('Y-m-d', $event_end_ts);
$event_end_time = date('H:i', $event_end_ts);
$calendar_id = intval($row['calendar_id']);
$calendar_name = escapeHtml($row['calendar_name']);
$calendar_color = escapeHtml($row['calendar_color']);
@@ -89,22 +101,61 @@ ob_start();
</div>
</div>
<?php if (!empty($event_repeat)) { ?>
<div class="alert alert-info">
<i class="fas fa-fw fa-redo mr-2"></i>
This event repeats <strong>every <?= strtolower($event_repeat) ?></strong>.
Saving or deleting affects <strong>every occurrence</strong> &mdash; a single
occurrence cannot be moved or cancelled on its own.
</div>
<?php } ?>
<div class="form-group">
<label>Start / End <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
</div>
<input type="datetime-local" class="form-control" name="start" value="<?= date('Y-m-d\TH:i:s', strtotime($event_start)) ?>" required>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input event-all-day-toggle" id="event_edit_all_day" name="all_day" value="1" <?php if ($event_all_day) { echo "checked"; } ?>>
<label class="custom-control-label" for="event_edit_all_day">All day</label>
</div>
</div>
<div class="form-group">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
<div class="form-row">
<div class="form-group col-md-6">
<label>Date from <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
</div>
<input type="date" class="form-control event-start-date" id="event_edit_start_date" name="start_date" value="<?= $event_start_date ?>" required>
</div>
</div>
<div class="form-group col-md-6">
<label>Date to <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
</div>
<input type="date" class="form-control" id="event_edit_end_date" name="end_date" value="<?= $event_end_date ?>" required>
</div>
</div>
</div>
<div class="form-row<?= $event_all_day ? ' d-none' : '' ?>" id="event_edit_time_fields">
<div class="form-group col-md-6">
<label>Time from <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
</div>
<input type="time" class="form-control event-start-time" id="event_edit_start_time" name="start_time" value="<?= $event_start_time ?>"<?= $event_all_day ? '' : ' required' ?>>
</div>
</div>
<div class="form-group col-md-6">
<label>Time to <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
</div>
<input type="time" class="form-control" id="event_edit_end_time" name="end_time" value="<?= $event_end_time ?>"<?= $event_all_day ? '' : ' required' ?>>
</div>
<input type="datetime-local" class="form-control" name="end" value="<?= date('Y-m-d\TH:i:s', strtotime($event_end)) ?>"required>
</div>
</div>
@@ -114,7 +165,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-recycle"></i></span>
</div>
<select class="form-control select2" name="repeat" disabled>
<select class="form-control select2" name="repeat">
<option <?php if (empty($event_repeat)) { echo "selected"; } ?> value="">Never</option>
<option <?php if ($event_repeat == "Day") { echo "selected"; } ?>>Day</option>
<option <?php if ($event_repeat == "Week") { echo "selected"; } ?>>Week</option>
@@ -190,7 +241,7 @@ ob_start();
</div>
<div class="modal-footer">
<a class="btn btn-default text-danger mr-auto" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times mr-2"></i>Delete</a>
<a class="btn btn-default text-danger mr-auto confirm-link" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times mr-2"></i><?= empty($event_repeat) ? 'Delete' : 'Delete series' ?></a>
<button type="submit" name="edit_event" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Save</button>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fa fa-times mr-2"></i>Cancel</button>
</div>

View File

@@ -0,0 +1,189 @@
<?php
require_once '../../../includes/modal_header.php';
$calendar_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT * FROM calendars WHERE calendar_id = $calendar_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$calendar_name = escapeHtml($row['calendar_name']);
$calendar_color = escapeHtml($row['calendar_color']);
$calendar_feed_key = escapeHtml($row['calendar_feed_key']);
$calendar_feed_busy_only = intval($row['calendar_feed_busy_only']);
$calendar_feed_created_at = escapeHtml($row['calendar_feed_created_at']);
$calendar_feed_accessed_at = escapeHtml($row['calendar_feed_accessed_at']);
// $config_base_url lives in config.php (written at setup from HTTP_HOST, or from
// the --base_url argument to setup_cli.php) and is expected to be a bare host.
// Tolerate a scheme having been saved into it rather than emitting https://https://
$base_url = rtrim(preg_replace('#^[a-z]+://#i', '', (string) $config_base_url), '/');
$feed_path = "/guest/guest_calendar_feed.php?key=$calendar_feed_key";
$feed_url_https = "https://$base_url$feed_path";
$feed_url_webcal = "webcal://$base_url$feed_path";
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-share-alt mr-2"></i>Share <?= $calendar_name ?></h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<?php if (empty($base_url)) { ?>
<div class="modal-body">
<div class="alert alert-danger mb-0">
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
<strong>$config_base_url is not set in config.php.</strong>
Without it there is no hostname to build a subscription link from. Set it
to the hostname this install is reached on and reopen this dialog.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">
<i class="fas fa-fw fa-times mr-2"></i>Close
</button>
</div>
<?php } elseif (empty($calendar_feed_key)) { ?>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="calendar_id" value="<?= $calendar_id ?>">
<div class="modal-body">
<p>
Publishing <strong><?= $calendar_name ?></strong> creates a secret link that
Google Calendar, Nextcloud, Apple Calendar or Thunderbird can subscribe to.
</p>
<div class="alert alert-warning">
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
Anyone with the link can read this calendar without logging in. Share it
like a password, and revoke it here if it gets out.
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="feedBusyOnly" name="busy_only" value="1">
<label class="custom-control-label" for="feedBusyOnly">
Busy only &mdash; publish time blocks without titles, descriptions or locations
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="share_calendar" class="btn btn-primary">
<i class="fas fa-fw fa-link mr-2"></i>Create link
</button>
<button type="button" class="btn btn-light" data-dismiss="modal">
<i class="fas fa-fw fa-times mr-2"></i>Cancel
</button>
</div>
</form>
<?php } else { ?>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="calendar_id" value="<?= $calendar_id ?>">
<div class="modal-body">
<div class="form-group">
<label>Subscription link</label>
<div class="input-group">
<input type="text" class="form-control" value="<?= $feed_url_https ?>" readonly onclick="this.select();">
<div class="input-group-append">
<button class="btn btn-secondary clipboardjs" type="button" data-clipboard-text="<?= $feed_url_https ?>" title="Copy link">
<i class="far fa-fw fa-copy"></i>
</button>
</div>
</div>
<small class="form-text text-muted">
Paste this into <strong>Google Calendar</strong> &rarr; Other calendars &rarr; From URL,
or <strong>Nextcloud Calendar</strong> &rarr; New calendar &rarr; New subscription.
</small>
</div>
<div class="form-group">
<label>Or open directly in a desktop calendar app</label>
<div class="input-group">
<input type="text" class="form-control" value="<?= $feed_url_webcal ?>" readonly onclick="this.select();">
<div class="input-group-append">
<button class="btn btn-secondary clipboardjs" type="button" data-clipboard-text="<?= $feed_url_webcal ?>" title="Copy link">
<i class="far fa-fw fa-copy"></i>
</button>
<a class="btn btn-secondary" href="<?= $feed_url_webcal ?>" title="Open">
<i class="fas fa-fw fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
<hr>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="feedBusyOnly" name="busy_only" value="1" <?php if ($calendar_feed_busy_only == 1) { echo "checked"; } ?>>
<label class="custom-control-label" for="feedBusyOnly">
Busy only &mdash; publish time blocks without titles, descriptions or locations
</label>
</div>
</div>
<dl class="row mb-0 text-muted">
<dt class="col-5">Link created</dt>
<dd class="col-7"><?= $calendar_feed_created_at ?: 'Unknown' ?></dd>
<dt class="col-5">Last fetched</dt>
<dd class="col-7"><?= $calendar_feed_accessed_at ?: 'Never' ?></dd>
</dl>
<div class="alert alert-secondary mt-3 mb-0">
<i class="fas fa-fw fa-info-circle mr-2"></i>
Subscriptions are read-only, and clients decide how often to refresh.
Google refreshes on its own schedule (often 12&ndash;24 hours) and cannot be
forced. Nextcloud defaults to once a week unless
<code>calendarSubscriptionRefreshRate</code> is lowered, and refuses
subscriptions pointing at a private IP address.
</div>
</div>
<div class="modal-footer justify-content-between">
<div>
<button type="submit" name="share_calendar" class="btn btn-primary">
<i class="fas fa-fw fa-check mr-2"></i>Save
</button>
<button type="button" class="btn btn-light" data-dismiss="modal">
<i class="fas fa-fw fa-times mr-2"></i>Close
</button>
</div>
<div class="dropdown dropup">
<button class="btn btn-light" type="button" data-toggle="dropdown">
<i class="fas fa-fw fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item confirm-link" href="post.php?regenerate_calendar_feed=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-sync mr-2"></i>Regenerate link
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?unshare_calendar=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-unlink mr-2"></i>Stop sharing
</a>
</div>
</div>
</div>
</form>
<?php } ?>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -68,6 +68,103 @@ if (isset($_GET['delete_calendar'])) {
}
if (isset($_POST['share_calendar'])) {
validateCSRFToken();
// Publishing a calendar mints an unauthenticated public URL, so keep it to
// admins. This is a deliberately narrow check using $session_is_admin rather
// than the module permission system - calendars are not owned by a module.
enforceAdminPermission();
$calendar_id = intval($_POST['calendar_id']);
$busy_only = isset($_POST['busy_only']) ? 1 : 0;
$calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
$existing_key = getFieldById('calendars', $calendar_id, 'calendar_feed_key');
if (empty($existing_key)) {
// 32 URL-safe base64 chars (~192 bits). Stored in cleartext, like
// invoice_url_key, so the link stays re-copyable for a second device.
$feed_key = escapeSql(randomString(32));
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = '$feed_key',
calendar_feed_busy_only = $busy_only,
calendar_feed_created_at = NOW()
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name published calendar $calendar_name as a read-only feed", 0, $calendar_id);
flashAlert("Calendar <strong>$calendar_name</strong> published - copy the subscription link from the share dialog");
} else {
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_busy_only = $busy_only
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Edit", "$session_name updated feed settings for calendar $calendar_name", 0, $calendar_id);
flashAlert("Feed settings for <strong>$calendar_name</strong> updated");
}
redirect();
}
if (isset($_GET['regenerate_calendar_feed'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_GET['regenerate_calendar_feed']);
$calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
$feed_key = escapeSql(randomString(32));
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = '$feed_key',
calendar_feed_created_at = NOW(),
calendar_feed_accessed_at = NULL
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name regenerated the feed link for calendar $calendar_name", 0, $calendar_id);
flashAlert("Feed link for <strong>$calendar_name</strong> regenerated - existing subscribers will stop updating and must re-subscribe", 'error');
redirect();
}
if (isset($_GET['unshare_calendar'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_GET['unshare_calendar']);
$calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = NULL,
calendar_feed_busy_only = 0,
calendar_feed_created_at = NULL,
calendar_feed_accessed_at = NULL
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name stopped sharing calendar $calendar_name", 0, $calendar_id);
flashAlert("Calendar <strong>$calendar_name</strong> is no longer shared", 'error');
redirect();
}
if (isset($_POST['add_event'])) {
validateCSRFToken();
@@ -79,7 +176,7 @@ if (isset($_POST['add_event'])) {
enforceClientAccess();
}
mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id");
mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id");
$event_id = mysqli_insert_id($mysqli);
@@ -158,7 +255,7 @@ if (isset($_POST['edit_event'])) {
$event_id = intval($_POST['event_id']);
mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id");
mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id");
//If email is checked
if ($email_event == 1) {

View File

@@ -5,8 +5,37 @@ $calendar_id = intval($_POST['calendar']);
$title = escapeSql($_POST['title']);
$location = escapeSql($_POST['location']);
$description = escapeSql($_POST['description']);
$start = escapeSql($_POST['start']);
$end = escapeSql($_POST['end']);
$all_day = isset($_POST['all_day']) ? 1 : 0;
/*
* The form posts the date and the time as separate fields, so recombine them into
* the DATETIME columns.
*
* All-day events are pinned to midnight at both ends, and event_end holds the LAST
* DAY the event covers - the same thing the form asks for. Both FullCalendar and
* iCalendar treat an all-day end as exclusive, so the render and feed paths add a
* day; do not add one here as well.
*/
$start_date = $_POST['start_date'] ?? '';
$end_date = !empty($_POST['end_date']) ? $_POST['end_date'] : $start_date;
if ($all_day) {
$start_raw = "$start_date 00:00:00";
$end_raw = "$end_date 00:00:00";
} else {
$start_time = !empty($_POST['start_time']) ? $_POST['start_time'] : '00:00';
$end_time = !empty($_POST['end_time']) ? $_POST['end_time'] : $start_time;
$start_raw = "$start_date $start_time";
$end_raw = "$end_date $end_time";
}
// A malformed value would otherwise land in 1970 via strtotime() returning false
$start_ts = strtotime($start_raw) ?: time();
$end_ts = strtotime($end_raw) ?: $start_ts;
$start = escapeSql(date('Y-m-d H:i:s', $start_ts));
$end = escapeSql(date('Y-m-d H:i:s', $end_ts));
$repeat = escapeSql($_POST['repeat'] ?? 0);
$client_id = intval($_POST['client_id']);
$email_event = intval($_POST['email_event'] ?? 0);

8
db.sql
View File

@@ -408,6 +408,7 @@ CREATE TABLE `calendar_events` (
`event_description` longtext DEFAULT NULL,
`event_start` datetime NOT NULL,
`event_end` datetime DEFAULT NULL,
`event_all_day` tinyint(1) NOT NULL DEFAULT 0,
`event_repeat` varchar(200) DEFAULT NULL,
`event_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`event_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
@@ -432,10 +433,15 @@ CREATE TABLE `calendars` (
`calendar_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_name` varchar(200) NOT NULL,
`calendar_color` varchar(200) NOT NULL,
`calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL,
`calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0,
`calendar_feed_created_at` datetime DEFAULT NULL,
`calendar_feed_accessed_at` datetime DEFAULT NULL,
`calendar_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`calendar_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`calendar_archived_at` datetime DEFAULT NULL,
PRIMARY KEY (`calendar_id`)
PRIMARY KEY (`calendar_id`),
UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

View File

@@ -24,3 +24,4 @@ require_once __DIR__ . '/functions/db.php';
require_once __DIR__ . '/functions/payments.php';
require_once __DIR__ . '/functions/sla.php';
require_once __DIR__ . '/functions/export.php';
require_once __DIR__ . '/functions/calendar.php';

371
functions/calendar.php Normal file
View File

@@ -0,0 +1,371 @@
<?php
// iCalendar (RFC 5545) generation for the read-only guest calendar feeds
// Consumed by guest/guest_calendar_feed.php
/*
* Escapes a value for an iCalendar TEXT property (RFC 5545 3.3.11)
* Backslashes are escaped first, otherwise the escapes added below get escaped again
*/
function icsEscapeText($text) {
$text = str_replace("\\", "\\\\", (string) $text);
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
return str_replace([";", ","], ["\\;", "\\,"], $text);
}
/*
* Folds a content line to 75 octets per RFC 5545 3.1
* Continuation lines begin with a single space which counts toward the 75, so
* $buffer already carries it. Folding happens on character boundaries so a
* multi-byte UTF-8 sequence is never cut in half - splitting mid-sequence is
* what makes hand-rolled feeds fail to parse on non-ASCII event titles.
*/
function icsFoldLine($line) {
if (strlen($line) <= 75) {
return $line;
}
$chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY);
// Invalid UTF-8 - fold on byte boundaries rather than emit an over-long line
if ($chars === false) {
return rtrim(chunk_split($line, 74, "\r\n "), "\r\n ");
}
$folded = '';
$buffer = '';
foreach ($chars as $char) {
if (strlen($buffer) + strlen($char) > 75) {
$folded .= $buffer . "\r\n";
$buffer = ' ';
}
$buffer .= $char;
}
return $folded . $buffer;
}
/*
* Converts an ITFlow datetime to an iCalendar UTC DATE-TIME
* Stored datetimes are in the instance's configured timezone. Emitting UTC for
* everything means the feed needs no VTIMEZONE block at all, which removes the
* largest source of breakage in hand-written ICS.
*/
function icsFormatUtc($datetime, $timezone) {
if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
return null;
}
try {
$dt = new DateTime($datetime, new DateTimeZone($timezone));
$dt->setTimezone(new DateTimeZone('UTC'));
} catch (Exception $e) {
return null;
}
return $dt->format('Ymd\THis\Z');
}
/*
* Converts an ITFlow datetime to an iCalendar DATE value (all-day events)
* No timezone conversion - a floating date must stay on the day it was entered
*/
function icsFormatDate($datetime, $offset_days = 0) {
if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
return null;
}
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return null;
}
return date('Ymd', strtotime("$offset_days day", $timestamp));
}
/*
* True when an event looks like an all-day event
* Fallback for rows written before event_all_day existed (added in 2.5.9): a
* midnight start, and either no end or a midnight end - the same rule the
* calendar page itself rendered by, and the rule the 2.5.9 backfill applies.
*/
function icsEventIsAllDay($start, $end) {
if (empty($start) || date('H:i:s', strtotime($start)) !== '00:00:00') {
return false;
}
if (empty($end) || $end === '0000-00-00 00:00:00') {
return true;
}
return date('H:i:s', strtotime($end)) === '00:00:00';
}
/*
* Maps ITFlow's event_repeat wording onto an RRULE
* The repeat select is disabled in both event modals as of 2.5.x, so this only
* applies to rows created before it was disabled or after it is re-enabled
*/
function icsRepeatToRrule($repeat) {
$map = [
'Day' => 'FREQ=DAILY',
'Week' => 'FREQ=WEEKLY',
'Month' => 'FREQ=MONTHLY',
'Year' => 'FREQ=YEARLY'
];
return $map[(string) $repeat] ?? null;
}
/*
* Builds a complete VCALENDAR document for one calendar
*
* $calendar - the calendars row (name, color, feed settings)
* $events - array of calendar_events rows
* $timezone - config_timezone, used to shift stored datetimes to UTC
* $host - config_base_url, used to build stable globally-unique UIDs
*
* Returns the document with CRLF line endings, ready to send.
*/
function buildCalendarFeedIcs(array $calendar, array $events, $timezone, $host) {
$busy_only = !empty($calendar['calendar_feed_busy_only']);
$calendar_name = (string) $calendar['calendar_name'];
$host = $host ?: 'itflow.local';
$now_utc = gmdate('Ymd\THis\Z');
// X-WR-CALNAME is non-standard and clients render its value literally, so a
// TEXT-escaped comma shows up as a visible backslash in the Google Calendar
// sidebar. Strip only what would break the line structure.
$calendar_name_header = str_replace(["\r\n", "\r", "\n"], ' ', $calendar_name);
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ITFlow//ITFlow Calendar Feed//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . $calendar_name_header,
'X-WR-TIMEZONE:' . $timezone,
// Nextcloud reads REFRESH-INTERVAL / X-PUBLISHED-TTL and stores it against
// the subscription - but only during a refresh run, and only if no rate is
// stored yet. The first scheduled run is still gated by its own default
// (P1D), so there is a cold start before this takes effect. Google ignores
// it entirely.
'REFRESH-INTERVAL;VALUE=DURATION:PT15M',
'X-PUBLISHED-TTL:PT15M'
];
if (!empty($calendar['calendar_color'])) {
$lines[] = 'X-APPLE-CALENDAR-COLOR:' . $calendar['calendar_color'];
}
foreach ($events as $event) {
$event_id = intval($event['event_id']);
$start = $event['event_start'];
$end = $event['event_end'];
$lines[] = 'BEGIN:VEVENT';
// Stable across edits and across feed regenerations, so subscribers
// update events in place instead of duplicating them
$lines[] = "UID:itflow-event-$event_id@$host";
// Derived from the event rather than the request clock: a feed whose bytes
// change on every fetch can never produce an ETag hit, which would defeat
// the conditional-GET path in guest_calendar_feed.php
$stamp = icsFormatUtc($event['event_updated_at'] ?: $event['event_created_at'], $timezone);
$lines[] = 'DTSTAMP:' . ($stamp ?: $now_utc);
// event_all_day is authoritative as of database version 2.5.9; the
// heuristic remains as a fallback for a row that predates the backfill
$all_day = isset($event['event_all_day'])
? !empty($event['event_all_day'])
: icsEventIsAllDay($start, $end);
if ($all_day) {
$dtstart = icsFormatDate($start);
// event_end holds the last day the event covers, which is what the event
// modal asks for. DTEND is exclusive for DATE values, so it has to land
// one day past that. A missing or non-advancing end still falls back to a
// single day rather than rendering as zero-length.
$dtend = icsFormatDate($end, 1);
if (empty($dtend) || $dtend <= $dtstart) {
$dtend = icsFormatDate($start, 1);
}
$lines[] = "DTSTART;VALUE=DATE:$dtstart";
$lines[] = "DTEND;VALUE=DATE:$dtend";
} else {
$dtstart = icsFormatUtc($start, $timezone);
// The event modals default a blank end to start + 1 hour client-side;
// match that rather than emitting an instantaneous event
$dtend = icsFormatUtc($end, $timezone);
if (empty($dtend) || $dtend <= $dtstart) {
$dtend = icsFormatUtc(date('Y-m-d H:i:s', strtotime("$start +1 hour")), $timezone);
}
$lines[] = "DTSTART:$dtstart";
$lines[] = "DTEND:$dtend";
}
$rrule = icsRepeatToRrule($event['event_repeat'] ?? '');
if ($rrule) {
$lines[] = "RRULE:$rrule";
}
if ($busy_only) {
// Titles, descriptions and locations all withheld - the feed shows
// only that the time is occupied
$lines[] = 'SUMMARY:Busy';
$lines[] = 'TRANSP:OPAQUE';
} else {
$lines[] = 'SUMMARY:' . icsEscapeText($event['event_title']);
if (!empty($event['event_location'])) {
$lines[] = 'LOCATION:' . icsEscapeText($event['event_location']);
}
if (!empty($event['event_description'])) {
$lines[] = 'DESCRIPTION:' . icsEscapeText($event['event_description']);
}
}
if (!empty($event['event_created_at'])) {
$created = icsFormatUtc($event['event_created_at'], $timezone);
if ($created) {
$lines[] = "CREATED:$created";
}
}
// Lets well-behaved clients spot changed events without a SEQUENCE counter,
// which calendar_events has nowhere to store
if ($stamp) {
$lines[] = "LAST-MODIFIED:$stamp";
}
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
$output = '';
foreach ($lines as $line) {
$output .= icsFoldLine($line) . "\r\n";
}
return $output;
}
/*
* Expands a repeating event into concrete occurrences within a window
*
* ITFlow stores recurrence as a single row with event_repeat set to Day, Week,
* Month or Year - there is no interval, count or until, and no per-occurrence
* override. The calendar page needs real instances to render, because the bundled
* FullCalendar build has no rrule plugin (adding one would mean vendoring rrule.js
* as well). The ICS feed does not use this: it emits an RRULE and lets the
* subscribing client do its own expansion.
*
* Offsets are computed from the original start rather than by stepping a running
* date, so a long series cannot drift.
*
* Returns an array of ['start' => ..., 'end' => ...] datetime strings, always
* including the original occurrence.
*/
function expandRecurringEvent(array $event, $window_start, $window_end, $limit = 750) {
$start = $event['event_start'];
$repeat = (string) ($event['event_repeat'] ?? '');
if (empty($start) || !icsRepeatToRrule($repeat)) {
return [['start' => $start, 'end' => $event['event_end']]];
}
try {
$base = new DateTime($start);
$from = new DateTime($window_start);
$to = new DateTime($window_end);
} catch (Exception $e) {
return [['start' => $start, 'end' => $event['event_end']]];
}
// Preserve the original duration on every occurrence
$duration = 0;
if (!empty($event['event_end']) && $event['event_end'] !== '0000-00-00 00:00:00') {
$duration = max(0, strtotime($event['event_end']) - strtotime($start));
}
$step = [
'Day' => 'day',
'Week' => 'week',
'Month' => 'month',
'Year' => 'year'
][$repeat];
$base_day = (int) $base->format('j');
$occurrences = [];
$i = 0;
// Walk forward from the original start; a series that begins after the window
// simply yields nothing until it reaches it
while (count($occurrences) < $limit) {
$candidate = clone $base;
if ($i > 0) {
$candidate->modify("+$i $step");
// Monthly on the 31st, or yearly on Feb 29: PHP rolls the overflow into
// the next month, which is not what a recurrence means. RFC 5545 skips
// those occurrences, so skip them here too rather than silently moving
// the event to the 1st or the 3rd.
if (($step === 'month' || $step === 'year') && (int) $candidate->format('j') !== $base_day) {
$i++;
if ($candidate > $to) {
break;
}
continue;
}
}
if ($candidate > $to) {
break;
}
if ($candidate >= $from) {
$occurrence_end = null;
if ($duration > 0) {
$end_dt = clone $candidate;
$end_dt->modify("+$duration second");
$occurrence_end = $end_dt->format('Y-m-d H:i:s');
}
$occurrences[] = [
'start' => $candidate->format('Y-m-d H:i:s'),
'end' => $occurrence_end
];
}
$i++;
}
return $occurrences;
}

View File

@@ -0,0 +1,114 @@
<?php
/*
* guest_calendar_feed.php
* Read-only iCalendar (ICS) feed for a single ITFlow calendar
*
* Unauthenticated by design - Google Calendar and Nextcloud fetch subscription
* URLs with no credentials at all, so possession of calendar_feed_key is the
* only authorisation. The key is 32 URL-safe base64 characters (~192 bits).
*
* Deliberately does NOT include guest/includes/inc_all_guest.php (that emits the
* guest HTML layout) and deliberately does NOT start a session - an unattended
* fetcher hitting this every 15 minutes should not be creating session files.
*/
require_once "../config.php";
require_once "../functions.php";
require_once "../includes/load_global_settings.php";
require_once "../includes/inc_set_timezone.php";
// How much of the calendar to publish. Recurring events are always included
// regardless of window, since their first occurrence can predate it.
define("FEED_MONTHS_PAST", 12);
define("FEED_MONTHS_FUTURE", 24);
// Never let a leaked feed URL end up in a search index
header("X-Robots-Tag: noindex, nofollow");
/*
* Bad or missing key - identical response either way, so the endpoint cannot be
* used to tell a wrong key from a revoked one
*/
function feedNotFound() {
header("HTTP/1.1 404 Not Found");
header("Content-Type: text/plain; charset=utf-8");
echo "Not found.";
exit();
}
if (!isset($_GET['key']) || strlen($_GET['key']) < 16 || strlen($_GET['key']) > 64) {
feedNotFound();
}
$feed_key = escapeSql($_GET['key']);
$sql = mysqli_query(
$mysqli,
"SELECT * FROM calendars
WHERE calendar_feed_key = '$feed_key'
AND calendar_feed_key IS NOT NULL
AND calendar_archived_at IS NULL
LIMIT 1"
);
if (mysqli_num_rows($sql) !== 1) {
feedNotFound();
}
$calendar = mysqli_fetch_assoc($sql);
$calendar_id = intval($calendar['calendar_id']);
// Gather events for this calendar only
$window_start = date('Y-m-d H:i:s', strtotime('-' . FEED_MONTHS_PAST . ' months'));
$window_end = date('Y-m-d H:i:s', strtotime('+' . FEED_MONTHS_FUTURE . ' months'));
$events_sql = mysqli_query(
$mysqli,
"SELECT * FROM calendar_events
WHERE event_calendar_id = $calendar_id
AND event_archived_at IS NULL
AND (
(event_repeat IS NOT NULL AND event_repeat != '')
OR event_start BETWEEN '$window_start' AND '$window_end'
)
ORDER BY event_start ASC"
);
$events = [];
while ($row = mysqli_fetch_assoc($events_sql)) {
$events[] = $row;
}
$ics = buildCalendarFeedIcs($calendar, $events, $config_timezone, $config_base_url);
// Let clients that send If-None-Match skip the transfer entirely
$etag = '"' . md5($ics) . '"';
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
header("HTTP/1.1 304 Not Modified");
header("ETag: $etag");
exit();
}
// Record that something is actually subscribed, without turning a hammered URL
// into one write per request
mysqli_query(
$mysqli,
"UPDATE calendars
SET calendar_feed_accessed_at = NOW()
WHERE calendar_id = $calendar_id
AND (calendar_feed_accessed_at IS NULL OR calendar_feed_accessed_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE))"
);
$filename = preg_replace('/[^A-Za-z0-9_-]/', '_', $calendar['calendar_name']) . '.ics';
header("Content-Type: text/calendar; charset=utf-8");
header("Content-Disposition: inline; filename=\"$filename\"");
// No explicit Content-Length - mod_deflate or nginx gzip would compress the body
// after this point and leave the declared length wrong, which some clients treat
// as a truncated response
header("Cache-Control: private, max-age=300");
header("ETag: $etag");
echo $ics;

View File

@@ -410,3 +410,95 @@ $(document).ready(function() {
// Data Tables
new DataTable('.dataTables');
});
/*
* Calendar event modals - the All day switch shows or hides the time row.
*
* The form uses four separate fields (date from / date to / time from / time to),
* so nothing here rewrites a value or changes an input type. An earlier version
* flipped a single datetime-local input to type="date", which a browser answers by
* silently discarding a value it now considers invalid - a fragile arrangement that
* left the fields empty whenever this handler had not run.
*
* required tracks visibility: a hidden required field blocks form submission with an
* unfocusable-element error that the user cannot act on.
*
* Delegated on document because the edit event modal is injected by ajax. The
* namespaced .off() keeps this to a single handler - modal_footer.php re-loads this
* file on every ajax modal open.
*/
$(document).off('change.itflowAllDay').on('change.itflowAllDay', '.event-all-day-toggle', function () {
const allDay = this.checked;
const timeFields = document.getElementById(this.id.replace(/_all_day$/, '_time_fields'));
if (!timeFields) {
return;
}
timeFields.classList.toggle('d-none', allDay);
timeFields.querySelectorAll('input').forEach(function (field) {
if (allDay) {
field.removeAttribute('required');
} else {
field.setAttribute('required', 'required');
}
});
});
/*
* Keep the end date at or after the start date, without shortening a longer span
* the user has already chosen.
*/
$(document).off('change.itflowEventDate').on('change.itflowEventDate', '.event-start-date', function () {
const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date'));
if (!endField || !this.value) {
return;
}
if (!endField.value || endField.value < this.value) {
endField.value = this.value;
}
});
/*
* Default the end time to an hour after the start, leaving a longer span alone.
* A start late in the evening rolls the end onto the following day rather than
* wrapping round to an end that precedes the start.
*/
$(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-start-time', function () {
const prefix = this.id.replace(/_start_time$/, '');
const endField = document.getElementById(prefix + '_end_time');
if (!endField || !this.value) {
return;
}
if (endField.value && endField.value > this.value) {
return;
}
const parts = this.value.split(':');
const end = new Date(2000, 0, 1, Number(parts[0]), Number(parts[1]));
end.setHours(end.getHours() + 1);
const pad = (n) => String(n).padStart(2, '0');
endField.value = pad(end.getHours()) + ':' + pad(end.getMinutes());
// Crossed midnight - carry the end date forward so the event still ends after
// it starts
if (end.getDate() !== 1) {
const startDate = document.getElementById(prefix + '_start_date');
const endDate = document.getElementById(prefix + '_end_date');
if (startDate && endDate && startDate.value && endDate.value <= startDate.value) {
const next = new Date(startDate.value + 'T00:00:00');
next.setDate(next.getDate() + 1);
endDate.value = next.getFullYear() + '-' + pad(next.getMonth() + 1) + '-' + pad(next.getDate());
}
}
});

View File

@@ -1,5 +1,7 @@
// Delegated on document rather than bound to the links present at page load, so
// that confirm-link also works on markup injected by an ajax modal
$(document).ready(function() {
$("a.confirm-link").click(function(e) {
$(document).off('click.itflowConfirm').on('click.itflowConfirm', 'a.confirm-link', function(e) {
e.preventDefault();
// Save the link reference to use after confirmation