Privacy model added, plus some helper scripts

This commit is contained in:
Adrian Stephens 2026-04-03 09:16:09 +01:00
parent 9b9c182dc7
commit 79a207a6f2
14 changed files with 293 additions and 4 deletions

View File

@ -0,0 +1,20 @@
ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e staging/calendar-plugin/calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb staging/calendar-plugin/src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d staging/calendar-plugin/src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba staging/calendar-plugin/src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 staging/calendar-plugin/src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 staging/calendar-plugin/src/Domain/CalDavService.php
4dc3337761c97aa550896fcc377aaab8338c598f39e489742dacbfd20e1a71b1 staging/calendar-plugin/src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f staging/calendar-plugin/src/Domain/IcsService.php
5f7fb1f8c00136ad2c4a2dc8b909dabf6494a194b09aa73557a4bf68d73f4ed2 staging/calendar-plugin/src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 staging/calendar-plugin/src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f staging/calendar-plugin/src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b staging/calendar-plugin/src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 staging/calendar-plugin/src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c staging/calendar-plugin/src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b staging/calendar-plugin/src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea staging/calendar-plugin/src/Infrastructure/WordPress/WordPressOptionsAdapter.php
ffc7c3eef8f3ed0873b5f614765219925d07c24ffd6ffa7422a3d862a7342450 staging/calendar-plugin/src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 staging/calendar-plugin/src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c staging/calendar-plugin/uninstall.php

Binary file not shown.

View File

@ -36,7 +36,7 @@ This document covers:
### Create Event
- `POST /events`
- Requires calendar write capability.
- Request body supports fields in `requirements/editor.md`.
- Request body supports fields in `requirements/editor.md`, including `visibility` (`public`/`private`).
- Response: `201` with created event payload and identifiers.
### List Events
@ -70,6 +70,16 @@ This document covers:
- Must not split the underlying recurring series.
- Response: `204` on success.
## Public Read Endpoints and Privacy Redaction
Public-read endpoints (for example `/public/events`, `/public/sidebar-events`, and ICS feed generation paths) must apply privacy masking for `private` events:
- Event date/time placement remains unchanged.
- Title is replaced with `Private Event`.
- Description/location/category and other non-time content are omitted from public responses.
- Logged-in write-capable UI and authenticated CalDAV/API reads continue to receive full event details.
## CalDAV User Workflow Endpoints
### Register
@ -139,3 +149,4 @@ Acceptance should verify:
- Validation and error payloads are consistent.
- Single-occurrence delete creates recurrence exception rather than split series.
- Authz rules enforce role/access constraints.
- Privacy redaction rules are enforced consistently across public API payloads and ICS output.

View File

@ -40,9 +40,15 @@ This document covers:
### ICS Access
- Public ICS endpoint visibility is governed by setup configuration.
- If endpoint is public: `public` may read only approved public events.
- If endpoint is public: `public` may read public events and redacted private events.
- If endpoint requires auth: enforce configured auth policy consistently.
### Privacy Visibility Rules
- `public` viewers (not logged in) see full details for public events.
- `public` viewers see private events in normal date/time positions with title `Private Event` and no other event content.
- Logged-in write-capable users and authenticated CalDAV users may view full details for both public and private events.
- Privacy masking applies consistently across public calendar page views, sidebar, and public ICS output.
## Ownership and Scope Rules
- CalDAV users operate against the single shared `public` calendar collection.
- Access control is enforced by account approval state (`pending_approval` vs `active`) rather than per-user private calendars.

View File

@ -74,6 +74,7 @@ The endpoint must support these operations at minimum.
### Create and Update
- `PUT`: create new event resource or replace an existing event resource.
- `PUT` updates must preserve recurrence/exception semantics from iCalendar input.
- CalDAV clients (including Thunderbird) must be able to read current event privacy and update it on edit (for example via `CLASS`).
- Write operations must require approved authenticated access.
### Delete
@ -101,6 +102,12 @@ Minimum mapping expectations:
- Recurrence rules -> `RRULE`
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
Privacy visibility mapping:
- Event visibility must round-trip through CalDAV/iCalendar semantics (for example `CLASS:PUBLIC` and `CLASS:PRIVATE` where supported).
- Authenticated CalDAV users must receive full event content for both public and private events.
- CalDAV feeds are not subject to the public redaction model used by anonymous web/ICS surfaces.
Recurrence behavior:
- Series-level recurrence remains a single logical event sequence keyed by `UID`.
@ -188,3 +195,4 @@ Acceptance should verify:
- Sync collection reports provide incremental changes.
- Recurring-event single-occurrence delete results in exception representation, not sequence split.
- Returned iCalendar validates against RFC 5545 expectations and is accepted by target clients.
- Privacy values round-trip through CalDAV edit flows (including Thunderbird) without losing event detail fidelity.

