Compare commits

..
7 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
stefan.tollkuehn a9b7fd11af Announce the suggestion list to screen readers
The invitee input had no combobox semantics: assistive
technology could not tell that the widget owns a listbox with
suggestions. Mark the input as role="combobox" with
aria-autocomplete="list" and an initial aria-expanded="false",
point aria-controls at the list and give the JS-built list a
matching id so the reference resolves.
2026-10-23 14:08:59 +02:00
stefan.tollkuehn 5bba3a93cd Align invitee separators with the server
The server splits the attendee input on whitespace, commas and
semicolons, but the search widget only treated commas and
semicolons as separators. Typing "a@x.com b" therefore sent the
whole string as the search query instead of "b", and picking a
contact dropped the whitespace-separated entry.

Split on /[\s;,]+/ in both fragment() and commit() so the
widget agrees with parseAttendees() on what an entry is.
2026-10-23 14:07:28 +02:00
stefan.tollkuehn e5432a0a38 Fix stale contact suggestions reopening
hide() did not invalidate in-flight search requests. Clearing
the input or picking a contact while a request was pending let
the old response re-render and pop the suggestion list back
open. Bump requestSeq in hide() so every dismissal retires
pending responses.

Add a Vitest + jsdom harness with black-box tests for the
invitee widget, covering the stale-response race both on
field clear and on contact pick.
2026-10-23 14:04:32 +02:00
11 changed files with 2487 additions and 28 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
/vendor/
node_modules/
.phpunit.result.cache
+27 -9
View File
@@ -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
@@ -88,10 +98,18 @@ occ app:enable atocal
## Structure
```
appinfo/info.xml app metadata
appinfo/routes.php routes
lib/AppInfo/Application.php container setup
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
templates/confirm.php confirmation page
```
## 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>
<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>
+6 -2
View File
@@ -13,7 +13,9 @@
'use strict';
var DEBOUNCE_MS = 250;
var SEPARATOR = /[;,]/;
/* Must match the server's attendee parsing: whitespace, comma and
* semicolon all separate entries. */
var SEPARATOR = /[\s;,]+/;
var input = document.getElementById('atocal-attendees');
@@ -28,6 +30,7 @@
}
var list = document.createElement('ul');
list.id = 'atocal-suggestions';
list.className = 'atocal-suggestions';
list.hidden = true;
list.setAttribute('role', 'listbox');
@@ -49,6 +52,7 @@
}
function hide() {
requestSeq++;
list.hidden = true;
list.replaceChildren();
suggestions = [];
@@ -58,7 +62,7 @@
}
function commit(email) {
var parts = input.value.split(/([;,])/);
var parts = input.value.split(/([\s;,]+)/);
parts.pop();
var prefix = parts.join('').replace(/\s+$/, '');
+51 -15
View File
@@ -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,27 +871,36 @@ class CalendarController extends Controller
{
$timeZone = $this->dateTimeZone->getTimeZone();
$date = \DateTimeImmutable::createFromFormat(
self::DATETIME_LOCAL,
$value,
$timeZone
);
if ($date === false) {
/*
* "!" 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(
'Y-m-d\TH:i:s',
$format,
$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;
throw new \InvalidArgumentException(
$this->l10n->t('Invalid date/time.')
);
}
/**
+2148
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
{
"private": true,
"description": "JS test harness for the atocal frontend scripts",
"scripts": {
"test:js": "vitest run"
},
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
}
+4
View File
@@ -140,6 +140,10 @@ $l = $_['l10n'];
id="atocal-attendees"
name="attendees"
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="atocal-suggestions"
aria-expanded="false"
list="atocal-contact-emails"
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
value="<?= $h($_['attendees']) ?>"
+58
View File
@@ -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')
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const DEBOUNCE_MS = 250;
const SEARCH_URL = '/apps/atocal/contact/search';
const BOB = { name: 'Bob', email: 'bob@example.com' };
const ZOE = { name: 'Zoe', email: 'zoe@example.com' };
let input;
let list;
function response(contacts) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(contacts),
});
}
/* Promise we can settle manually, to emulate an in-flight request */
function pendingResponse() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
function type(fragment) {
input.value = fragment;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/* Let the debounce fire and the promise chain settle */
async function settle(ms = DEBOUNCE_MS) {
await vi.advanceTimersByTimeAsync(ms);
}
beforeAll(async () => {
document.body.innerHTML =
'<span><input id="atocal-attendees" data-search-url="' + SEARCH_URL + '"'
+ ' role="combobox" aria-autocomplete="list"'
+ ' aria-controls="atocal-suggestions" aria-expanded="false"></span>';
await import('../../js/invitees.js');
input = document.getElementById('atocal-attendees');
list = document.querySelector('.atocal-suggestions');
});
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
});
afterEach(async () => {
/* Empty fragment hides the list and cancels pending debounces */
type('');
await settle(0);
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('invitee suggestions', () => {
it('marks the input as a combobox controlling the list', () => {
expect(input.getAttribute('role')).toBe('combobox');
expect(input.getAttribute('aria-autocomplete')).toBe('list');
/* aria-controls must resolve to the list built by the widget */
const controlled = document.getElementById(
input.getAttribute('aria-controls')
);
expect(controlled).toBe(list);
expect(input.getAttribute('aria-expanded')).toBe('false');
});
it('renders matching contacts', async () => {
fetch.mockReturnValue(response([BOB]));
type('bo');
await settle();
expect(fetch).toHaveBeenCalledTimes(1);
expect(list.hidden).toBe(false);
expect(list.querySelectorAll('[role="option"]')).toHaveLength(1);
expect(list.textContent).toContain('bob@example.com');
expect(input.getAttribute('aria-expanded')).toBe('true');
});
it('ignores responses that arrive after the field was cleared', async () => {
const inFlight = pendingResponse();
fetch.mockReturnValue(inFlight.promise);
type('bo');
await settle();
/* User clears the field while the request is still in flight */
type('');
inFlight.resolve({
ok: true,
json: () => Promise.resolve([BOB]),
});
await settle(0);
expect(list.hidden).toBe(true);
expect(list.children).toHaveLength(0);
expect(input.getAttribute('aria-expanded')).toBe('false');
});
it('ignores responses that arrive after a contact was picked', async () => {
const second = pendingResponse();
fetch
.mockReturnValueOnce(response([BOB]))
.mockReturnValueOnce(second.promise);
type('bo');
await settle();
expect(list.hidden).toBe(false);
/* Typing more triggers a second request */
type('bob');
await settle();
/* User picks the visible suggestion while the request is in flight */
list.querySelector('button').dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
);
expect(input.value).toBe('bob@example.com, ');
expect(list.hidden).toBe(true);
/* The stale response must not reopen the list */
second.resolve({
ok: true,
json: () => Promise.resolve([ZOE]),
});
await settle(0);
expect(list.hidden).toBe(true);
expect(list.children).toHaveLength(0);
});
it('searches only the last whitespace-separated fragment', async () => {
fetch.mockReturnValue(response([BOB]));
type('alice@example.com bo');
await settle();
expect(fetch).toHaveBeenCalledWith(
SEARCH_URL + '?query=bo',
expect.objectContaining({ headers: { Accept: 'application/json' } })
);
});
it('keeps whitespace-separated entries when picking a contact', async () => {
fetch.mockReturnValue(response([BOB]));
type('alice@example.com bo');
await settle();
list.querySelector('button').dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
);
expect(input.value).toBe('alice@example.com bob@example.com, ');
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/js/**/*.test.mjs'],
},
});