Compare commits

...
4 Commits
Author SHA1 Message Date
stefan.tollkuehn dd4716faf4 Update README
- add license section,
- add info about vibe coding at top
- add information about contacts search GET
- minor changes
2026-10-27 11:43:43 +01:00
stefan.tollkuehn 34d54b5883 update appinfo
- add screenshots
- fix emailaddress
2026-10-27 10:50:51 +01:00
stefan.tollkuehn 9446f128c9 Rate limit contact search, skip short queries
The contact-search endpoint had no request-rate limit and ran
an address-book search even for an empty or single-character
query, the most expensive case. Authenticated users could
trigger repeated scans with simple scripted requests.

Apply the framework's per-user UserRateLimit attribute (120
requests per minute) and return an empty result without
touching the address book for fragments under two characters.
The unfiltered datalist on the confirm page is unchanged.
2026-10-26 13:43:58 +01:00
stefan.tollkuehn 2b3e96e50a Reject normalized datetime-local inputs
createFromFormat() only returns false on hard parse failures.
Impossible dates such as 2026-02-31 were silently normalized
into March, so a confirmed event could land on a different day
than the booking link supplied. Non-padded values like
2026-2-3T10:00 were also accepted and reformatted.

Parse with a leading "!" so no current-date fields leak in,
require zero warnings and errors from getLastErrors(), and
round-trip the value through the format. Impossible dates,
out-of-range components and non-padded values now throw in
both the confirmation and the creation stage.
2026-10-26 13:39:07 +01:00
4 changed files with 134 additions and 18 deletions

No files matched your search