View File

@ -96,6 +96,7 @@ If a report is unsupported for a resource, server returns standards-appropriate
## Content Handling
- Event objects use `text/calendar` payloads with RFC 5545-compatible `VCALENDAR`.
- Authenticated CalDAV object responses (`GET`, `REPORT`, `calendar-multiget`) return full event details for both public and private events.
- Unsupported component types should be rejected unless explicitly mapped.
- Server should normalize line endings/content as required by iCalendar compatibility.

View File

@ -33,6 +33,7 @@ Requirements:
The stored model must support these fields (direct columns or normalized equivalents):
- Event identifier (internal primary key)
- `visibility` (`public` or `private`)
- `title`
- `location`
- `category`
@ -55,6 +56,7 @@ The stored model must support these fields (direct columns or normalized equival
- Data model must support efficient retrieval by date range for day/week/month/year UI windows.
- Data model must support generation of recurrence occurrences for both web UI and ICS output.
- Queries for public UI must return only intended public event data.
- Queries for public UI/ICS/sidebar must include private events with privacy redaction (time/date shown, non-time content masked).
## Local Testing Data Requirements
The local compatibility-harness test environment must support deterministic data setup and teardown.

View File

@ -22,6 +22,7 @@ This document covers:
Required fields:
- `id` (PK)
- `uid` (stable iCalendar UID)
- `visibility` (`public`, `private`; default `public`)
- `title`
- `description`
- `location`
@ -42,6 +43,11 @@ Constraints:
- `title`, `start_datetime` required
- `end_datetime >= start_datetime`
- recurrence fields internally consistent
- `visibility` must be one of the supported enum values
Privacy semantics:
- `private` events are stored with full details.
- Public-facing renderers (public ICS/public calendar/sidebar) must apply redaction rules at read time, not by destructively altering stored event data.
### Recurrence Exceptions
Required fields:

View File

@ -35,6 +35,7 @@ Behavior requirements:
Supported fields:
- `title` (required)
- `visibility` (`public` or `private`; default `public`)
- `description`
- `location`
- `category`
@ -55,6 +56,7 @@ UI behavior requirements:
- Time controls hide when `all_day_event` is selected.
- Floating/persistent labels keep field purpose visible.
- Internal event ID is hidden from users.
- Editor must provide a control to mark events as `public` or `private`.
## Recurrence Model
Supported `repeat_type` values:
@ -91,3 +93,4 @@ Acceptance should verify:
- Deleting one occurrence creates an exception while preserving the series.
- Monthly ordinal patterns save and reload correctly.
- Validation blocks invalid start/end ranges.
- Privacy visibility setting saves and reloads correctly.

View File

@ -49,6 +49,7 @@ Field mapping requirements:
- Plugin end date/time -> `DTEND`
- Plugin category -> `CATEGORIES`
- Stable unique event identifier -> `UID`
- Plugin `visibility` controls public-output masking for private events
- Last update timestamp -> `DTSTAMP`
Additional mapping guidance:
@ -57,6 +58,15 @@ Additional mapping guidance:
- Timed events must use `DTSTART;TZID=Europe/London` and `DTEND;TZID=Europe/London`.
- `DTEND` must represent a valid end boundary and not precede `DTSTART`.
Privacy behavior:
- `public` events are exported with normal mapped content.
- `private` events remain present in the feed at normal date/time positions, but with redaction:
- `SUMMARY` must be `Private Event`
- `DTSTART`/`DTEND` remain populated
- `DESCRIPTION`, `LOCATION`, and `CATEGORIES` must be omitted from public ICS output
## Recurrence Mapping
Recurring entries should be represented with `RRULE` where possible.
@ -84,7 +94,7 @@ If specific recurrence shapes cannot be represented in a single RRULE, plugin ma
- Generation errors should fail safely with observable error handling.
## Security and Privacy
- Export must include only events intended for public/user-facing distribution.
- Export must enforce visibility policy: private events are included with redacted content in public ICS output.
- Internal/admin-only metadata must not leak into ICS properties.
## Verification Requirements

View File

@ -26,13 +26,14 @@ This document covers:
## Access Policy
- Access mode is configurable in `Setup`:
- `public_read`: endpoint accessible without auth, only public events included
- `public_read`: endpoint accessible without auth; includes both public and private events, with private events redacted
- `authenticated_read`: endpoint requires authenticated access
- Default mode: `public_read` unless overridden by policy requirements.
## Data Scope
- ICS output includes only events intended for the selected endpoint audience.
- Internal/admin-only metadata must never appear in output.
- In `public_read` mode, private events remain in timeline position but expose only date/time and the title `Private Event`.
- Recurrence and exception semantics follow `requirements/ics.md` and `requirements/recurrence_exceptions.md`.
## Stability and Subscription

View File

@ -83,6 +83,16 @@ Minimum display requirements:
- Category (if provided)
- Description excerpt/summary (if configured for display)
Privacy display rules:
- Public events render full details per normal display rules.
- Private events remain visible in chronological position for all viewers.
- In public (not logged-in) UI and sidebar rendering, private events must display:
- Title as `Private Event`
- Date/time fields as normal
- No location/category/description content
- In logged-in write-capable UI, private events display full details.
## Web Event Editing UX (Authorized Users)
Where the viewer has write permission, the web UI must support event creation/editing.
@ -94,6 +104,7 @@ Requirements:
- Irrelevant recurrence fields must be hidden based on current recurrence selections.
- Form controls use persistent inline/floating labels so field purpose remains visible after input.
- Event ID must not be shown in the UI.
- Create/edit panel must show current privacy (`public`/`private`) and allow changing it before save.
- `location` and `category` should be rendered on one row.
- `start date/time` and `end date/time` should be rendered on one row.
- `repeat type` and `every` interval should be rendered on one row.
@ -112,6 +123,7 @@ Requirements:
## Login and Access Modes
- `/calendar` must support two user modes: public (not logged in) and logged-in.
- Public mode is read-only.
- In public mode, private events are redacted (title `Private Event`; time preserved; non-time fields hidden).
- A `Login` action must open an in-page dialog.
- Login dialog must support credential login.
- Login dialog must support registration (registration is a request for approval/write access).
@ -149,3 +161,4 @@ Acceptance should verify:
- All five view modes are selectable and render expected event windows.
- Empty-state behavior is clear and user-friendly.
- ICS link is present and returns valid calendar payload.
- Privacy redaction behavior is correct in public views, sidebar, and logged-in views.

137
scripts/deploy_remote.sh Executable file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/credentials/.env"
if [[ -f "${ENV_FILE}" ]]; then
# shellcheck disable=SC1090
source "${ENV_FILE}"
fi
ARTIFACT=""
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
while [[ $# -gt 0 ]]; do
case "$1" in
--artifact)
ARTIFACT="${2:-}"
shift 2
;;
--wp-root)
WP_ROOT="${2:-}"
shift 2
;;
--help|-h)
cat <<'USAGE'
Deploy a plugin artifact to remote WordPress.
Usage:
scripts/deploy_remote.sh [--artifact /abs/or/relative/path.zip] [--wp-root /var/www/wordpress]
Defaults:
- Artifact: latest ./package/calendar-plugin-*.zip
- Remote host settings from credentials/.env
This script enforces ownership:
- chown -R www-data:www-data <remote plugin dir>
USAGE
exit 0
;;
*)
echo "[deploy] unknown argument: $1" >&2
exit 2
;;
esac
done
if [[ -z "${ARTIFACT}" ]]; then
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
fi
if [[ -z "${ARTIFACT}" ]]; then
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
exit 1
fi
if [[ "${ARTIFACT}" != /* ]]; then
ARTIFACT="${ROOT_DIR}/${ARTIFACT#./}"
fi
if [[ ! -f "${ARTIFACT}" ]]; then
echo "[deploy] artifact not found: ${ARTIFACT}" >&2
exit 1
fi
MANIFEST="${ARTIFACT%.zip}.manifest.sha256"
if [[ ! -f "${MANIFEST}" ]]; then
echo "[deploy] warning: manifest not found beside artifact (${MANIFEST}); continuing" >&2
fi
required=(REMOTE_HOST REMOTE_PORT REMOTE_USER REMOTE_SSH_KEY_PATH REMOTE_APP_DIR REMOTE_WP_CLI WP_URL)
for key in "${required[@]}"; do
if [[ -z "${!key:-}" ]]; then
echo "[deploy] missing required env var: ${key}" >&2
exit 1
fi
done
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
if [[ "${SSH_KEY_PATH}" != /* ]]; then
SSH_KEY_PATH="${ROOT_DIR}/${SSH_KEY_PATH}"
fi
if [[ ! -f "${SSH_KEY_PATH}" ]]; then
echo "[deploy] ssh key not found: ${SSH_KEY_PATH}" >&2
exit 1
fi
artifact_base="$(basename -- "${ARTIFACT}")"
stage_name="${artifact_base%.zip}"
STAGE_DIR="/tmp/${stage_name}"
SSH=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}")
RSYNC=(rsync -avz --delete -e "ssh -F /dev/null -i ${SSH_KEY_PATH} -p ${REMOTE_PORT} -o StrictHostKeyChecking=accept-new")
echo "[deploy] staging on remote: ${STAGE_DIR}"
"${SSH[@]}" "mkdir -p '${STAGE_DIR}'"
if [[ -f "${MANIFEST}" ]]; then
"${RSYNC[@]}" "${ARTIFACT}" "${MANIFEST}" "${REMOTE_USER}@${REMOTE_HOST}:${STAGE_DIR}/"
else
"${RSYNC[@]}" "${ARTIFACT}" "${REMOTE_USER}@${REMOTE_HOST}:${STAGE_DIR}/"
fi
"${SSH[@]}" "set -euo pipefail; rm -rf '${STAGE_DIR}/extracted'; mkdir -p '${STAGE_DIR}/extracted'; unzip -q '${STAGE_DIR}/${artifact_base}' -d '${STAGE_DIR}/extracted'; test -f '${STAGE_DIR}/extracted/calendar-plugin/calendar-plugin.php'"
"${SSH[@]}" "set -euo pipefail; rsync -a --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/'"
"${SSH[@]}" "set -euo pipefail; chown -R www-data:www-data '${REMOTE_APP_DIR}'"
"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null 2>&1 || true"
# Exact-match style checksum dry-run check
"${SSH[@]}" "set -euo pipefail; rsync -avznc --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^[^./]|^\./' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi"
# Ownership sanity check: must be zero mismatches
non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")"
if [[ "${non_owned}" != "0" ]]; then
echo "[deploy] ownership check failed: ${non_owned} path(s) not owned by www-data:www-data" >&2
exit 1
fi
# Resolve slug for endpoint checks
url_slug="$("${SSH[@]}" "'${REMOTE_WP_CLI}' --path='${WP_ROOT}' option get calendar_plugin_url_slug --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
if [[ -n "${url_slug}" ]]; then
ICS_URL="${WP_URL%/}/${url_slug}/calendar.ics"
CALDAV_URL="${WP_URL%/}/${url_slug}/caldav/"
else
ICS_URL="${WP_URL%/}/calendar.ics"
CALDAV_URL="${WP_URL%/}/caldav/"
fi
health_code="$(curl -sS -o /tmp/codex_health.out -w '%{http_code}' "${WP_URL%/}/wp-json/calendar/v1/health" || true)"
ics_code="$(curl -sS -o /tmp/codex_ics.out -w '%{http_code}' "${ICS_URL}" || true)"
caldav_code="$(curl -sS -o /tmp/codex_caldav.out -w '%{http_code}' "${CALDAV_URL}" || true)"
echo "[deploy] plugin deployed: ${artifact_base}"
echo "[deploy] ownership: www-data:www-data (verified)"
echo "[deploy] checks: health=${health_code} ics=${ics_code} caldav_unauth=${caldav_code}"
echo "[deploy] urls: ${ICS_URL} ${CALDAV_URL}"

71
scripts/package_plugin.sh Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/credentials/.env"
if [[ -f "${ENV_FILE}" ]]; then
# shellcheck disable=SC1090
source "${ENV_FILE}"
fi
VERSION=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
VERSION="${2:-}"
shift 2
;;
--help|-h)
cat <<'USAGE'
Create a plugin package artifact from ./code.
Usage:
scripts/package_plugin.sh [--version X.Y.Z]
Notes:
- If --version is not provided, reads Version from code/calendar-plugin.php.
- Writes zip + manifest to ./package.
USAGE
exit 0
;;
*)
echo "[package] unknown argument: $1" >&2
exit 2
;;
esac
done
if [[ -z "${VERSION}" ]]; then
VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
fi
if [[ -z "${VERSION}" ]]; then
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
exit 1
fi
SLUG="calendar-plugin"
PACKAGE_DIR="${ROOT_DIR}/package"
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
ARTIFACT="${PACKAGE_DIR}/${SLUG}-${VERSION}.zip"
MANIFEST="${PACKAGE_DIR}/${SLUG}-${VERSION}.manifest.sha256"
mkdir -p "${PACKAGE_DIR}/staging"
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}"
rsync -a --delete "${ROOT_DIR}/code/" "${STAGING_DIR}/"
(
cd "${PACKAGE_DIR}/staging"
zip -qr "${ARTIFACT}" "${SLUG}"
)
(
cd "${STAGING_DIR}"
find . -type f -print0 | sort -z | xargs -0 sha256sum
) > "${MANIFEST}"
echo "[package] created artifact: ${ARTIFACT}"
echo "[package] created manifest: ${MANIFEST}"