blob: 088ae6ecdcf89a0da209fd8cccb35ab35fd141a6 [file] [view]
# ASF Community Calendar
A shared calendar for the Apache Software Foundation. It holds three kinds of
events, each with its own rules about who may see and change them:
- **Personal events** belong to one person. Nobody else can see them.
- **Project events** belong to a project. Public ones are visible to everybody,
including people who are not logged in; private ones are visible only to the
project's committee. Project members create and edit them.
- **Foundation events** belong to the ASF as a whole. Public ones are visible to
everybody; private ones are visible only to foundation members. Only members
can create or edit them.
Every event has a shortlink, something like `https://calendar.apache.org/e/Qvj3wfyH`,
that opens the event directly. Public events also carry OpenGraph tags on that
page, so pasting a shortlink into chat or email shows the event title rather
than a bare URL.
The web UI has day, week, month, year and agenda views, filters for calendars,
projects and visibility, a text search, and an iCalendar export so events can be
pulled into whatever calendar application you already use. When you are logged
in it opens showing everything you have access to: your own events, every
project you belong to, and the foundation calendar.
It is timezone aware in both directions. When you add an event you say which
timezone the times are in, and everybody else sees them on their own clock. The
switch at the top of the page draws the whole calendar in your browser's
timezone or in any of the major timezones, and you can pin up to three more
alongside it to see an event in the organiser's time and in yours at once.
There is also a help page, at `/help`, that explains the views, the filters, the
timezone controls and exactly what each kind of user can see and do.
The backend is [asfquart](https://github.com/apache/infrastructure-asfquart)
(Quart with ASF conventions layered on top), which provides the OAuth login
against `oauth.apache.org` and the project and committee membership that the
access rules are built on. The frontend is Svelte 5 built with Vite.
## Contents
- [How access works](#how-access-works)
- [Requirements](#requirements)
- [Installing](#installing)
- [Configuration](#configuration)
- [Running it](#running-it)
- [Serving from a sub-directory](#serving-from-a-sub-directory)
- [Embedding the agenda elsewhere](#embedding-the-agenda-elsewhere)
- [Timezones](#timezones)
- [Importing an iCalendar file](#importing-an-icalendar-file)
- [The API](#the-api)
- [API documentation](#api-documentation)
- [Project layout](#project-layout)
- [Tests](#tests)
- [Debugging](#debugging)
- [Things it does not do](#things-it-does-not-do)
## How access works
After an OAuth login, asfquart puts the user's affiliations into the session:
`session.projects` lists the projects they commit to, `session.committees` lists
the committees (PMCs) they sit on, and `session.isMember` says whether they are
a foundation member. Those three facts decide everything.
| Category | Visibility | Who can read it | Who can create, edit and delete |
| ---------- | ---------- | ----------------------------------- | --------------------------------- |
| personal | private | the owner | the owner |
| project | public | everybody, including anonymous | `session.projects` for that project |
| project | private | `session.committees` for that project | `session.committees` for that project |
| foundation | public | everybody, including anonymous | foundation members |
| foundation | private | foundation members | foundation members |
A few details worth knowing:
- Personal events are always private. If a client asks for a public personal
event, it is stored as private anyway.
- Committee membership implies project membership. A PMC member who is somehow
missing from the committer list can still manage their project's public
events.
- Any project member can edit any of their project's events, not only the ones
they created. The `owner` field records who added an event but does not
restrict who can change it.
- An edit has to pass the check twice: once against the event as it is stored,
and once against the event as it would be afterwards. That stops somebody
moving an event into a calendar they cannot write to, or out of one they
cannot write to.
- The membership test uses asfquart's own `Requirements.member`, so this app
agrees with whatever `@asfquart.auth.require({R.member})` would decide
elsewhere.
The rules live in one place, `backend/asfcalendar/permissions.py`, as plain
functions over `(event, session)`. `backend/tests/test_permissions.py` walks
through every combination of category, visibility, project and user. The same
table, written for users rather than for developers, is in the app itself at
`/help`, along with a note about what the current visitor's own account allows.
The database query that lists events applies the same rules as a SQL `WHERE`
clause, so listing a month does not mean loading every event in the table and
filtering in Python. The API then runs `can_view` over the results anyway. That
is deliberate belt and braces: a mistake in the SQL should not become a
disclosure bug, and there is a test asserting the two agree.
## Requirements
- Python 3.11 or newer
- [uv](https://docs.astral.sh/uv/) for the backend
- Node.js 20 or newer for the frontend
Nothing else. Events live in a SQLite file, so there is no database server to
set up.
## Installing
```shell
git clone https://github.com/apache/comdev-calendar.git
cd comdev-calendar
# Backend dependencies, into .venv
uv sync --all-groups
# Frontend dependencies
cd frontend && npm ci && cd ..
# Configuration
cp config.yaml.example config.yaml
```
Or, if you have `make`:
```shell
make install
cp config.yaml.example config.yaml
```
## Configuration
Configuration lives in `config.yaml` in the repository root, in the YAML format
asfquart reads. `config.yaml.example` documents every key; all of them are
optional and the defaults are fine for local work.
```yaml
server:
host: 127.0.0.1
port: 8080
# Absolute base URL of the deployment, used to build shortlinks. Leave it
# empty and the base is taken from the incoming request instead.
base_url: "https://calendar.apache.org"
database:
path: "calendar.sqlite3"
app:
title: "ASF Community Calendar"
frontend_dist: "frontend/dist"
# Which clock the calendar opens on for someone who has not chosen:
# "local" or "utc".
default_display_zone: "local"
shortlink_prefix: "/e"
oauth:
uri: "/auth"
debug: false
# Read by asfquart itself: maximum session lifetime in seconds, 0 for no limit.
MAX_SESSION_AGE: 0
```
Relative paths are resolved against the directory holding `config.yaml`.
Two files appear at runtime and should not be committed (both are in
`.gitignore`):
- `calendar.sqlite3` - the event database.
- `apptoken.txt` - the key asfquart uses to sign session cookies. It is created
with mode 0600 on first start. Delete it and everybody is logged out.
## Running it
### Development
You want two processes. The backend serves the API and handles OAuth; the Vite
dev server serves the UI with hot reloading and proxies `/api` and `/auth`
through to the backend, so the browser sees a single origin and session cookies
work normally.
```shell
# Terminal 1
uv run asf-calendar --reload
# Terminal 2
cd frontend && npm run dev
```
Then open <http://localhost:5173>.
If your backend is somewhere other than `http://127.0.0.1:8080`, tell Vite:
```shell
BACKEND=http://127.0.0.1:9000 npm run dev
```
Useful backend flags:
```
-c, --config PATH path to the YAML config (default: ./config.yaml)
--host HOST bind address, overriding the config
--port PORT port, overriding the config
--reload restart when a source file changes
--debug Quart debug mode, with tracebacks in responses
--access-log log every request
-v, --verbose log at DEBUG level
```
### Production
`frontend/dist` is committed to the repository, kept up to date by CI (see
[Committed build output](#committed-build-output)). A deployment is therefore a
checkout and the backend, with no Node involved:
```shell
uv run asf-calendar
```
The backend serves `frontend/dist` as static files and falls back to
`index.html` for client-side routes. If you are working from a branch where the
build is stale, or you have just changed the UI locally, rebuild it yourself:
```shell
cd frontend && npm ci && npm run build && cd ..
```
The server is [hypercorn](https://hypercorn.readthedocs.io/). To run it directly,
for instance under systemd with your own worker settings:
```shell
uv run hypercorn --bind 0.0.0.0:8080 --workers 1 'asfcalendar.app:create_app()'
```
#### Use one worker
`asfquart` keeps the pending OAuth states in a process-local
dictionary, so with several workers a login started on one and completed on
another will fail. This is
[a known asfquart limitation](https://github.com/apache/infrastructure-asfquart/issues/52).
### As a service
`pipservice-comdev-calendar.service` is a systemd unit for the ASF's pipservice
deployment pattern. It runs `uv run asf-calendar` from the checkout, which finds
`pyproject.toml` and `config.yaml` by walking up from the package directory.
If you move things around, or install the package rather than running it from a
checkout, pin the config explicitly rather than relying on that: either pass
`--config /path/to/config.yaml`, or set `ASF_CALENDAR_CONFIG` in the unit. The
same environment variable is what `--reload` uses to hand the config path to its
worker processes.
Behind httpd or another reverse proxy, pass the original `Host` header through.
asfquart builds the OAuth callback URL from it, and a rewritten host sends users
back to the wrong place after login:
```apache
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
```
That is the layout for a calendar at the root of its host. To serve it from a
sub-directory instead, see [Serving from a
sub-directory](#serving-from-a-sub-directory).
Session cookies are set with `Secure`, so the deployment has to be served over
HTTPS. See [Debugging](#debugging) if logins seem to work but do not stick.
## Serving from a sub-directory
The calendar normally sits at the root of a host, at
`https://calendar.apache.org/`. It can be mounted in a sub-directory instead by
setting one config key:
```yaml
server:
base_path: "/calendar"
```
Everything moves under it in one go: the pages, the whole `/api` surface, the
OAuth endpoint, the static assets and the event shortlinks. A shortlink becomes
`https://calendar.apache.org/calendar/e/AbCd2345`, the login URL becomes
`/calendar/auth?login=/calendar/`, and the callback URL asfquart sends to the
OAuth provider comes back to `/calendar/auth` as well, so the whole login
round-trip stays inside the mount point.
**The reverse proxy has to pass the prefix through, not strip it.** The app
answers on the prefixed paths, so:
```apache
ProxyPreserveHost On
ProxyPass /calendar/ http://127.0.0.1:8080/calendar/
ProxyPassReverse /calendar/ http://127.0.0.1:8080/calendar/
```
Note the `/calendar/` on both sides. Getting this wrong is the one likely
mistake, so the app watches for it: a request that arrives outside the mount
point gets a 404 saying which URL the calendar answers on and printing the
`ProxyPass` line it expects. A plain `GET /` redirects to `/calendar/`.
### How it works, and why the build is not involved
The obvious way to do this in a Vite app is `base: "/calendar/"` at build time.
That would be wrong here, because `frontend/dist` is committed to the repository
and shared between deployments - baking a prefix in would tie one build to one
mount point.
Instead the prefix is applied when a page is served:
- Vite is configured with `base: "./"`, so the built `index.html` refers to its
script, stylesheet and favicon relatively. Nothing in `dist` mentions an
absolute path.
- `index.html` ships with `<base href="/">` as the first thing in its `<head>`.
The backend rewrites that one attribute to the configured mount point on the
way out. The browser then resolves those relative URLs against it, which is
also what makes them work on a nested route such as `/calendar/e/AbCd2345`,
where resolving against the document's own directory would look for the
assets under `/calendar/e/`.
- The frontend reads the mount point back out of `document.baseURI` and puts it
in front of its own API calls, its client-side routes and its `pushState`
navigation. That lives in `frontend/src/lib/base.ts`.
So the same committed build serves both layouts, and switching between them is a
config edit and a restart.
One thing to keep in mind if you are editing the frontend: because `<base href>`
is set, **every relative URL in the page resolves against the mount point**, not
against the current route. Build paths with the helpers in `base.ts` rather than
writing `/api/...`, `/auth?login=/` or `/icon.png` by hand, or they will break
the moment somebody mounts the app in a sub-directory:
| Helper | For |
| --------------------- | ----------------------------------------------------- |
| `apiUrl("/events")` | an API endpoint |
| `appPath("/help")` | a client-side route, including `pushState` targets |
| `assetUrl("icon.png")`| a file shipped in the build |
| `loginUrl()` | the OAuth login link |
| `logoutUrl()` | the OAuth logout link |
| `withoutBase(path)` | turning `location.pathname` back into an app route |
The login and logout links are a special case worth knowing about. `/api/session`
returns the real URLs, because a deployment can move the endpoint with the
`oauth.uri` config key, and those always win. `loginUrl()` and `logoutUrl()` are
the fallbacks used before that request has come back or if it fails - which is
precisely when somebody is most likely to be reaching for the login link, so
they have to be right too.
### Developing against a mounted backend
The Vite dev server always serves the app at the root. If the backend you are
proxying to has a `base_path` set, tell the proxy where to find it:
```shell
BASE_PATH=/calendar npm run dev
```
For everyday work it is simpler to leave `base_path` empty in your local
`config.yaml` and test the sub-directory setup against the built frontend.
## Embedding the agenda elsewhere
The agenda view can be dropped into another site as an iframe: a project's own
website can carry a live list of its upcoming events without anybody copying
dates about by hand.
```html
<iframe
src="https://calendar.apache.org/embed/agenda?project=httpd&limit=8&title=Upcoming+httpd+events"
title="Upcoming httpd events"
width="100%"
height="420"
loading="lazy"
style="border: 1px solid #d9dee5; border-radius: 6px;"
></iframe>
```
The embed is a separate, much smaller app than the calendar: no header, no
filter panel, no editing, and it asks the backend for nothing but the events it
is going to draw. Clicking an event opens its shortlink in a new tab rather
than navigating inside the frame.
### What it shows
**Public events only, always.** Session cookies are `SameSite=Strict`, so a
cross-site iframe never carries one and the embed is an anonymous view. This is
the intended behaviour rather than a limitation to route around: a page on
somebody else's site should not be able to display private project or
foundation events, even to somebody who would be allowed to see them on the
calendar itself.
### Parameters
All optional, all on the query string.
| Parameter | Default | What it does |
| ----------------------- | ------- | -------------------------------------------------------------- |
| `project`, `projects` | all | project names; repeat the parameter or comma-separate |
| `category`, `categories`| all | `personal`, `project` or `foundation` |
| `q` | - | text search over title, description, location and project |
| `days` | `60` | how far ahead to look, 1 to 366 |
| `limit` | `20` | how many events to show, 1 to 100 |
| `zone` | `local` | `local`, or an IANA name such as `UTC` or `Europe/Berlin` |
| `title` | - | a heading above the list |
| `showzone` | `1` | whether to say which clock the times are on |
| `transparent` | `0` | drop the background so the host page shows through |
| `theme` | `auto` | `auto`, `light` or `dark`; `auto` follows the reader's setting |
| `credit` | `1` | whether to show the "ASF Community Calendar" link at the foot |
Numbers outside their range are clamped rather than rejected, and anything
unrecognised falls back to the default, so a mistyped URL degrades to a sensible
embed instead of an error.
### Sizing the frame
An iframe cannot size itself to its content. The embed measures itself and posts
its height to the page framing it, so the host can resize:
```html
<script>
window.addEventListener("message", (event) => {
// Check the origin: any page can post a message.
if (event.origin !== "https://calendar.apache.org") return;
if (event.data?.type !== "asf-calendar-embed:height") return;
document.querySelector("iframe.asf-calendar").style.height = `${event.data.height}px`;
});
</script>
```
The message carries nothing but a pixel height. It is sent with a `*` target
origin, because the embed has no way of knowing who is framing it, which is why
the listener above has to check `event.origin` itself.
If you would rather not run any script, give the iframe a fixed height and a
`limit` that fits it.
### Headers the deployment needs
The application does not set framing or CORS headers - that is the reverse
proxy's job, and deliberately so, since only the deployment knows who should be
allowed to embed it. Nothing in the app forbids framing, so there is nothing to
undo; what is needed is permission.
**To allow framing**, set `frame-ancestors` on the calendar's responses, and do
not send `X-Frame-Options: DENY` or `SAMEORIGIN`, which would override it in
older browsers:
```apache
# Who may embed the calendar. Be specific; 'self' alone blocks other sites.
Header always set Content-Security-Policy "frame-ancestors 'self' https://*.apache.org"
Header always unset X-Frame-Options
```
Scoping that to the embed route only, so the rest of the calendar stays
unframeable:
```apache
<Location "/embed/">
Header always set Content-Security-Policy "frame-ancestors 'self' https://*.apache.org"
Header always unset X-Frame-Options
</Location>
```
**CORS is not needed for the iframe embed.** A framed page fetches its own
origin, so no cross-origin request happens. You only need CORS if somebody wants
to call `/api/events` from their own JavaScript and render it themselves:
```apache
<Location "/api/">
Header always set Access-Control-Allow-Origin "https://example.apache.org"
Header always append Vary "Origin"
</Location>
```
Two things to get right there:
- **Do not send `Access-Control-Allow-Credentials: true`.** Combined with a
permissive origin it would let another site read a logged-in user's private
events. Anonymous cross-origin reads are the only thing that should work.
- **Send `Vary: Origin`** if the allowed origin varies, or a cache will hand one
site's response to another.
`Access-Control-Allow-Origin: *` is defensible here, since the anonymous API
returns only public events, but it is worth deciding deliberately rather than by
default.
### Checking it works
```shell
# The route serves the app
curl -sI https://calendar.apache.org/embed/agenda | head -1
# The data it will draw, as an anonymous caller sees it
curl -s 'https://calendar.apache.org/api/events?project=httpd&limit=5&sort=start' | jq '.events[].title'
# The framing header is present and permissive enough
curl -sI https://calendar.apache.org/embed/agenda | grep -i -e content-security-policy -e x-frame-options
```
If the frame comes up blank, look in the browser console: a `frame-ancestors`
refusal is reported there and nowhere else.
## Timezones
An event happens at one moment in time, but that moment reads differently on
different clocks. The app keeps those two things apart, and the distinction runs
all the way through:
- **Instants** are what the backend stores. Every event's start and end are
epoch seconds in UTC, and the API talks in ISO 8601 UTC. There is no ambiguity
about when an event actually is.
- **The event timezone** is what the organiser meant when they typed the times
in. It is stored alongside the event as an IANA name such as `Europe/Berlin`,
validated against the system tz database. An event form entry of "15:00" in
Berlin means 13:00 UTC in July and 14:00 UTC in January, and the app works
that out. Changing the timezone in the form keeps the clock reading and moves
the instant, which is what an organiser almost always means.
- **The display timezone** is the clock the reader wants the calendar drawn on.
The switch in the header has two sides: **Local**, which follows the browser
and is labelled with the place it resolves to, and a picker holding the major
timezones, which starts on UTC. Choosing either redraws everything - the grid
columns, the position of each block in the day, the times on each event, and
which day an event falls on. The choice is remembered in the browser;
`app.default_display_zone` decides what a first-time visitor gets, and takes
`local` or any IANA name.
- **Comparison timezones** are extra clocks shown next to the display one. Add
up to three under "Timezones" in the filter panel and they appear as extra
hour columns beside the week and day grids, after the time on each agenda
row, and in an event's details. A grid has one time axis, so these are labels
rather than extra columns of events - which is all you need to answer "what
time is that for me".
An event's details show the headline time on your clock, then a line per other
clock worth knowing about: the organiser's own zone, and each comparison zone
you have added. Clocks that read the same as yours are left out rather than
repeated.
Zone names such as `Europe/Copenhagen` are how the tz database identifies a
clock, not a claim about where anyone is sitting, so the UI shows the city
together with the current abbreviation and offset - "Copenhagen (CEST,
UTC+02:00)" - to make it obvious why that particular clock. Where a zone has no
letter abbreviation the offset stands on its own, since Intl would otherwise
just repeat it back as "GMT+9".
**All-day events are dates, not instants.** They are snapped to whole UTC days
and have no organiser timezone, exactly as iCalendar treats a `VALUE=DATE`
event. An all-day event on 14 March is on 14 March for everybody, and does not
move when the switch is flipped. Their stored end is exclusive: midnight of the
day after the last day.
In the iCalendar export, `DTSTART` and `DTEND` are always UTC, which every
client reads correctly. The organiser's zone rides along as an
`X-ASF-EVENT-TIMEZONE` property for anything that cares.
The display picker offers a short list of well-known zones rather than all ~400
the tz database knows, because it answers "show me this calendar in Tokyo time".
The event form still offers the full list, since an organiser really might be
anywhere.
The conversions live in `frontend/src/lib/timezone.ts`. The trick it uses is
worth knowing about if you go reading it: a "wall date" is an ordinary
JavaScript `Date` whose *local* getters read as the wall clock in some other
zone. Converting at the edges means the whole grid arithmetic works unchanged,
whichever clock is on display. Offsets for named zones come from
`Intl.DateTimeFormat`, so daylight saving is handled by the browser's own tz
data rather than by us.
## Importing an iCalendar file
A logged-in user can upload an `.ics` file and have every event in it added to
one calendar. **Import** appears next to **New event** once you are signed in.
The form works in two steps, on purpose. Choosing a file shows a preview of what
was found, and only then does it ask which calendar the events belong to. That
second question is not defaulted: an import can create dozens of events at once,
and a wrong default would publish somebody's private meetings or file them under
the wrong project. The Import button stays disabled, with a line saying what is
still to answer, until the calendar, the visibility and (for a project import)
the project have all been chosen.
Which options are offered follows the same rules as anywhere else: a committer
is shown their projects but not the private option, a committee member gets
both, and the foundation calendar only appears for foundation members.
### What the importer does with a file
Files come from Google Calendar, Outlook, Thunderbird and hand-rolled scripts,
so the reading is forgiving. Rather than refusing a whole upload over one
awkward entry, it repairs what it can and says what it changed - every note
appears in the preview before anything is saved:
| In the file | What happens |
| ---------------------------------------- | -------------------------------------------------------- |
| `DTSTART;TZID=Europe/Berlin` | kept as the event's organiser timezone |
| `DTSTART;VALUE=DATE` | an all-day event, whole UTC days |
| `DURATION` instead of `DTEND` | used to work out the end |
| no end at all | an hour, or a day for an all-day event |
| a floating time with no zone | read as UTC, with a note |
| a title longer than the limit | trimmed, with a note |
| a `mailto:` or other non-http `URL` | dropped, with a note |
| `RRULE` | imported as a single occurrence, with a note |
| `RECURRENCE-ID` (a moved occurrence) | imported as its own event, under the series' `UID` |
| `STATUS:CANCELLED`, or no `DTSTART` | skipped, and counted in the file's warnings |
| `VTODO`, `VJOURNAL` | ignored; only `VEVENT` is an event |
An event that survives all that still goes through exactly the same validation
as one typed in by hand, so nothing gets in through the importer that could not
have been created normally. The whole import is one transaction: every event is
validated and the permission checked before any of them is written, so a file
either lands completely or not at all.
A `UID` is therefore not unique within an import, and neither is a warning:
a series and each of its moved occurrences share one `UID`, and two entries can
raise word-for-word the same note. Nothing that renders or stores a preview may
treat either as an identifier.
At most 200 events and 1 MiB per file.
### From a script
Two endpoints, both needing a session. `POST /api/import/preview` reads a file
and says what is in it without writing anything; `POST /api/import` does the
import. Either takes the file as the `file` part of a multipart form, or as the
whole request body:
```shell
# What is in this file?
curl -s -X POST --data-binary @events.ics \
-H 'Content-Type: text/calendar' -H 'X-No-Redirect: 1' \
-b cookies.txt https://calendar.apache.org/api/import/preview | jq
# Import it as public httpd events
curl -s -X POST -b cookies.txt -H 'X-No-Redirect: 1' \
-F file=@events.ics -F category=project -F project=httpd -F visibility=public \
https://calendar.apache.org/api/import
```
When the file is the request body there is nowhere to put the form fields, so
they go on the query string instead:
```shell
curl -s -X POST --data-binary @events.ics \
-H 'Content-Type: text/calendar' -H 'X-No-Redirect: 1' -b cookies.txt \
'https://calendar.apache.org/api/import?category=project&project=httpd'
```
`category` is required. `visibility` defaults to `public`, the same as
`POST /api/events` - the API keeps the single-event default, and it is the form
that insists on an explicit answer. The response carries the created events and
any warnings.
## The API
Everything under `/api` speaks JSON, errors included. Anonymous reads are
allowed; anything that writes needs a session.
| Method | Path | What it does |
| ----------------- | ------------------------ | --------------------------------------------------- |
| GET | `/api/session` | who is logged in, plus login and logout URLs |
| GET | `/api/calendars` | which calendars this session can read and write |
| GET | `/api/events` | list events (see the filters below) |
| POST | `/api/events` | create an event |
| GET | `/api/events/<id>` | one event |
| PUT, PATCH | `/api/events/<id>` | replace an event |
| DELETE | `/api/events/<id>` | delete an event |
| GET | `/api/events/<id>.ics` | one event as iCalendar |
| GET | `/api/events.ics` | a filtered iCalendar feed |
| GET | `/api/shortlink/<token>` | look an event up by its shortlink token |
| POST | `/api/import/preview` | read an `.ics` file without saving anything |
| POST | `/api/import` | turn an `.ics` file into events |
| GET | `/api/healthz` | liveness check |
Outside `/api`, the backend serves the built frontend: `/` for the calendar,
`/help` for the help page, and `/e/<token>` for an event shortlink. All three
are client-side routes, so the backend answers them with the same SPA shell.
`GET /api/events` accepts:
| Parameter | Meaning |
| ----------------------- | -------------------------------------------------------------- |
| `start`, `end` | ISO 8601 or epoch seconds; returns events overlapping `[start, end)` |
| `category`, `categories`| `personal`, `project` or `foundation`; repeat or comma-separate |
| `project`, `projects` | project names; repeat or comma-separate |
| `visibility` | `public` or `private` |
| `owner` | a uid, or `me` |
| `q` | text search over title, description, location and project |
| `sort` | `start`, `-start`, `title`, `-title`, `created`, `-created` |
| `limit`, `offset` | paging; `limit` is capped at 2000 |
A minimal create:
```shell
curl -X POST http://localhost:8080/api/events \
-H 'Content-Type: application/json' \
-H 'X-No-Redirect: 1' \
-b cookies.txt \
-d '{
"title": "Release party",
"category": "project",
"project": "httpd",
"visibility": "public",
"start": "2026-04-01T17:00:00Z",
"end": "2026-04-01T19:00:00Z"
}'
```
Timestamps go in and come out as ISO 8601 in UTC. Epoch seconds are accepted on
the way in. All-day events are snapped to whole UTC days, and their end is
exclusive, matching iCalendar's `DTEND`.
An event may also carry a `timezone`, an IANA name such as `"Europe/Berlin"`,
recording the clock the organiser entered the times on. It defaults to `"UTC"`,
is validated against the tz database, and is forced to `"UTC"` for all-day
events.
It has one effect on parsing: a timestamp sent with **no** offset is read in the
event's timezone rather than in UTC, so the obvious thing works.
```
{"start": "2026-07-10T15:00:00", "timezone": "Europe/Berlin"} -> 13:00Z
{"start": "2026-01-10T15:00:00", "timezone": "Europe/Berlin"} -> 14:00Z
{"start": "2026-07-10T15:00:00Z", "timezone": "Europe/Berlin"} -> 15:00Z
{"start": "2026-07-10T15:00:00+09:00","timezone": "Europe/Berlin"} -> 06:00Z
```
A timestamp that says what offset it is in, or ends in `Z`, is always taken at
face value. Epoch seconds are unaffected. The frontend always sends an absolute
UTC instant, so this only matters to anything talking to the API directly.
`X-No-Redirect: 1` matters. Without it, asfquart answers an unauthenticated
request by redirecting to the OAuth provider, which is right for a browser
following a link and useless for a `fetch()` or a `curl`. With it you get a JSON
401. The frontend sends it on every request.
Errors look like this, with `field` present when a particular field was at
fault:
```json
{ "error": "'end' must be after 'start'", "field": "end" }
```
The table above is a summary. The full reference - every parameter, every
schema, every status code - is generated from the running service and described
in the next section.
## API documentation
The API describes itself in [OpenAPI](https://spec.openapis.org/oas/v3.1.0)
3.1, and the calendar serves an interactive Swagger UI for it.
| Where | What |
| ------------------------ | ------------------------------------------------------ |
| **API** button, or `/docs` | Swagger UI, with "try it out" against this deployment |
| `/api/openapi.json` | the description, for tooling |
| `/api/openapi.yaml` | the same thing, for reading |
```shell
curl -s https://calendar.apache.org/api/openapi.json | jq '.paths | keys'
# Generate a client
npx @openapitools/openapi-generator-cli generate \
-i https://calendar.apache.org/api/openapi.json -g python -o ./client
```
The `servers` entry is filled in from the host serving the document, including
the mount point when the app is [in a
sub-directory](#serving-from-a-sub-directory), so "try it out" calls the
deployment the reader is already on. Paths in the document itself stay
mount-point free, so one description covers either layout.
Requests from the docs page carry `X-No-Redirect: 1` and same-origin
credentials, so an unauthenticated call returns a readable 401 rather than a
redirect, and a logged-in reader can exercise the endpoints that need a session.
### How it is kept from going stale
A hand-written API description rots quietly: someone adds a route, nobody
updates the YAML, and six months later the docs are lying. This one is built in
`backend/asfcalendar/openapi.py`, in Python, out of the same constants the code
enforces - `CATEGORIES`, `VISIBILITIES`, `MAX_TITLE_LENGTH`, `SORT_COLUMNS`,
`MAX_LIMIT`, the project name pattern, the shortlink alphabet. Raise a field
limit and the published schema follows with nothing to remember.
What cannot be derived - prose, and which status codes an endpoint returns - is
written out by hand, so `backend/tests/test_openapi.py` guards it instead:
- **Every route is documented, and nothing is documented that does not exist.**
Both directions are checked against the application's real `url_map`, so
adding an endpoint without describing it fails the build, and so does leaving
a description behind after deleting one.
- **The `Event` schema matches `Event.to_json()`**, field for field. A new field
on the dataclass that nobody documented fails too.
- **Nothing marked `readOnly` is accepted as input**, and the writable schema is
a subset of the readable one.
- **The document validates** against `openapi-spec-validator`, at the root and
in a sub-directory, both as built and as served.
So the answer to "is this up to date?" is that CI will not let it be otherwise.
When you change the API, expect a test to tell you what you forgot.
### Swagger UI is vendored, not fetched
`swagger-ui-dist` is copied into `frontend/public/vendor/` by
`scripts/vendor-swagger.mjs`, which runs automatically before `npm run dev` and
`npm run build`. Nothing is loaded from a CDN, which matters for a deployment
with a strict `Content-Security-Policy`.
It is a static asset rather than a bundled import on purpose. It keeps 1.5MB of
third-party JavaScript out of the app bundle and its source map - the calendar
itself is still about 110KB - and, because `frontend/dist` is committed, it
means those files only change when Swagger UI is upgraded rather than being
rewritten by every UI change. The cost is that `dist` is about 2.6MB rather than
750KB, most of it in `dist/vendor/`, added to git once.
To upgrade: `npm install -D swagger-ui-dist@latest && npm run vendor` in
`frontend/`, then rebuild.
## Project layout
```
backend/
asfcalendar/
__main__.py command line entry point, starts hypercorn
app.py builds the Quart app, static files, shortlink pages
api.py the /api blueprint
permissions.py the access rules, as pure functions
storage.py SQLite, including the visibility SQL
models.py the Event type and payload validation
ics.py iCalendar output
icsimport.py reading an uploaded iCalendar file
openapi.py the OpenAPI description, built from the real constants
shortlink.py shortlink tokens
config.py reading config.yaml
tests/ pytest suite
frontend/
src/
App.svelte state, loading and routing
components/ Header, TimezoneSwitch, FilterPanel, the five views,
EventDialog, ImportDialog, HelpPage, EmbedAgenda, ApiDocs
lib/
api.ts the API client
base.ts the deployment's mount point, for sub-directory installs
dates.ts date arithmetic and grid maths
timezone.ts wall-clock conversions, zone naming, the zone picker
embed.ts options for the embeddable agenda
importing.ts where uploaded events should land
events.ts grouping, overlap layout, colours
filters.ts client-side filtering and sorting
drafts.ts new and edited events, and a local canEdit
types.ts shared types
tests/ component tests and fixtures
public/vendor/ Swagger UI, copied from node_modules at build time
scripts/ that copy step
config.yaml.example documented configuration
images.png the site logo; copied to frontend/public/icon.png
```
## Tests
### Backend
```shell
uv run pytest # the whole suite
uv run pytest -k permissions # one area
uv run pytest --cov=backend/asfcalendar --cov-report=term-missing
uv run mypy # strict type checking
uv run ruff check backend # lint
```
The suite covers the access rules exhaustively, the storage layer including the
agreement between the SQL filter and `can_view`, payload validation, the
iCalendar output, the OpenAPI description (see
[above](#how-it-is-kept-from-going-stale)), and the HTTP API end to end through
Quart's test client.
Sessions in the API tests are set through the real signed session cookie rather
than by patching, so they take the same path asfquart does in production. The
personas live in `backend/tests/conftest.py`:
| Persona | Projects | Committees | Member |
| ------- | -------------- | ---------- | ------ |
| alice | httpd, tomcat | - | no |
| bob | httpd | httpd | no |
| carol | tomcat | tomcat | yes |
| dave | maven | - | no |
Between them they cover committer without committee, committee member,
foundation member, and complete outsider.
### Frontend
```shell
cd frontend
npm run test # vitest, once
npm run test:watch # vitest, watching
npm run check # svelte-check: types across .ts and .svelte
npm run coverage
```
The date and layout maths, the timezone conversions, the filters, the draft
handling and the API client are tested as plain functions. The components are
rendered into jsdom with Testing Library and driven the way a user would drive
them.
The suite pins `TZ` to UTC so assertions about local time are stable;
`src/tests/setup.ts` does that. That would hide any bug in the display-zone
switch, since local and UTC agree there, so `src/lib/events.zone.test.ts` puts
the browser in Asia/Tokyo for its duration and checks that events move to the
right day, land in the right place in the grid, and that all-day events do not
move at all.
### CI
Three GitHub workflows, all path-filtered so a backend change does not start the
frontend jobs and vice versa:
- `.github/workflows/backend.yml` runs ruff, `ruff format --check`, mypy, and
pytest on Python 3.11, 3.12 and 3.13, then boots the real server and calls it.
- `.github/workflows/frontend.yml` runs svelte-check and vitest on Node 20 and
22, builds the UI, and uploads `frontend/dist` as an artifact. This is the one
that runs on pull requests.
- `.github/workflows/build-dist.yml` rebuilds `frontend/dist` on pushes to the
default branch and commits the result back if it differs. See below.
### Committed build output
`frontend/dist` is tracked in git rather than ignored, so that deploying the app
needs nothing but a checkout and Python. Keeping it honest by hand would be
tedious and easy to forget, so `build-dist.yml` does it: on every push that
touches `frontend/` it runs `npm ci && npm run build`, compares the result with
what is committed, and pushes a "Rebuild frontend/dist" commit when they differ.
When they match, which is the usual case for a backend-only or docs change, it
does nothing.
Some details that matter if you are changing that workflow:
- **It cannot set itself off.** GitHub does not start new workflow runs from
commits pushed with `GITHUB_TOKEN`. The `!frontend/dist/**` path exclusion is
a second line of defence, for the day somebody swaps in a PAT.
- **The build is reproducible.** Vite names its output by content hash, so an
unchanged source tree produces byte-identical files and the "did anything
change" check is trustworthy rather than approximate.
- **Stale assets are removed.** The commit uses `git add -A`, so the previous
hashed filenames go away instead of accumulating.
- **Concurrent pushes are handled.** Runs are serialised per branch and an
in-flight build is cancelled by a newer push, since its output is obsolete
anyway. If a push is still rejected because the branch moved, the workflow
replays the build onto the new tip, up to three times.
You will get merge conflicts in `frontend/dist` if two branches both change the
UI. Resolve them by rebuilding rather than by editing: `npm run build` in
`frontend/`, then `git add frontend/dist`. Or simply take either side and let CI
correct it on the next push to the default branch.
The build includes a source map, which is most of the roughly 750 KB in `dist`
and changes whenever the UI does. It is there because it makes production
problems debuggable. If you would rather not carry it in the history, set
`sourcemap: false` in `frontend/vite.config.ts`.
## Debugging
**Nothing but a page saying there is no frontend build.** The backend could not
find `frontend/dist/index.html`. It is committed to the repository, so this
usually means a `make clean`, a stray `rm -rf`, or a checkout of a branch from
before it was tracked. Run `npm run build` in `frontend/`, or use the Vite dev
server and open port 5173 instead of 8080. The page tells you which path it
looked in.
**The deployed UI is not the code you just merged.** `frontend/dist` is
committed, and the `Build dist` workflow updates it on pushes to the default
branch. Check that workflow ran and pushed its commit; a deployment from before
that commit will still be serving the previous build.
**The UI loads but every request fails.** Open the browser's network tab. If
`/api/session` returns HTML, the Vite proxy is not reaching the backend; check
the backend is running and that `BACKEND` points at it. If it returns a 502, the
backend crashed - look at its terminal.
**Login redirects to `oauth.apache.org` and comes back to the wrong host.**
asfquart builds the callback URL from the `Host` header. Behind a proxy, set
`ProxyPreserveHost On`. Locally, use the Vite dev server rather than opening the
backend port directly, so everything is on one origin.
**Login appears to work but you are still logged out.** Session cookies are set
`Secure`, `HttpOnly` and `SameSite=Strict`, so the browser will not store them
over plain http on a non-localhost host. In development use localhost; in
production terminate TLS in front of the app. `Secure` is relaxed only when the
app is constructed with `testing=True`, which is for the test suite.
**An API call redirects to `/auth` instead of returning JSON.** Send
`X-No-Redirect: 1`. asfquart's redirect-to-login behaviour is deliberate for
browsers and unhelpful for scripts.
**A 403 with a message about a project or committee.** That is the access rules
working. `GET /api/session` shows what asfquart thinks you are a member of, and
`GET /api/calendars` shows what that entitles you to. If the lists look wrong,
the problem is in LDAP or the OAuth response, not here; logging out and back in
picks up changed affiliations.
**Wanting to see the rules decide something in isolation.** They are pure
functions and take a plain object:
```python
from asfquart.session import ClientSession
from asfcalendar.permissions import can_view, can_write
session = ClientSession({"uid": "bob", "pmcs": ["httpd"], "projects": ["httpd"]})
can_view(some_event, session)
```
**An import says "No events could be read from that file".** Either it is not
iCalendar, or every entry in it was skipped. Run it through
`POST /api/import/preview`, whose `warnings` say which: entries with no
`DTSTART`, and ones marked `STATUS:CANCELLED`, are counted there.
**An imported event is an hour out.** Look at its `DTSTART` in the file. A time
with no `Z` and no `TZID` is "floating" and is read as UTC, which the preview
warns about; the fix is in whatever produced the file.
**The API documentation page is blank, or says Swagger UI could not be loaded.**
The vendored files are missing from the build. Run `npm run build` in
`frontend/`, which copies them in first, and check
`curl -sI https://your.host/vendor/swagger-ui-bundle.js` comes back 200. If the
browser console reports a CSP violation instead, the deployment's
`script-src`/`style-src` needs to allow the calendar's own origin.
**A test fails saying an endpoint is "missing from openapi.py".** That is the
anti-drift check doing its job: a route was added or changed without updating
the description. The message names the method and path; add it to
`_paths()` in `backend/asfcalendar/openapi.py`.
**An embedded agenda shows an empty frame.** Open the browser console on the
*host* page. A `frame-ancestors` refusal is reported there and nowhere else, and
no request reaches the calendar at all. See
[Embedding the agenda elsewhere](#embedding-the-agenda-elsewhere) for the header
the deployment needs.
**An embedded agenda is missing events somebody expects to see.** It is an
anonymous view by design: `SameSite=Strict` session cookies are not sent to a
cross-site frame, so only public events appear. Compare with
`curl -s '.../api/events?...'` with no cookie, which sees exactly what the embed
sees.
**Under a sub-directory, the page loads but is unstyled and blank.** The browser
is fetching the assets from the wrong place. Look at the served HTML: the
`<base href>` should be `/calendar/`, matching `server.base_path`. If it says
`/`, the config did not take effect; if the assets 404, the proxy is probably
stripping the prefix, which the section above covers.
**Under a sub-directory, everything 404s with a message about `ProxyPass`.**
That is the app telling you the request arrived outside its mount point. Either
`server.base_path` does not match what the proxy sends, or the proxy is
rewriting `/calendar/x` to `/x`. The message prints the directive it expects.
**Shortlinks come out as `/calendar/calendar/e/...`.** Older configs put the
path in `server.base_url`. Only the scheme and host are read from it now, so
this should not happen; if it does, the `base_path` value itself has the prefix
twice.
**The comparison hour columns are an hour out later in the week.** A week view
shares one set of hour gutters across all seven days, so the labels are computed
for the first day shown. If one of the two zones changes for daylight saving
mid-week, the rest of that week reads an hour off. The day view, which has one
column, is always exact.
**An event shows up on the wrong day.** Check the timezone switch in the header.
An event at 22:00 UTC is the following morning in Tokyo, and the calendar will
correctly put it there when the browser's clock is on display. If the event is
all-day, it should not move at all; if it does, that is a bug worth reporting.
**An event was posted at the wrong time.** The form's times are in the timezone
picked in the form, which is not necessarily the one the calendar is being
displayed in. The event's details show both once it is saved.
**`'Europe/Berlin' is not a known timezone`.** The Python process cannot find a
tz database. The `tzdata` package is a dependency for exactly this reason, so
`uv sync` should fix it; on a system with its own zoneinfo it is not used.
**`database is locked`.** SQLite allows one writer at a time. It should not
happen with a single worker; if it does, something else has the file open - a
second copy of the app, or a `sqlite3` shell.
**Changes to Python files are ignored.** Start with `--reload`. Note that
`app.runx()`, asfquart's own watcher, is not what this app uses: asfquart 0.1.12
restarts by calling `quart.utils.restart()`, which newer Quart releases renamed
to `run_reloader`, so the process dies on the first reload. `--reload` uses
hypercorn's reloader instead.
**More logging.** `-v` turns on DEBUG for everything, `--access-log` logs
requests. `--debug` additionally puts Quart in debug mode, which returns
tracebacks in HTTP responses; that is for local use only.
**Looking directly at the data.**
```shell
sqlite3 calendar.sqlite3 'SELECT id, shortlink, category, visibility, project, title FROM events;'
```
**Everybody suddenly logged out.** `apptoken.txt` was deleted or replaced, so
existing session cookies no longer verify. Expected; they just need to log in
again.
## Things it does not do
Worth being clear about, so nobody goes looking:
- No recurring events. Every event is a single occurrence, and a repeating
event in an imported file arrives as one.
- No invitations, attendance or reminders. It is a calendar, not a scheduler.
- No per-event access lists. Access follows the category and visibility, and
nothing else.
- No per-viewer timezone beyond the local/UTC switch. You cannot ask to read the
calendar in a third zone that is neither yours nor UTC.
- One process only, because of the [OAuth state limitation](#use-one-worker).
## Licence
Apache License 2.0.