Compare commits
4
Commits
a9b7fd11af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4716faf4 | ||
|
|
34d54b5883 | ||
|
|
9446f128c9 | ||
|
|
2b3e96e50a |
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
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 event and submits the form. The event lands in the user's
|
||||
"Personal" calendar and the browser opens the Calendar app.
|
||||
the event and submits the form. The event lands in a calendar the user
|
||||
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
|
||||
|
||||
@@ -30,6 +37,7 @@ the event and submits the form. The event lands in the user's
|
||||
|--------|------|---------|
|
||||
| GET | `/apps/atocal/confirm` | Show confirmation page |
|
||||
| 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`)
|
||||
|
||||
@@ -43,6 +51,9 @@ the event and submits the form. The event lands in the user's
|
||||
| `url` | no | HTTPS URL; merged into description |
|
||||
| `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
|
||||
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
|
||||
- Calendar app enabled
|
||||
- A writable calendar named "Personal"
|
||||
(event creation fails with a friendly error otherwise)
|
||||
- A writable calendar ("Personal" is preselected if present)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -90,8 +100,16 @@ occ app:enable atocal
|
||||
```
|
||||
appinfo/info.xml app metadata
|
||||
appinfo/routes.php routes
|
||||
css/style.css page styles
|
||||
js/invitees.js invitee input
|
||||
l10n/*.json translations
|
||||
lib/AppInfo/Application.php container setup
|
||||
lib/Controller/CalendarController.php confirm/create logic
|
||||
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
@@ -8,11 +8,15 @@
|
||||
</description>
|
||||
<version>1.0.0</version>
|
||||
<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>
|
||||
<category>integration</category>
|
||||
<category>tools</category>
|
||||
<dependencies>
|
||||
<nextcloud min-version="34" max-version="35"/>
|
||||
</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>
|
||||
@@ -10,6 +10,7 @@ use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\UserRateLimit;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
@@ -76,6 +77,19 @@ class CalendarController extends Controller
|
||||
*/
|
||||
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 IContactsManager $contactsManager;
|
||||
private IAppManager $appManager;
|
||||
@@ -310,8 +324,21 @@ class CalendarController extends Controller
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
#[UserRateLimit(
|
||||
limit: self::SEARCH_RATE_LIMIT,
|
||||
period: self::SEARCH_RATE_PERIOD
|
||||
)]
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -844,29 +871,38 @@ class CalendarController extends Controller
|
||||
{
|
||||
$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(
|
||||
self::DATETIME_LOCAL,
|
||||
$format,
|
||||
$value,
|
||||
$timeZone
|
||||
);
|
||||
|
||||
if ($date === false) {
|
||||
$date = \DateTimeImmutable::createFromFormat(
|
||||
'Y-m-d\TH:i:s',
|
||||
$value,
|
||||
$timeZone
|
||||
);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
|
||||
if (
|
||||
$date !== false
|
||||
&& ($errors === false
|
||||
|| ($errors['warning_count'] === 0
|
||||
&& $errors['error_count'] === 0))
|
||||
&& $date->format(substr($format, 1)) === $value
|
||||
) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
if ($date === false) {
|
||||
throw new \InvalidArgumentException(
|
||||
$this->l10n->t('Invalid date/time.')
|
||||
);
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject invalid UTF-8 and control characters. Newlines
|
||||
* (\n, \r) are only allowed where multi-line text is
|
||||
|
||||
@@ -9,6 +9,7 @@ use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Http\Attribute\UserRateLimit;
|
||||
use OCP\Calendar\ICalendar;
|
||||
use OCP\Calendar\ICalendarEventBuilder;
|
||||
use OCP\Calendar\ICreateFromString;
|
||||
@@ -218,6 +219,35 @@ class CalendarControllerTest extends TestCase
|
||||
$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
|
||||
*/
|
||||
@@ -1056,6 +1086,34 @@ class CalendarControllerTest extends TestCase
|
||||
$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
|
||||
{
|
||||
$this->contactsManager->expects($this->once())->method('search')
|
||||
|
||||
Reference in new issue
Block a user