Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 123 additions & 8 deletions drivers/place/public_events.cr
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,37 @@ class Place::PublicEvents < PlaceOS::Driver
accessor bookings : Bookings_1
accessor calendar : Calendar_1

# the permission field lives in the staff API `EventMetadata` table, it is not
# part of a calendar event, so it can't be included in the Bookings cache
accessor staff_api : StaffAPI_1

alias Permission = PlaceOS::Model::EventMetadata::Permission

# the number of event references we send to the staff API in a single request,
# this keeps the query string well below the HTTP request line size limit
REF_BATCH_SIZE = 50

default_settings({
# how often we re-check the event metadata permissions
metadata_refresh_minutes: 5,
})

@all_bookings : Array(PublicEvent) = [] of PublicEvent
@public_event_ids : Set(String) = Set(String).new
@filter_mutex : Mutex = Mutex.new

bind Bookings_1, :bookings, :on_bookings_change

def on_update
refresh_minutes = setting?(Int32, :metadata_refresh_minutes) || 5

# a permission can be changed without the event changing, and the Bookings
# driver only publishes `bookings` when the events have actually changed,
# so we can't rely on the subscription alone to keep the cache fresh
schedule.clear
schedule.every(refresh_minutes.minutes) { filter_and_cache } if refresh_minutes > 0
end

private def on_bookings_change(_subscription, new_value : String)
@all_bookings = Array(PublicEvent).from_json(new_value)
filter_and_cache
Expand All @@ -26,21 +52,90 @@ class Place::PublicEvents < PlaceOS::Driver
end

private def filter_and_cache : Array(PublicEvent)
logger.debug { "received #{@all_bookings.size} total events from bookings" }
@filter_mutex.synchronize do
events = @all_bookings
logger.debug { "received #{events.size} total events from bookings" }

permissions = event_permissions(events)

public_events = events.select do |event|
# a calendar event marked private has had its title and host masked by
# the Bookings driver, so there is nothing useful (or safe) to publish
permission_for(event, permissions).public? && !event.private?
end

logger.debug { "#{public_events.size} events have PUBLIC permission" }

@public_event_ids = public_events.compact_map(&.id).to_set
self["public_events"] = public_events
public_events
end
end

# Looks the metadata permission up in the staff API.
# Returns the instance level permissions and the recurring master permissions
# separately, so instance metadata can take precedence over the master.
private def event_permissions(events : Array(PublicEvent)) : Permissions
by_event = {} of String => Permission
by_master = {} of String => Permission
permissions = {by_event, by_master}
return permissions if events.empty?

system_id = system.id
refs = events.flat_map { |event| [event.id, event.ical_uid, event.recurring_event_id] }.compact
refs.uniq!
return permissions if refs.empty?

refs.each_slice(REF_BATCH_SIZE) do |batch|
metadata(system_id, batch).each do |meta|
by_event[meta.event_id] = meta.permission
by_event[meta.ical_uid] = meta.permission

# only the metadata of the series master applies to the whole series,
# instances have their own metadata which also references the master
if (master_id = meta.recurring_master_id) && master_id == meta.event_id
by_master[master_id] = meta.permission
if resource_master_id = meta.resource_master_id
by_master[resource_master_id] = meta.permission
end
end
end
end

permissions
end

private def metadata(system_id : String, event_ref : Array(String)) : Array(EventMetadata)
response = staff_api.query_metadata(system_id: system_id, event_ref: event_ref).get
Array(EventMetadata).from_json(response.to_json)
end

private def permission_for(event : PublicEvent, permissions : Permissions) : Permission
by_event, by_master = permissions

public_events = @all_bookings.select(&.permission.public?)
if (event_id = event.id) && (permission = by_event[event_id]?)
return permission
end

if (ical_uid = event.ical_uid) && (permission = by_event[ical_uid]?)
return permission
end

logger.debug { "#{public_events.size} events have PUBLIC permission" }
if (master_id = event.recurring_event_id) && (permission = by_master[master_id]?)
return permission
end

@public_event_ids = public_events.compact_map(&.id).to_set
self["public_events"] = public_events
public_events
Permission::PRIVATE
end

# Forces a Bookings re-poll then re-applies the public filter.
@[Security(Level::Administrator)]
def update_public_events : Nil
bookings.poll_events.get

# the re-poll only publishes `bookings` if the events have changed, so we
# always re-apply the filter to pick up metadata permission changes
filter_and_cache
end

# Appends an external attendee to the calendar event.
Expand Down Expand Up @@ -68,7 +163,20 @@ class Place::PublicEvents < PlaceOS::Driver
true
end

