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
4 changes: 4 additions & 0 deletions .ameba.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Lint/DebugCalls:
Excluded:
- drivers/**/*_spec.cr

Lint/NotNil:
Excluded:
- drivers/**/*_spec.cr

# NOTE: These should all be reviewed on an individual basis to see if their
# complexity can be reasonably reduced.
Metrics/CyclomaticComplexity:
Expand Down
88 changes: 88 additions & 0 deletions drivers/place/public_events.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
require "placeos-driver"
require "place_calendar"

# Filters Bookings event cache down to public events for unauthenticated access.
# Uses the Calendar driver for guest registration.
class Place::PublicEvents < PlaceOS::Driver
descriptive_name "PlaceOS Public Events"
generic_name :PublicEvents
description %(Caches public events for external access and handles guest registration)

accessor bookings : Bookings_1
accessor calendar : Calendar_1

@all_bookings : Array(PlaceCalendar::Event) = [] of PlaceCalendar::Event
@public_event_ids : Set(String) = Set(String).new

bind Bookings_1, :bookings, :on_bookings_change

private def on_bookings_change(_subscription, new_value : String)
@all_bookings = Array(PlaceCalendar::Event).from_json(new_value)
filter_and_cache
rescue error
logger.warn(exception: error) { "failed to process bookings update" }
end

private def filter_and_cache : Array(PlaceCalendar::Event)
public_events = @all_bookings.select do |event|
event.extended_properties.try { |props| props["public"]? == "true" }
end
@public_event_ids = public_events.compact_map(&.id).to_set
self["public_events"] = public_events.map { |e| PublicEvent.new(e) }
public_events
end

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

# Appends an external attendee to the calendar event.
def register_attendee(event_id : String, name : String, email : String) : Bool
unless @public_event_ids.includes?(event_id)
logger.warn { "#{event_id} is not a known public event" }
return false
end

cal_id = system.email.presence
unless cal_id
logger.error { "system has no calendar email configured" }
return false
end

event_data = calendar.get_event(cal_id, event_id).get
unless event_data
logger.warn { "event #{event_id} not found in calendar" }
return false
end

event = PlaceCalendar::Event.from_json(event_data.to_json)
event.attendees << PlaceCalendar::Event::Attendee.new(name: name, email: email)
calendar.update_event(event, calendar_id: cal_id).get
true
end

# Fields that are safe to expose publicly.
private struct PublicEvent
include JSON::Serializable

getter id : String?
getter title : String?
getter event_start : Int64
getter event_end : Int64?
getter location : String?
getter timezone : String?
getter? all_day : Bool

def initialize(event : PlaceCalendar::Event)
@id = event.id
@title = event.title
@event_start = event.event_start.to_unix
@event_end = event.event_end.try(&.to_unix)
@location = event.location
@timezone = event.timezone
@all_day = event.all_day?
end
end
end
54 changes: 54 additions & 0 deletions drivers/place/public_events_readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Public Events Readme

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 `extended_properties["public"] == "true"`
* 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


