# calembed

Embeddable calendar service at `https://calembed.calee.com.au`.

## Public calendar links, Open Graph & WhatsApp preview

A shared public calendar link:

```
https://calembed.calee.com.au/public-calendar.php?base=<base>&token=<token>[&year=YYYY&month=MM]
```

- renders **server-side Open Graph / Twitter metadata** (title, description and a
  1200×630 PNG preview) so WhatsApp, iMessage, Slack, etc. show a rich card;
- still opens the existing interactive calendar in an iframe when tapped.

`base` is one of `portal`, `business`, `cewa` (defaults to `portal` when
omitted). `token` must match `^[A-Za-z0-9_-]{8,128}$`. The public URL contract
is unchanged.

### Endpoints

| Endpoint | Purpose |
| --- | --- |
| `public-calendar.php?base=&token=` | Public landing page + Open Graph metadata + iframe. |
| `calendar-preview.php?base=&token=[&year=&month=]` | 1200×630 `image/png` preview card (the `og:image`). |
| `calendar-embed.php?ics=<url>` | Existing interactive viewer (legacy `?ics=` route, now strictly re-validated). |

Only `base` + `token` are accepted by the public page and the preview endpoint;
the upstream ICS URL is constructed internally and never taken from the request.
The legacy `calendar-embed.php?ics=` route still works but the URL is strictly
re-validated (HTTPS only, exact allowed host, exact `/remote.php/dav/public-calendars/{token}?export`
path/query, no credentials/port).

### Open Graph behaviour

- Metadata is emitted in the initial server response (never via JavaScript) and
  escaped with `htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`.
- All metadata URLs are absolute HTTPS built from the **configured canonical
  origin** (`https://calembed.calee.com.au`), never from `Host` /
  `X-Forwarded-Host`.
- Tags: `og:type`, `og:site_name`, `og:title`, `og:description`, `og:url`,
  `og:image` (+ `:secure_url`, `:type`, `:width`, `:height`, `:alt`),
  `twitter:card=summary_large_image`, `twitter:title/description/image`.
- The preview never shows event descriptions, attendees, notes or locations.
  Short event **summaries** are **off by default** and only appear when
  `CALEE_PREVIEW_SHOW_SUMMARIES` is enabled (`1`/`true`/`yes`/`on`); otherwise
  the card shows markers/dates and generic "Event" rows.
- The `og:image` URL is content-versioned with `&v=<short-fingerprint>-r<renderer-version>-s<privacy-state>`,
  so a changed calendar — or toggling summary privacy — yields a new image URL
  that link-preview crawlers refetch.
- A revoked / nonexistent calendar returns **404**; a temporary upstream failure
  returns a safe generic card (no internal error text is ever exposed). The
  preview endpoint also sets a non-leaking `X-Calee-Preview-State` diagnostic
  header.

### Requirements (production host)

- **PHP** ≥ 8.1 (deploy targets `php8.3-fpm`).
- **cURL** — ICS fetching is cURL-only (no `file_get_contents` fallback).
- **mbstring** (required) — for correct text sizing/truncation. Byte fallbacks
  keep endpoints from fataling without it, but the runtime preflight **fails**
  until it is installed.
- **iconv** (recommended) — for UTF-8 scrubbing. Guarded: falls back to
  mbstring/bytes if absent (reported by the preflight, not a hard failure).
- **GD with FreeType** (`imagettftext`) — for the PNG preview. If GD is missing,
  a committed static branded PNG (`assets/calendar-preview-fallback.png`) is
  served instead; if only FreeType/the font is missing, a bitmap-font card is
  drawn.
- A **TrueType font** — DejaVu Sans by default (`fonts-dejavu-core`), or set
  `CALEE_PREVIEW_FONT`.

Install the PHP 8.3 packages and fonts on the production host:

```bash
sudo apt-get install -y \
  php8.3-cli php8.3-fpm php8.3-curl php8.3-mbstring php8.3-gd \
  fonts-dejavu-core
# php8.3-gd is built with FreeType on Debian/Ubuntu; iconv ships with PHP core.
```

Then run the preflight to confirm all of the above on the target host:

```bash
php tools/check-calendar-runtime.php
```

### Cache directory