alias Permission = PlaceOS::Model::EventMetadata::Permission
alias Permissions = Tuple(Hash(String, Permission), Hash(String, Permission))

# The subset of the staff API event metadata we require.
# NOTE:: we don't use `PlaceOS::Model::EventMetadata` as it is a database
# backed model that renders linked bookings on serialisation.
private struct EventMetadata
include JSON::Serializable

getter event_id : String
getter ical_uid : String
getter recurring_master_id : String?
getter resource_master_id : String?
getter permission : Permission = Permission::PRIVATE
end

# Fields that are safe to expose publicly.
private struct PublicEvent
Expand All @@ -83,7 +191,14 @@ class Place::PublicEvents < PlaceOS::Driver
getter timezone : String?
getter? all_day : Bool = false

# used for matching metadata and filtering, never exposed publicly
@[JSON::Field(ignore_serialize: true)]
getter permission : Permission = Permission::PRIVATE
getter ical_uid : String? = nil

@[JSON::Field(ignore_serialize: true)]
getter recurring_event_id : String? = nil

@[JSON::Field(ignore_serialize: true)]
getter? private : Bool = false
end
end
30 changes: 25 additions & 5 deletions drivers/place/public_events_readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Docs on the PlaceOS Public Events driver.
This driver filters the Bookings event cache down to publicly visible events and handles guest registration, enabling unauthenticated access to selected calendar events.

* Subscribes to the Bookings driver's `:bookings` status and filters events where `private` is `false`
* Subscribes to the Bookings driver's `:bookings` status and filters events whose staff API event metadata `permission` is `PUBLIC`
* Caches the filtered set of public events (with a reduced set of safe fields) as the `:public_events` status
* Provides a `register_attendee` function for appending external (guest) attendees to a public event via the Calendar driver

Expand All @@ -14,16 +14,36 @@ Requires the following drivers in the same system:

* Bookings - for the room/calendar event cache and polling
* Calendar - for reading and updating calendar events when registering attendees
* StaffAPI - for looking up the event metadata `permission` field

The system must also have a calendar email configured (used as the `calendar_id` when calling the Calendar driver).


## How It Works

1. The Bookings driver polls the calendar and publishes all events to its `:bookings` status
2. PublicEvents receives the update via the subscription binding and filters to non-private events (`private == false`)
3. The filtered events are stored in `:public_events` with only safe, non-sensitive fields exposed: `id`, `title`, `body`, `event_start`, `event_end`, `location`, `timezone`, `all_day`
4. When a guest registers, `register_attendee` checks the event is in the public set, fetches it from the Calendar driver, appends the attendee, and writes it back
2. PublicEvents receives the update via the subscription binding
3. The `permission` field lives in the staff API `event_metadatas` table, it is not part of a calendar event, so it is never present in the Bookings cache. PublicEvents looks it up with `StaffAPI.query_metadata`, passing the `id`, `ical_uid` and `recurring_event_id` of every cached event as `event_ref` (batched to keep the query string small)
4. Events are kept where the metadata permission is `PUBLIC`:
* `PRIVATE` (the default when no metadata exists) and `OPEN` are excluded. `OPEN` only allows users in the same tenant to join, so it is not suitable for unauthenticated access
* metadata that belongs to an event instance takes precedence over the metadata of the recurring master (i.e. `recurring_master_id == event_id`), so a single public occurrence does not make the whole series public
* events marked private on the calendar are always excluded, the Bookings driver has already masked their title and host
5. The filtered events are stored in `:public_events` with only safe, non-sensitive fields exposed: `id`, `title`, `body`, `event_start`, `event_end`, `location`, `timezone`, `all_day`
6. When a guest registers, `register_attendee` checks the event is in the public set, fetches it from the Calendar driver, appends the attendee, and writes it back

A permission can be changed without the event itself changing, and a driver only publishes a status when its value has changed, so the `:bookings` subscription can't be relied on to keep the cache fresh. The metadata permissions are re-checked:

* whenever the Bookings cache changes
* every `metadata_refresh_minutes` (defaults to 5, set to 0 to disable)
* when `update_public_events` is called


## Settings

```yaml
# how often we re-check the event metadata permissions, 0 to disable
metadata_refresh_minutes: 5
```


## Public System Usage
Expand Down Expand Up @@ -51,4 +71,4 @@ args:

### `update_public_events : Nil`

Administrator-only. Triggers a Bookings re-poll and repopulates the public events cache via the subscription binding.
Administrator-only. Triggers a Bookings re-poll and repopulates the public events cache via the subscription binding.
Loading
Loading