+23 -5
View File
@@ -1,5 +1,10 @@
# AToCal — Add To Calendar # AToCal — Add To Calendar
> **Vibe-coded:** AToCal is written by Mistral AI's coding agent, not by hand.
> The maintainer sets the goals, makes the product decisions and tries the app
> out in the browser; there has been no independent human code review and
> no security audit.
A Nextcloud app that lets remote applications create events in the A Nextcloud app that lets remote applications create events in the
authenticated user's Nextcloud Calendar via a simple link. authenticated user's Nextcloud Calendar via a simple link.
@@ -21,8 +26,10 @@ remote app Nextcloud (atocal)
``` ```
The remote app links the user to the confirm page. The user reviews The remote app links the user to the confirm page. The user reviews
the event and submits the form. The event lands in the user's the event and submits the form. The event lands in a calendar the user
"Personal" calendar and the browser opens the Calendar app. chooses. The user adds invitees/attendees to the event, who receive
an invitation, as long as the user owns the calendar.
After the event is created the browser opens the Calendar app.
## Routes ## Routes
@@ -30,6 +37,7 @@ the event and submits the form. The event lands in the user's
|--------|------|---------| |--------|------|---------|
| GET | `/apps/atocal/confirm` | Show confirmation page | | GET | `/apps/atocal/confirm` | Show confirmation page |
| POST | `/apps/atocal/create` | Create the event | | POST | `/apps/atocal/create` | Create the event |
| GET | `/apps/atocal/contacts/search` | Contact search for the invitee input |
## Link parameters (GET `/confirm` and form POST `/create`) ## Link parameters (GET `/confirm` and form POST `/create`)
@@ -43,6 +51,9 @@ the event and submits the form. The event lands in the user's
| `url` | no | HTTPS URL; merged into description | | `url` | no | HTTPS URL; merged into description |
| `organizer` | no | email address; display info only | | `organizer` | no | email address; display info only |
The create form adds `calendarId` (selected calendar) and
`attendees` (emails, max 20).
Dates carry no timezone and are interpreted in the user's Nextcloud Dates carry no timezone and are interpreted in the user's Nextcloud
timezone (server default as fallback). timezone (server default as fallback).
@@ -73,8 +84,7 @@ https://cloud.example.com/apps/atocal/confirm?summary=Kickoff&start=2026-10-12T1
- Nextcloud 34 or 35 - Nextcloud 34 or 35
- Calendar app enabled - Calendar app enabled
- A writable calendar named "Personal" - A writable calendar ("Personal" is preselected if present)
(event creation fails with a friendly error otherwise)
## Install ## Install
@@ -90,8 +100,16 @@ occ app:enable atocal
``` ```
appinfo/info.xml app metadata appinfo/info.xml app metadata
appinfo/routes.php routes appinfo/routes.php routes
css/style.css page styles
js/invitees.js invitee input
l10n/*.json translations
lib/AppInfo/Application.php container setup lib/AppInfo/Application.php container setup
lib/Controller/CalendarController.php confirm/create logic lib/Controller/CalendarController.php confirm/create logic
templates/confirm.php confirmation page templates/confirm.php confirmation page
templates/style.css page styles
``` ```
## Licence
AGPL-3.0-or-later, see LICENSE. AToCal comes without any warranty.
AToCal is an independent project and is not affiliated with any other product or company of a similar name.
+6 -2
View File
@@ -8,11 +8,15 @@
</description> </description>
<version>1.0.0</version> <version>1.0.0</version>
<licence>AGPL</licence> <licence>AGPL</licence>
<author>Stefan Tollkühn s.tollkuehn@bnvb-ingenieure.de</author> <author mail="atocal@bnvb-ingenieure.de">Stefan Tollkühn</author>
<namespace>AToCal</namespace> <namespace>AToCal</namespace>
<category>integration</category> <category>integration</category>
<category>tools</category>
<dependencies> <dependencies>
<nextcloud min-version="34" max-version="35"/> <nextcloud min-version="34" max-version="35"/>
</dependencies> </dependencies>
<screenshot></screenshot> <website>https://github.com/osiktech/atocal</website>
<bugs>https://github.com/osiktech/atocal/issues</bugs>
<repository type="git">https://github.com/osiktech/atocal</repository>
<screenshot>https://private-user-images.githubusercontent.com/21309019/685938995-dbd7ec4c-6adc-4059-bcd6-59cce9d6fcb5.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTMwOTQyMDgsIm5iZiI6MTc5MzA5MzkwOCwicGF0aCI6Ii8yMTMwOTAxOS82ODU5Mzg5OTUtZGJkN2VjNGMtNmFkYy00MDU5LWJjZDYtNTljY2U5ZDZmY2I1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI3VDA5MzgyOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWFlZDE3MDk4OTNhYmU0ZjIzNDBjZjMwNTc5ZjFmZDIyMTFjNjU4MzBmMzkxODllOTA0NTUxYTMyMGIwMTA3MDAmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.ChinpCO2myfCeA1hW7Eydtlu13WcdIu98nNom9QvDNg</screenshot>
</info> </info>
+47 -11
View File
@@ -10,6 +10,7 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
@@ -76,6 +77,19 @@ class CalendarController extends Controller
*/ */
private const MAX_CONTACT_SUGGESTIONS = 50; private const MAX_CONTACT_SUGGESTIONS = 50;
/*
* Short fragments are not distinctive enough for an
* address-book search, so they skip it entirely.
*/
private const MIN_SEARCH_QUERY = 2;
/*
* Live-search requests per minute per user. The JS debounce
* produces at most four per second while typing.
*/
private const SEARCH_RATE_LIMIT = 120;
private const SEARCH_RATE_PERIOD = 60;
private IManager $calendarManager; private IManager $calendarManager;
private IContactsManager $contactsManager; private IContactsManager $contactsManager;
private IAppManager $appManager; private IAppManager $appManager;
@@ -310,8 +324,21 @@ class CalendarController extends Controller
*/ */
#[NoAdminRequired] #[NoAdminRequired]
#[NoCSRFRequired] #[NoCSRFRequired]
#[UserRateLimit(
limit: self::SEARCH_RATE_LIMIT,
period: self::SEARCH_RATE_PERIOD
)]
public function searchContacts(string $query = ''): DataResponse public function searchContacts(string $query = ''): DataResponse
{ {
/*
* An empty or single-character query means a scan of the
* whole address book: the most expensive case, skipped
* because such fragments match too much anyway.
*/
if (mb_strlen(trim($query)) < self::MIN_SEARCH_QUERY) {
return new DataResponse([]);
}
return new DataResponse($this->contactSuggestions($query)); return new DataResponse($this->contactSuggestions($query));
} }
@@ -844,29 +871,38 @@ class CalendarController extends Controller
{ {
$timeZone = $this->dateTimeZone->getTimeZone(); $timeZone = $this->dateTimeZone->getTimeZone();
/*
* "!" resets all fields, so no parts of the current date
* leak in. PHP normalizes impossible dates instead of
* failing (2026-02-31 rolls into March), so require zero
* warnings and an exact format round-trip on top of the
* parse result.
*/
foreach (['!' . self::DATETIME_LOCAL, '!Y-m-d\TH:i:s'] as $format) {
$date = \DateTimeImmutable::createFromFormat( $date = \DateTimeImmutable::createFromFormat(
self::DATETIME_LOCAL, $format,
$value, $value,
$timeZone $timeZone
); );
if ($date === false) { $errors = \DateTimeImmutable::getLastErrors();
$date = \DateTimeImmutable::createFromFormat(
'Y-m-d\TH:i:s', if (
$value, $date !== false
$timeZone && ($errors === false
); || ($errors['warning_count'] === 0
&& $errors['error_count'] === 0))
&& $date->format(substr($format, 1)) === $value
) {
return $date;
}
} }
if ($date === false) {
throw new \InvalidArgumentException( throw new \InvalidArgumentException(
$this->l10n->t('Invalid date/time.') $this->l10n->t('Invalid date/time.')
); );
} }
return $date;
}
/** /**
* Reject invalid UTF-8 and control characters. Newlines * Reject invalid UTF-8 and control characters. Newlines
* (\n, \r) are only allowed where multi-line text is * (\n, \r) are only allowed where multi-line text is
+58
View File
@@ -9,6 +9,7 @@ use OCP\App\IAppManager;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\Calendar\ICalendar; use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarEventBuilder; use OCP\Calendar\ICalendarEventBuilder;
use OCP\Calendar\ICreateFromString; use OCP\Calendar\ICreateFromString;
@@ -218,6 +219,35 @@ class CalendarControllerTest extends TestCase
$this->invoke($c, 'parseLocalDateTime', ['tomorrow']); $this->invoke($c, 'parseLocalDateTime', ['tomorrow']);
} }
public function testParseLocalDateTimeAcceptsLeapDay(): void
{
$c = $this->newController();
$date = $this->invoke($c, 'parseLocalDateTime', ['2028-02-29T10:00']);
$this->assertSame('2028-02-29 10:00:00 UTC', $date->format('Y-m-d H:i:s T'));
}
public static function provideInvalidLocalDateTimes(): iterable
{
return [
'impossible day rolls into next month' => ['2026-02-31T10:00'],
'leap day in a non-leap year' => ['2027-02-29T10:00'],
'month out of range' => ['2026-13-01T10:00'],
'day out of range' => ['2026-00-15T10:00'],
'hour out of range' => ['2026-02-03T25:00'],
'non-padded components' => ['2026-2-3T10:00'],
];
}
#[DataProvider('provideInvalidLocalDateTimes')]
public function testParseLocalDateTimeRejectsInvalidValues(string $value): void
{
$c = $this->newController();
$this->expectException(\InvalidArgumentException::class);
$this->invoke($c, 'parseLocalDateTime', [$value]);
}
/* /*
* validateEventData * validateEventData
*/ */
@@ -1056,6 +1086,34 @@ class CalendarControllerTest extends TestCase
$this->assertCount(50, $response->getData()); $this->assertCount(50, $response->getData());
} }
/*
* searchContacts(): trivial queries must not reach the
* address book
*/
public function testSearchContactsSkipsShortQueries(): void
{
$this->contactsManager->expects($this->never())->method('search');
$c = $this->newController();
$this->assertSame([], $c->searchContacts('')->getData());
$this->assertSame([], $c->searchContacts('a')->getData());
$this->assertSame([], $c->searchContacts(' b ')->getData());
}
public function testSearchContactsIsRateLimited(): void
{
$method = new \ReflectionMethod(CalendarController::class, 'searchContacts');
$attributes = $method->getAttributes(UserRateLimit::class);
$this->assertCount(1, $attributes);
$limit = $attributes[0]->newInstance();
$this->assertSame(120, $limit->getLimit());
$this->assertSame(60, $limit->getPeriod());
}
public function testSearchContactsTruncatesQuery(): void public function testSearchContactsTruncatesQuery(): void
{ {
$this->contactsManager->expects($this->once())->method('search') $this->contactsManager->expects($this->once())->method('search')