ICS content and rendered previews are cached in a file cache **outside the web
root** (so `deploy.sh`'s `rsync --delete` cannot wipe or expose it):

- Default: `/var/cache/calee-calembed` (override with `CALEE_CALENDAR_CACHE_DIR`).
- Must be writable by `www-data`. `deploy.sh` creates it (`chown www-data:www-data`,
  `chmod 750`). Caching is best-effort: if the directory is unavailable the site
  still works, just uncached.
- Cache keys are SHA-256 hashes of at least `base + token` (raw tokens never
  appear in filenames); writes are atomic (temp file + `rename`); nonexistent
  calendars are negatively cached briefly.
- **Locking uses a fixed, bounded set of striped lock files** rather than one
  lock file per cache key. Each `namespace + key` hashes to a stripe in
  `[0, CALEE_CALENDAR_CACHE_LOCK_STRIPES)` and always uses the same stable
  **`lock-stripe-NNNN.lock`** pathname (a **fixed 4-digit width**, e.g.
  `lock-stripe-0005.lock`, regardless of the currently configured stripe count —
  see below); `flock()` therefore always operates on the same inode. Because
  these files are **never unlinked** during a request or by GC, there is no
  unlink/inode race (two processes can never end up holding different inodes for
  the same logical lock), and distinct tokens can no longer create unbounded
  lock files — at most `CALEE_CALENDAR_CACHE_LOCK_STRIPES` lock files can ever
  exist. Different keys may share a stripe and serialize briefly, which is
  acceptable. The lock still prevents duplicate upstream fetches and duplicate
  preview renders for the same key.
- **First striped-lock deployment (and any `CALEE_CALENDAR_CACHE_LOCK_STRIPES`
  change) requires a full PHP-FPM restart, never a graceful reload.**
  `deploy.sh` uses `systemctl restart` (not `reload`) for exactly this reason: a
  graceful reload lets old workers keep serving requests while new ones start,
  so an old worker (which may still use the pre-striped-lock, per-key scheme —
  or, after a stripe-count change, a different stripe mapping) could
  concurrently fetch/render the *same* calendar as a new worker without either
  seeing the other's lock. `deploy.sh` fails the deployment if the restart
  doesn't succeed or the service isn't active afterward (see
  `tools/check-calendar-deploy.php`). The stripe filename's fixed width exists
  for the same reason: it guarantees a given stripe index maps to the same
  filename no matter what stripe count is currently configured, so even a
  restart-discipline violation can't split one logical stripe across two paths.
  Old, now-unused stripe files left over from a previous (higher) stripe count
  are harmless to retain — GC never removes any stripe-lock file.
- **Garbage collection** is automatic and safe: stale entries are deleted on
  read, and a low-probability opportunistic sweep — on cache writes **and on
  lock release** (so a failed upstream load that only created/held a lock still
  triggers cleanup) — removes content/temp entries older than
  `CALEE_CALENDAR_CACHE_MAX_AGE`, and, when the cache exceeds
  `CALEE_CALENDAR_CACHE_MAX_BYTES` or `CALEE_CALENDAR_CACHE_MAX_FILES`, evicts
  the oldest **content** files first. GC **never unlinks a lock file** (stripe or
  legacy) — the effective file cap is *content/temp files + the fixed stripe-lock
  population*, and content eviction is age/recency-based so an in-flight (freshly
  renamed) entry is never the target. GC never follows symlinks and never breaks
  rendering on failure. Token spraying therefore cannot grow the cache without
  bound. (If `CALEE_CALENDAR_CACHE_MAX_FILES` is set below
  `CALEE_CALENDAR_CACHE_LOCK_STRIPES`, the stripe files are kept and the runtime
  preflight warns — raise the file cap.)
- A deterministic cleanup command runs one GC pass and prints **only** aggregate
  counts/bytes (no cache path, filenames, keys or tokens). It exits `0` on a
  successful pass and `2` if the cache directory cannot be inspected (distinct
  from an empty cache, which is still `0`):

  ```bash
  php tools/cleanup-calendar-cache.php
  ```

  Run it from cron or a systemd timer as `www-data` for belt-and-braces cleanup,
  e.g. crontab: `*/15 * * * * www-data /usr/bin/php /var/www/calendar-embed/tools/cleanup-calendar-cache.php >/dev/null 2>&1`.
- **Legacy per-key lock migration.** Deployments that pre-date striped locks may
  still contain old `<namespace>-<hash>.lock` files. New code never creates them,
  and request-time GC ignores them (never unlinks them) so a still-running old
  worker's `flock` stays safe. They are empty and finite, so leaving them
  permanently is a safe option. To reclaim them in one maintenance step **after a
  full PHP-FPM restart onto this version** (no old workers running, no deploy in
  progress), run:

  ```bash
  php tools/cleanup-calendar-cache.php --purge-legacy-locks
  ```

  Without that explicit flag the command never deletes legacy lock files, and it
  never touches striped locks.

### Environment variables

| Variable | Default | Purpose |
| --- | --- | --- |
| `CALEE_CALENDAR_CACHE_DIR` | `/var/cache/calee-calembed` | File cache location (outside the web root). |
| `CALEE_CALENDAR_CACHE_TTL` | `300` | Positive cache TTL (seconds) for ICS + previews. |
| `CALEE_CALENDAR_NEGATIVE_TTL` | `60` | Short negative cache TTL for nonexistent calendars. |
| `CALEE_CALENDAR_CACHE_MAX_AGE` | `86400` | Max age (seconds) any cache file may reach before GC removes it. |
| `CALEE_CALENDAR_CACHE_MAX_BYTES` | `268435456` | Max total cache size (bytes); GC evicts oldest first past this. |
| `CALEE_CALENDAR_CACHE_MAX_FILES` | `10000` | Max total cache files (content/temp **plus** the fixed stripe-lock population). GC evicts oldest **content** first; lock files are counted but never evicted. |
| `CALEE_CALENDAR_CACHE_LOCK_STRIPES` | `256` | Number of fixed striped lock files (`lock-stripe-NNNN.lock`, fixed 4-digit width). Clamped to `[16, 4096]`. Bounds the lock-file population regardless of distinct tokens. **Changing this requires a full PHP-FPM restart** (see Cache directory above) — never a rolling/graceful reload. |
| `CALEE_CALENDAR_CACHE_GC_PROBABILITY` | `0.01` | Probability (0–1) that a cache write **or lock release** triggers opportunistic GC. |
| `CALEE_CALENDAR_TIMEZONE` | `UTC` | Display-timezone fallback after a valid calendar `X-WR-TIMEZONE` (see Timezone below). |
| `CALEE_CALENDAR_MAX_ICS_BYTES` | `5242880` | Max accepted ICS response size (bytes). |
| `CALEE_CALENDAR_MAX_EVENTS` | `20000` | Max VEVENTs parsed; exceeding it fails cleanly (no silent truncation). |
| `CALEE_CALENDAR_MAX_RECURRENCE_WORK` | `200000` | Global recurrence-expansion work budget per calendar (one unit per candidate inspected). |
| `CALEE_CALENDAR_MAX_RECURRENCE_ITERATIONS` | `10000` | Per-event recurrence loop guard. Exhaustion is a controlled failure (never partial data), not a silent truncation. |
| `CALEE_CALENDAR_MAX_OCCURRENCES` | `1000` | Max occurrences from expanding one recurring event; exceeding it fails cleanly. |
| `CALEE_PREVIEW_SHOW_SUMMARIES` | `false` | Show short event summaries in previews (`1`/`true`/`yes`/`on`); default hides them. |
| `CALEE_PREVIEW_FONT` | — | Path to a regular `.ttf`; falls back to system DejaVu Sans. |
| `CALEE_PREVIEW_FONT_BOLD` | — | Path to a bold `.ttf` (optional). |
| `CALEE_CALENDAR_INTENT_SECRET` | — | HMAC secret for the "Open in Calee app" handoff (unchanged). |

### Timezone

The code's deterministic fallback is **UTC**. In production, set

```
CALEE_CALENDAR_TIMEZONE=Australia/Perth
```

unless every upstream ICS feed reliably provides `X-WR-TIMEZONE`. A valid event
`TZID` and a valid calendar `X-WR-TIMEZONE` still take precedence; this variable
only fills the gap for feeds that specify neither. (Australia/Perth is **not**
hard-coded in the parser — it is configuration.)

### Recurrence handling

The supported RRULE subset is `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL`,
`COUNT`, `UNTIL`, `BYDAY`, `BYMONTHDAY`, plus `EXDATE`/`RDATE`. Expansion
fast-forwards to the requested window with closed-form arithmetic (no historical
walk), and:

- **Invalid calendar dates are skipped, never clamped.** `BYMONTHDAY=31` occurs
  only in months that have a 31st (it skips Feb/Apr/Jun/Sep/Nov); a Feb-29 rule
  (monthly `BYMONTHDAY=29` or yearly) occurs only in leap years, using the full
  Gregorian rule (2096 leap, 2100 not, 2400 leap). Negative `BYMONTHDAY`
  (`-1…-31`) counts back from month end; `BYMONTHDAY=0` or `|value|>31` is
  treated as never-matching.
- **`MONTHLY` `INTERVAL` is measured in months** everywhere (including the
  leap-February fast-forward counting), so `INTERVAL=12`/`24`/`36` on Feb 29 land
  on the correct leap years and `COUNT` is not exhausted early.
- **`COUNT` counts only valid occurrences**, so a bounded series still yields
  exactly `COUNT` real dates regardless of skipped invalid positions.
- **Natural termination is checked before charging a guard/budget unit,
  whenever it can already be determined without inspecting a candidate** — the
  cursor/week/month/year already past `rangeEnd`, past `UNTIL`, or `COUNT`
  already reached. This is what makes exact-limit series work correctly: e.g. a
  `COUNT=2` daily series with `CALEE_CALENDAR_MAX_RECURRENCE_ITERATIONS=2` and
  `CALEE_CALENDAR_MAX_RECURRENCE_WORK=2` succeeds with exactly two occurrences,
  rather than spuriously entering a third charged pass only to discover the
  series had already ended.
  - **This does not mean charging (and possible exhaustion) never happens
    before a natural end.** A position that must actually be *inspected* to
    determine its outcome still consumes one iteration and/or one work unit,
    because inspection was required to learn the outcome — and if the guard or
    budget is exhausted at that point, expansion still throws `too_large`,
    exactly as it does for a genuinely unbounded series. Concretely: an invalid
    monthly/yearly date (e.g. Feb 31, or Feb 29 in a non-leap year) charges one
    iteration and one work unit even though it yields no occurrence, because
    resolving it was the only way to discover it was invalid; an `EXDATE`d
    candidate (any frequency) charges its one work unit for the same reason;
    and `WEEKLY`'s very first emitted week (week 0 only — never a later week,
    since `UNTIL`/`COUNT` are pre-checked against each week's earliest possible
    candidate, and that check is exact from week 1 onward) charges one
    iteration even if DTSTART falls after every `BYDAY` position in that week,
    since it must be entered to discover that. `COUNT`/`UNTIL`/range endings
    are otherwise exact (no wasted charge) for DAILY, MONTHLY and YEARLY, and
    for WEEKLY from the second emitted week onward. The exact-boundary
    self-tests (`check-calendar-recurrence.php`) enumerate this per frequency
    (DAILY, WEEKLY, MONTHLY, YEARLY) rather than asserting a blanket "never
    throws before the natural end" — that stronger claim is not actually true
    for positions that must be inspected to be ruled out, by design.