## Requirements

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

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 events where `extended_properties["public"] == "true"`
3. The filtered events are stored in `:public_events` with only safe, non-sensitive fields exposed: `id`, `title`, `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


## Public System Usage

This driver is intended to be placed in the same system as the public events calendar. It follows the same public system access pattern as the WebRTC driver — a Guest JWT is issued to the caller after passing the invisible Google reCAPTCHA, granting read access to the `:public_events` status and the ability to call `register_attendee`.


## Functions

### `register_attendee(event_id, name, email) : Bool`

Appends an external attendee to a public calendar event.

* Returns `true` on success
* Returns `false` if the `event_id` is not in the public events set, or if the system has no calendar email configured

```yaml
# Example call
function: register_attendee
args:
event_id: "evt-abc-123"
name: "Alice Smith"
email: "alice@external.com"
```

### `update_public_events : Nil`

Administrator-only. Triggers a Bookings re-poll and repopulates the public events cache via the subscription binding.
136 changes: 136 additions & 0 deletions drivers/place/public_events_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require "placeos-driver/spec"
require "place_calendar"

DriverSpecs.mock_driver "Place::PublicEvents" do
system({
Bookings: {BookingsMock},
Calendar: {CalendarMock},
})

# BookingsMock publishes its events in on_load, which triggers the
# Bookings_1 :bookings subscription in our driver. Give it a moment to fire.
sleep 200.milliseconds

# -----------------------------------------------------------------------
# Test 1: subscription populates the public events cache automatically
# -----------------------------------------------------------------------
events = status[:public_events].as_a
events.size.should eq(1)
events[0]["id"].as_s.should eq("evt-public-1")
events[0]["title"].as_s.should eq("Public Conference")

# -----------------------------------------------------------------------
# Test 2: events without extended_properties.public are excluded
# -----------------------------------------------------------------------
events.none? { |e| e["id"].as_s == "evt-private-no-ext" }.should be_true

# -----------------------------------------------------------------------
# Test 3: events with extended_properties.public = false are excluded
# -----------------------------------------------------------------------
events.none? { |e| e["id"].as_s == "evt-private-explicit" }.should be_true

# -----------------------------------------------------------------------
# Test 4: only allowlisted fields are present in the public cache
# -----------------------------------------------------------------------
events[0]["event_start"].as_i64.should be > 0_i64
events[0]["event_end"].as_i64.should be > 0_i64
events[0]["attendees"]?.should be_nil
events[0]["host"]?.should be_nil
events[0]["body"]?.should be_nil
events[0]["online_meeting_url"]?.should be_nil
events[0]["creator"]?.should be_nil

# -----------------------------------------------------------------------
# Test 5: update_public_events triggers a Bookings re-poll and returns nil;
# the cache is repopulated via the :bookings subscription binding.
# -----------------------------------------------------------------------
exec(:update_public_events).get
sleep 200.milliseconds
updated_events = status[:public_events].as_a
updated_events.size.should eq(1)
updated_events[0]["id"].as_s.should eq("evt-public-1")

# -----------------------------------------------------------------------
# Test 6: register_attendee appends the guest via the Calendar driver
# -----------------------------------------------------------------------
exec(:register_attendee, "evt-public-1", "Alice Smith", "alice@external.com").get.should be_true

attendees = system(:Calendar)[:updated_attendees].as_a
attendees.any? { |a| a["email"].as_s == "alice@external.com" }.should be_true
attendees.any? { |a| a["name"].as_s == "Alice Smith" }.should be_true

# -----------------------------------------------------------------------
# Test 7: register_attendee returns false for unknown event IDs
# -----------------------------------------------------------------------
exec(:register_attendee, "evt-private-no-ext", "Bob", "bob@example.com").get.should be_false

# Calendar must not have been called again — updated_attendees unchanged
system(:Calendar)[:updated_attendees].as_a
.none? { |a| a["email"].as_s == "bob@example.com" }
.should be_true
end

# :nodoc:
# Simulates the Bookings driver. Publishes a fixed set of three events on load
# so the PublicEvents driver's subscription fires immediately:
# - one explicitly public (should appear in the cache)
# - one with no properties (should be excluded)
# - one explicitly non-public (should be excluded)
class BookingsMock < DriverSpecs::MockDriver
def on_load
now = Time.utc
self[:bookings] = [
PlaceCalendar::Event.new(
id: "evt-public-1",
host: "organizer@company.com",
title: "Public Conference",
event_start: now + 1.day,
event_end: now + 1.day + 2.hours,
extended_properties: Hash(String, String?){"public" => "true"},
attendees: [PlaceCalendar::Event::Attendee.new(name: "Internal Person", email: "internal@company.com")],
),
PlaceCalendar::Event.new(
id: "evt-private-no-ext",
host: "team@company.com",
title: "Internal Meeting",
event_start: now + 2.days,
event_end: now + 2.days + 1.hour,
),
PlaceCalendar::Event.new(
id: "evt-private-explicit",
host: "exec@company.com",
title: "Executive Briefing",
event_start: now + 3.days,
event_end: now + 3.days + 1.hour,
extended_properties: Hash(String, String?){"public" => "false"},
),
]
end

def poll_events : Nil
# Re-publish current bookings to exercise the subscription path.
on_load
end
end

# :nodoc:
# Simulates the Calendar driver (Microsoft::GraphAPI / Place::CalendarCommon).
# get_event returns a PlaceCalendar::Event directly.
# update_event records the final attendees list for assertion.
class CalendarMock < DriverSpecs::MockDriver
def get_event(calendar_id : String, event_id : String, user_id : String? = nil) : PlaceCalendar::Event
now = Time.utc
PlaceCalendar::Event.new(
id: event_id,
host: calendar_id,
title: "Public Conference",
event_start: now + 1.day,
event_end: now + 1.day + 2.hours,
)
end

def update_event(event : PlaceCalendar::Event, user_id : String? = nil, calendar_id : String? = nil) : PlaceCalendar::Event
self[:updated_attendees] = event.attendees
event
end
end
34 changes: 34 additions & 0 deletions harness
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@
# `u`: fail script if a variable is unset (unitialized)
set -eu

# Holds the path to the saved .git worktree pointer while the harness runs.
_WORKTREE_BACKUP=""

# Restore the original git worktree .git file if we replaced it.
# Safe to call even when no replacement was made.
restore_git_worktree() {
if [ -n "${_WORKTREE_BACKUP}" ] && [ -f "${_WORKTREE_BACKUP}" ]; then
rm -rf "${PWD}/.git"
mv "${_WORKTREE_BACKUP}" "${PWD}/.git"
_WORKTREE_BACKUP=""
fi
}

# The test-harness needs a real git repo to read the current commit hash.
# When running inside a git worktree the .git entry is a file (not a directory)
# that points to an absolute path the container cannot see. Detect this and
# temporarily replace it with a self-contained repo for the duration of the run.
setup_git_for_harness() {
if [ -f "${PWD}/.git" ]; then
echo '░░░ Git worktree detected, creating temporary repo for harness...'
_WORKTREE_BACKUP="${PWD}/.git.worktree-bak"
cp "${PWD}/.git" "${_WORKTREE_BACKUP}"
rm "${PWD}/.git"
git -C "${PWD}" init -q
git -C "${PWD}" add -A
git -C "${PWD}" commit -q -m "temp: harness run"
fi
}

# Always restore the worktree pointer on exit (covers errors and Ctrl-C).
trap restore_git_worktree EXIT

say_done() {
printf "░░░ Done.\n"
}
Expand Down Expand Up @@ -56,6 +88,8 @@ format() {
}

report() {
setup_git_for_harness

echo '░░░ PlaceOS Driver Compilation Report'
echo '░░░ Pulling images...'
docker compose pull &> /dev/null
Expand Down
4 changes: 2 additions & 2 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ shards:

neuroplastic:
git: https://github.com/spider-gazelle/neuroplastic.git
version: 1.14.1
version: 1.14.2

ntlm:
git: https://github.com/spider-gazelle/ntlm.git
Expand Down Expand Up @@ -251,7 +251,7 @@ shards:

placeos-models:
git: https://github.com/placeos/models.git
version: 9.86.0
version: 9.86.2

pool:
git: https://github.com/ysbaddaden/pool.git
Expand Down
Loading