Skip to content
Merged
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
133 changes: 130 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,134 @@
# Changelog

All notable changes to `bookables` will be documented in this file
All notable changes to `bookables` are documented in this file.

## 1.0.0 - 201X-XX-XX
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- initial release
## [Unreleased]

### Security

- Cap requested date ranges to avoid unbounded slot generation (denial-of-service).
`WeeklyScheduleAgenda`, `AgendaSlotter` and `DaySlotter` now take an optional
`maxDays` argument (default `366`, `0` disables) and throw
`Puntodev\Bookables\Exceptions\DateRangeTooLargeException` when exceeded.
- Reject non-positive `duration`/`step` (and negative `timeAfter`/`timeBefore`) in
the slotters with `InvalidArgumentException`, preventing degenerate zero-interval
loops.
- Validate schedule times strictly as a time of day (`HH:MM` or `HH:MM:SS`,
`00:00`–`23:59`), rejecting relative expressions such as `now` or `+1 day`.
- `WeeklySchedule::fromJson()` now throws an `Exception` on malformed or non-object
JSON instead of a `TypeError`.

### Changed

- Support Laravel 13, drop Laravel 12, and update dependencies (#25).

### Documentation

- Add a README usage guide and `AGENTS.md` (#26).

## [4.1.2] - 2026-03-07

- Update dependencies (#24).

## [4.1.1] - 2025-11-22

- Update dependencies (#23).

## [4.1.0] - 2025-11-09

- Support for PHP 8.3 and newer (#22).

## [4.0.1] - 2025-02-16

- All interfaces take and return immutable Carbon dates and periods (#21).

## [4.0.0] - 2025-02-05

- Make the library compatible with PHP 8.3 and later (#20).
- Update libraries (#19).

## [3.1.0] - 2023-03-18

- Upgrade to PHPUnit 10 (#17).

## [3.0.0] - 2022-12-14

- Make the library compatible with PHP 8.2 and later (#16).

## [2.6.0] - 2022-04-11

- Remove the production dependency on `nunomaduro/collision` (#15).

## [2.5.0] - 2021-11-22

- Redesign the agenda slotter (#14).
- Add a day slotter (#13).
- Segregate the slotter/agenda interfaces (#12).

## [2.4.0] - 2021-11-22

- Add the agenda slotter (#11).
- Update libraries (#10).

## [2.3.0] - 2021-06-11

- Add an option to align (or not) minutes to the stepping (#9).

## [2.2.0] - 2021-04-17

- Convert a schedule to array and to JSON (#8).

## [2.1.0] - 2021-04-09

- Remove the unused `Bookable` interface (#7).
- Require PHP 8+ and update libraries (#6).

## [2.0.0] - 2021-03-14

- Add a field to temporarily override a schedule and disable the agenda without
deleting it (#5).
- Update libraries (#4).

## [1.0.0] - 2020-11-26

- Support for PHP 8 (#3).

## [0.0.4] - 2020-09-02

- Add contracts, agenda, and time slots (#2).

## [0.0.3] - 2020-09-02

- Add the weekly schedule class (#1).

## [0.0.2] - 2020-09-01

- Restrict the supported runtime to PHP 7.4.

## [0.0.1] - 2020-09-01

- Initial release.

[Unreleased]: https://github.com/puntodev/bookables/compare/v4.1.2...HEAD
[4.1.2]: https://github.com/puntodev/bookables/compare/v4.1.1...v4.1.2
[4.1.1]: https://github.com/puntodev/bookables/compare/v4.1.0...v4.1.1
[4.1.0]: https://github.com/puntodev/bookables/compare/v4.0.1...v4.1.0
[4.0.1]: https://github.com/puntodev/bookables/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/puntodev/bookables/compare/v3.1.0...v4.0.0
[3.1.0]: https://github.com/puntodev/bookables/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/puntodev/bookables/compare/v2.6.0...v3.0.0
[2.6.0]: https://github.com/puntodev/bookables/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/puntodev/bookables/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/puntodev/bookables/compare/v2.3.0...v2.4.0
[2.3.0]: https://github.com/puntodev/bookables/compare/v2.2.0...v2.3.0
[2.2.0]: https://github.com/puntodev/bookables/compare/v2.1.0...v2.2.0
[2.1.0]: https://github.com/puntodev/bookables/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/puntodev/bookables/compare/v1.0.0...v2.0.0
[1.0.0]: https://github.com/puntodev/bookables/compare/v0.0.4...v1.0.0
[0.0.4]: https://github.com/puntodev/bookables/compare/v0.0.3...v0.0.4
[0.0.3]: https://github.com/puntodev/bookables/compare/v0.0.2...v0.0.3
[0.0.2]: https://github.com/puntodev/bookables/compare/v0.0.1...v0.0.2
[0.0.1]: https://github.com/puntodev/bookables/releases/tag/v0.0.1
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ $schedule = WeeklySchedule::fromArray(WeeklySchedule::defaultWorkingHours());

| Key | Type | Description |
| --- | --- | --- |
| `daily` | object | Map of day-of-week (`Sun`, `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`) to a list of `{ "start": "HH:MM", "end": "HH:MM" }` ranges. `start` must be before `end`. |
| `daily` | object | Map of day-of-week (`Sun`, `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`) to a list of `{ "start": "HH:MM", "end": "HH:MM" }` ranges. Times must be a zero-padded time of day (`HH:MM` or `HH:MM:SS`, `00:00`–`23:59`); relative expressions like `now` are rejected. `start` must be before `end`. |
| `hours_in_advance` | int | Minimum booking notice, in hours. **Metadata only** — stored and exposed via `hoursInAdvance()`, but not enforced by the slotters (see notes below). |
| `disable_all` | bool | When `true`, the schedule yields no availability regardless of `daily`. Optional, defaults to `false`. |

Expand Down Expand Up @@ -242,6 +242,29 @@ class Professional implements HasAgenda
yields no ranges.
- Ranges and slots are immutable `League\Period\Period` objects; all internal date
math uses Carbon's immutable variants.
- **Requested date ranges are capped.** `WeeklyScheduleAgenda`, `AgendaSlotter` and
`DaySlotter` generate one entry per day (and per slot) in the `[from, to]` window,
so an unbounded range would exhaust memory. Each takes an optional `maxDays`
argument (default `366`) and throws
`Puntodev\Bookables\Exceptions\DateRangeTooLargeException` when the window is
larger. Pass `0` (or less) to disable the limit if you have your own bound:

```php
use Puntodev\Bookables\Exceptions\DateRangeTooLargeException;

$agenda = new WeeklyScheduleAgenda($schedule, maxDays: 92);
$slotter = new AgendaSlotter($agenda, duration: 30, maxDays: 92);
$slotter = new DaySlotter(duration: 30, step: 15, maxDays: 92);

try {
$slots = $slotter->makeSlotsForDates($from, $to);
} catch (DateRangeTooLargeException $e) {
// reject the request (e.g. HTTP 422)
}
```
- **Slot durations must be positive.** `AgendaSlotter` (`duration`) and `DaySlotter`
(`duration`, `step`) reject non-positive values with `InvalidArgumentException`;
`timeAfter`/`timeBefore` must not be negative.

## Testing

Expand Down
12 changes: 11 additions & 1 deletion src/Agenda/WeeklyScheduleAgenda.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@
use Exception;
use League\Period\Period;
use Puntodev\Bookables\Contracts\Agenda;
use Puntodev\Bookables\Support\DateRangeGuard;
use Puntodev\Bookables\WeeklySchedule;

class WeeklyScheduleAgenda implements Agenda
{
private WeeklySchedule $weeklySchedule;

public function __construct(WeeklySchedule $weeklySchedule)
private int $maxDays;

/**
* $maxDays caps the size of the requested range to avoid unbounded range
* generation; a value of 0 or less disables the limit.
*/
public function __construct(WeeklySchedule $weeklySchedule, int $maxDays = 366)
{
$this->weeklySchedule = $weeklySchedule;
$this->maxDays = $maxDays;
}

/**
Expand All @@ -28,6 +36,8 @@ public function __construct(WeeklySchedule $weeklySchedule)
*/
public function possibleRanges(CarbonInterface $from, CarbonInterface $to): array
{
DateRangeGuard::ensureWithinLimit($from, $to, $this->maxDays);

$startOfDateFrom = $from->toImmutable()->startOfDay();
$endOfDateTo = $to->toImmutable()->endOfDay();

Expand Down
14 changes: 14 additions & 0 deletions src/Exceptions/DateRangeTooLargeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Puntodev\Bookables\Exceptions;

use InvalidArgumentException;

/**
* Thrown when the requested date range exceeds the maximum number of days a
* slotter or agenda is allowed to process, preventing unbounded slot
* generation (a denial-of-service vector).
*/
class DateRangeTooLargeException extends InvalidArgumentException
{
}
18 changes: 17 additions & 1 deletion src/Slots/AgendaSlotter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,40 @@
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Carbon\CarbonPeriodImmutable;
use InvalidArgumentException;
use League\Period\Period;
use Puntodev\Bookables\Contracts\Agenda;
use Puntodev\Bookables\Contracts\TimeSlotter;
use Puntodev\Bookables\Support\DateRangeGuard;

/**
* TimeSlotter that uses an $agenda to get the date ranges for the required dates
* and creates slots for those ranges using a particular $duration.
* If $timeBefore is specified, it ensures that before each appointment there's at least $timeBefore minutes.
* If $timeAfter is specified, it ensures that after each appointment there's at least $timeAfter minutes.
*
* $maxDays caps the size of the requested range to avoid unbounded slot
* generation; a value of 0 or less disables the limit.
*/
readonly class AgendaSlotter implements TimeSlotter
{
public function __construct(private Agenda $agenda, private int $duration, private int $timeAfter = 0, private int $timeBefore = 0)
public function __construct(private Agenda $agenda, private int $duration, private int $timeAfter = 0, private int $timeBefore = 0, private int $maxDays = 366)
{
if ($this->duration <= 0) {
throw new InvalidArgumentException('Duration must be a positive number of minutes.');
}
if ($this->timeAfter < 0) {
throw new InvalidArgumentException('Time after must not be negative.');
}
if ($this->timeBefore < 0) {
throw new InvalidArgumentException('Time before must not be negative.');
}
}

public function makeSlotsForDates(CarbonInterface $startDate, CarbonInterface $endDate): array
{
DateRangeGuard::ensureWithinLimit($startDate, $endDate, $this->maxDays);

$ranges = $this->agenda->possibleRanges($startDate, $endDate);

$ret = [];
Expand Down
15 changes: 14 additions & 1 deletion src/Slots/DaySlotter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,34 @@
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Carbon\CarbonPeriodImmutable;
use InvalidArgumentException;
use League\Period\Period;
use Puntodev\Bookables\Contracts\TimeSlotter;
use Puntodev\Bookables\Support\DateRangeGuard;

/**
* TimeSlotter that creates slots for the whole day for each date within the requested range
* and creates slots for those dates using a particular $duration and $step.
*
* $maxDays caps the size of the requested range to avoid unbounded slot
* generation; a value of 0 or less disables the limit.
*/
readonly class DaySlotter implements TimeSlotter
{
public function __construct(private int $duration, private int $step)
public function __construct(private int $duration, private int $step, private int $maxDays = 366)
{
if ($this->duration <= 0) {
throw new InvalidArgumentException('Duration must be a positive number of minutes.');
}
if ($this->step <= 0) {
throw new InvalidArgumentException('Step must be a positive number of minutes.');
}
}

public function makeSlotsForDates(CarbonInterface $startDate, CarbonInterface $endDate): array
{
DateRangeGuard::ensureWithinLimit($startDate, $endDate, $this->maxDays);

$startOfDateFrom = $startDate->toImmutable()->startOfDay();
$endOfDateTo = $endDate->toImmutable()->endOfDay();

Expand Down
35 changes: 35 additions & 0 deletions src/Support/DateRangeGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Puntodev\Bookables\Support;

use Carbon\CarbonInterface;
use Puntodev\Bookables\Exceptions\DateRangeTooLargeException;

/**
* Guards slot/range generation against unbounded date ranges, which would
* otherwise let a caller exhaust memory and CPU (a denial-of-service vector).
*/
final class DateRangeGuard
{
/**
* @throws DateRangeTooLargeException when the span between $from and $to
* exceeds $maxDays. A $maxDays of 0 or less disables the check.
*/
public static function ensureWithinLimit(CarbonInterface $from, CarbonInterface $to, int $maxDays): void
{
if ($maxDays <= 0) {
return;
}

$days = $from->toImmutable()->startOfDay()
->diffInDays($to->toImmutable()->startOfDay(), true);

if ($days > $maxDays) {
throw new DateRangeTooLargeException(sprintf(
'Requested date range spans %d days, which exceeds the maximum of %d days.',
(int)ceil($days),
$maxDays,
));
}
}
}
24 changes: 18 additions & 6 deletions src/WeeklySchedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,23 +98,32 @@ private static function validate(array $array): void
if (!array_key_exists('end', $item)) {
throw new Exception("Invalid value in json representation of schedule. Element doesn't have end time: key: " . $k);
}
try {
$start = new DateTime($item['start']);
} catch (Exception $e) {
if (!self::isValidTimeOfDay($item['start'])) {
throw new Exception("Invalid time in json representation of schedule. Start is not a valid time: key: " . $k . ", start value: " . $item['start']);
}
try {
$end = new DateTime($item['end']);
} catch (Exception $e) {
if (!self::isValidTimeOfDay($item['end'])) {
throw new Exception("Invalid time in json representation of schedule. End is not a valid time: key: " . $k . ", end value: " . $item['end']);
}
$start = new DateTime($item['start']);
$end = new DateTime($item['end']);
if ($start > $end) {
throw new Exception("Invalid time range in json representation of schedule. Start time must be before end time: key: " . $k . ", start: " . $item['start'] . ', end: ' . $item['end']);
}
}
}
}

/**
* Accepts only a time of day in HH:mm or HH:mm:ss format (00:00:00 to
* 23:59:59). Rejects relative expressions such as "now" or "+1 day" and
* out-of-range values such as "25:00" or "14:60".
*/
private static function isValidTimeOfDay(mixed $time): bool
{
return is_string($time)
&& preg_match('/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/', $time) === 1;
}

public function forDate(CarbonInterface $date): array
{
return $this->forDay(self::$dowMap[$date->dayOfWeek]);
Expand Down Expand Up @@ -153,6 +162,9 @@ public function disableAll(): bool
public static function fromJson(string $json): WeeklySchedule
{
$json_decoded = json_decode($json, true);
if (!is_array($json_decoded)) {
throw new Exception("Invalid JSON in schedule: " . $json);
}
self::validate($json_decoded);

return self::fromArray($json_decoded);
Expand Down
Loading
Loading