- **Guards fail closed.** The per-event iteration guard
  (`CALEE_CALENDAR_MAX_RECURRENCE_ITERATIONS`), the per-event occurrence cap
  (`CALEE_CALENDAR_MAX_OCCURRENCES`) and the shared per-calendar work budget
  (`CALEE_CALENDAR_MAX_RECURRENCE_WORK`, **one unit per candidate actually
  inspected, none for appending**) each raise a controlled `too_large` error on
  exhaustion and **never** return a partial occurrence set.
  `BYSETPOS`/`BYMONTH`/`BYWEEKNO`/`BYYEARDAY` and numeric `BYDAY` prefixes are
  not expanded.

### Local checks

```bash
# Syntax
find . -name '*.php' -print0 | xargs -0 -n1 php -l

# Feature self-tests
php tools/check-calendar-data.php           # parsing, timezones, recurrence, event limit, cache
php tools/check-calendar-recurrence.php     # fast-forward recurrence + work budget
php tools/check-calendar-cache.php          # cache lifecycle + garbage collection
php tools/check-calendar-dependencies.php   # mbstring/iconv detection + safe text fallbacks
php tools/check-calendar-preview.php        # PNG renderer + fetcher + endpoint headers + versioning
php tools/check-public-calendar-meta.php    # Open Graph metadata + escaping + versioned og:image
php tools/check-calendar-runtime.php        # runtime dependency preflight
php tools/check-calendar-rrule.php          # weekly RRULE regression
php tools/check-calendar-deploy.php         # deploy.sh striped-lock rollout safety (static checks)

# Existing checks (require the intent secret)
CALEE_CALENDAR_INTENT_SECRET=dev-test-secret php tools/check-calendar-intent-token.php
CALEE_CALENDAR_INTENT_SECRET=dev-test-secret php tools/check-calendar-app-launch.php
```

These same checks — including the multiprocess striped-lock concurrency tests
and the recurrence iteration-guard tests — run in CI
(`.github/workflows/calendar-preview-ci.yml`, PHP 8.3 with
curl/mbstring/gd/FreeType/DejaVu). The workflow triggers **once per pull-request
update** and **once on push to `main`** (the feature branch is intentionally not
in the `push` list, so a PR does not produce duplicate runs); `workflow_dispatch`
allows manual runs. It fails on any failed assertion or preflight warning.

### Deployment smoke tests

After deploying, with a **real** public `base` + `token`:

```bash
# Landing page + metadata (expect HTTP 200 and og:image/og:url on calembed.calee.com.au)
curl -sSi "https://calembed.calee.com.au/public-calendar.php?base=business&token=REAL_TOKEN" | grep -iE 'HTTP/|og:(image|url|title)'

# PNG preview (expect HTTP 200, Content-Type: image/png)
curl -sSi "https://calembed.calee.com.au/calendar-preview.php?base=business&token=REAL_TOKEN&year=2026&month=8" | grep -iE 'HTTP/|Content-Type|Content-Length'
curl -s   "https://calembed.calee.com.au/calendar-preview.php?base=business&token=REAL_TOKEN" -o /tmp/preview.png && file /tmp/preview.png   # PNG image data, 1200 x 630

# Revoked / bad token (expect HTTP 404 page, and a branded PNG from the preview)
curl -sSi "https://calembed.calee.com.au/public-calendar.php?base=business&token=deleted12345" | head -n1
```

### Troubleshooting WhatsApp preview caching

- WhatsApp/Facebook aggressively cache Open Graph data per URL. If a preview
  looks stale, use the [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/)
  and "Scrape Again" to refresh the crawler's cache for the exact URL.
- The `og:image` URL itself is **content-versioned** — it carries
  `&v=<short-fingerprint>-r<renderer-version>-s<privacy-state>` — so a genuinely
  changed calendar (or a summary-privacy toggle) produces a **different image
  URL** that crawlers refetch. The `ETag` only enables conditional revalidation
  of the *same* URL; it does not change the URL
  on its own. Our server-side cache refreshes within `CALEE_CALENDAR_CACHE_TTL`
  (default 5 min).
- A shared link must resolve the **same** month everywhere; always share the URL
  with explicit `year`/`month` if you need a fixed month card.
- If cards show the branded fallback image, run `php tools/check-calendar-runtime.php`
  on the host — GD/FreeType/font or the cache directory is likely missing.

## App Link Verification Files

The `.well-known/` directory contains the public verification files required for
CaleeMobile to open `https://calembed.calee.com.au/follow?t=<signed_calendar_intent>`
as a native deep link on iOS and Android.

### iOS Universal Links

File: `.well-known/apple-app-site-association`
Verification URL: `https://calembed.calee.com.au/.well-known/apple-app-site-association`

- Ready to deploy.
- Covers the `/follow` path for app ID `WQ3JPT4U3H.au.com.calee.mobile`
  (Team ID `WQ3JPT4U3H`, bundle ID `au.com.calee.mobile`).

### Android App Links

File: `.well-known/assetlinks.json`
Verification URL: `https://calembed.calee.com.au/.well-known/assetlinks.json`

- **Not ready for production.** The SHA-256 certificate fingerprint is a placeholder.
- Before shipping to production, replace `REPLACE_WITH_REAL_ANDROID_APP_SIGNING_SHA256`
  in `.well-known/assetlinks.json` with the real value from one of:
  - **Google Play Console** → Setup → App integrity → App signing certificate SHA-256
  - **Release keystore** via:
    ```
    keytool -list -v -keystore release.keystore -alias <alias>
    ```
- Do not ship Android production App Links with the placeholder value.

### Verifying after deployment

```bash
# iOS
curl -i https://calembed.calee.com.au/.well-known/apple-app-site-association
# Expect: HTTP 200, JSON body, appIDs contains WQ3JPT4U3H.au.com.calee.mobile

# Android
curl -i https://calembed.calee.com.au/.well-known/assetlinks.json
# Expect: HTTP 200, JSON body, package_name au.com.calee.mobile, sha256_cert_fingerprints present

# Existing routes (must still work)
curl -i "https://calembed.calee.com.au/follow?url=https%3A%2F%2Fbusiness.calee.com.au%2Fremote.php%2Fdav%2Fpublic-calendars%2F8mRYfarTaog4H2Mm%3Fexport&title=Test%20Calendar"
curl -i "https://calembed.calee.com.au/calendar-intent?t=BAD_TOKEN"
```
