Skip to content

Commit d173b07

Browse files
committed
feat(public_events): [PPT-2247] Support Public Event Sharing & Registration
1 parent 9261442 commit d173b07

4 files changed

Lines changed: 255 additions & 16 deletions

File tree

src/placeos-rest-api/controllers/application.cr

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@ module PlaceOS::Api
163163
CommonError.new(error, false)
164164
end
165165

166+
# 401 if reCAPTCHA verification fails
167+
@[AC::Route::Exception(Error::RecaptchaFailed, status_code: HTTP::Status::UNAUTHORIZED)]
168+
def recaptcha_failed(error) : CommonError
169+
Log.debug { error.message }
170+
CommonError.new(error, false)
171+
end
172+
173+
# 503 if guest access is not configured (e.g. JWT_SECRET absent)
174+
@[AC::Route::Exception(Error::GuestAccessDisabled, status_code: HTTP::Status::SERVICE_UNAVAILABLE)]
175+
def guest_access_disabled(error) : CommonError
176+
Log.debug { error.message }
177+
CommonError.new(error, false)
178+
end
179+
166180
# 403 if user role invalid for a route
167181
@[AC::Route::Exception(Error::Forbidden, status_code: HTTP::Status::FORBIDDEN)]
168182
def resource_access_forbidden(error) : Nil
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
require "base64"
2+
require "jwt"
3+
require "placeos-driver/storage"
4+
require "placeos-driver/proxy/system"
5+
require "placeos-driver/proxy/remote_driver"
6+
7+
require "./application"
8+
9+
module PlaceOS::Api
10+
class PublicEvents < Application
11+
include Utils::CoreHelper
12+
13+
base "/api/engine/v2/public_events/"
14+
15+
# guest_token is fully unauthenticated — reCAPTCHA is the only gate.
16+
skip_action :authorize!, only: [:guest_token]
17+
skip_action :set_user_id, only: [:guest_token]
18+
19+
# Regular authenticated users AND guest JWTs may call index / register.
20+
before_action :can_read_guest, only: [:index, :register]
21+
22+
##########################################################################
23+
# Before filters
24+
##########################################################################
25+
26+
# Resolves the target ControlSystem from either a system-id or a permalink
27+
# (code). Raises 404 if the system does not exist or is not marked public.
28+
@[AC::Route::Filter(:before_action, only: [:guest_token, :index, :register])]
29+
def find_current_control_system(
30+
@[AC::Param::Info(description: "a system id or unique permalink", example: "sys-12345")]
31+
system_id : String,
32+
)
33+
system = if system_id.starts_with?("sys-")
34+
::PlaceOS::Model::ControlSystem.find!(system_id)
35+
else
36+
res = ::PlaceOS::Model::ControlSystem.where(code: system_id).first?
37+
raise Error::NotFound.new("could not find system #{system_id}") unless res
38+
res
39+
end
40+
41+
raise Error::NotFound.new("could not find system #{system_id}") unless system.public
42+
43+
Log.context.set(control_system_id: system_id)
44+
@current_control_system = system
45+
end
46+
47+
getter! current_control_system : ::PlaceOS::Model::ControlSystem
48+
49+
##########################################################################
50+
# Request / response structs
51+
##########################################################################
52+
53+
struct CaptchaResponse
54+
include JSON::Serializable
55+
property? success : Bool
56+
end
57+
58+
struct TokenRequest
59+
include JSON::Serializable
60+
getter captcha : String
61+
getter name : String
62+
getter email : String
63+
end
64+
65+
struct RegistrationRequest
66+
include JSON::Serializable
67+
getter event_id : String
68+
getter name : String
69+
getter email : String
70+
end
71+
72+
##########################################################################
73+
# Constants
74+
##########################################################################
75+
76+
JWT_SECRET = ENV["JWT_SECRET"]?.try { |k| Base64.decode_string(k) }
77+
MODULE_NAME = "PublicEvents"
78+
79+
##########################################################################
80+
# Routes
81+
##########################################################################
82+
83+
# Issues a short-lived guest JWT after verifying the reCAPTCHA challenge.
84+
#
85+
# The resulting token is scoped to the requested public system and grants:
86+
# - read access to the cached `:public_events` driver status
87+
# - the ability to call `register_attendee` via the `/register` route
88+
#
89+
# Mirrors the WebRTC `guest_entry` flow:
90+
# - authority.internals["recaptcha_secret"] → live Google verification
91+
# - authority.internals["recaptcha_skip"] = true → skip (dev / test only)
92+
@[AC::Route::POST("/guest_token/:system_id", body: :params)]
93+
def guest_token(
94+
system_id : String,
95+
params : TokenRequest,
96+
) : String
97+
jwt_secret = JWT_SECRET
98+
raise Error::GuestAccessDisabled.new("guest access not enabled") unless jwt_secret
99+
100+
authority = current_authority.as(::PlaceOS::Model::Authority)
101+
102+
if recaptcha_secret = authority.internals["recaptcha_secret"]?.try(&.as_s)
103+
HTTP::Client.new("www.google.com", tls: true) do |http|
104+
http.connect_timeout = 2.seconds
105+
begin
106+
resp = http.post("/recaptcha/api/siteverify?secret=#{recaptcha_secret}&response=#{params.captcha}")
107+
if resp.success?
108+
result = CaptchaResponse.from_json(resp.body)
109+
raise Error::RecaptchaFailed.new("recaptcha rejected") unless result.success?
110+
else
111+
raise Error::RecaptchaFailed.new("error verifying recaptcha response")
112+
end
113+
rescue error : Error::RecaptchaFailed
114+
raise error
115+
rescue error
116+
# Do not block the user if Google is temporarily unreachable.
117+
Log.error(exception: error) { "recaptcha verification failed" }
118+
end
119+
end
120+
else
121+
raise Error::RecaptchaFailed.new("recaptcha not configured") unless authority.internals["recaptcha_skip"]? == true
122+
end
123+
124+
system = current_control_system
125+
expires = 12.hours.from_now
126+
127+
payload = {
128+
iss: "POS",
129+
iat: 1.minute.ago.to_unix,
130+
exp: expires.to_unix,
131+
jti: UUID.random.to_s,
132+
aud: authority.domain,
133+
scope: ["guest"],
134+
sub: "guest-#{UUID.random}",
135+
u: {
136+
n: params.name,
137+
e: params.email,
138+
p: 0,
139+
r: [system_id],
140+
},
141+
}
142+
143+
jwt = JWT.encode(payload, jwt_secret, JWT::Algorithm::RS256)
144+
145+
response.cookies << HTTP::Cookie.new(
146+
name: "bearer_token",
147+
value: jwt,
148+
path: "/api/engine/v2/public_events",
149+
expires: expires,
150+
secure: true,
151+
http_only: true,
152+
samesite: :strict,
153+
)
154+
155+
jwt
156+
end
157+
158+
# Returns the cached list of public events for the given system.
159+
#
160+
# Reads the `:public_events` status key directly from Redis — no live
161+
# driver round-trip is made. The response is sourced from the cache that
162+
# the PublicEvents driver maintains via its Bookings subscription.
163+
#
164+
# Guest JWTs are accepted provided the system id appears in their roles.
165+
@[AC::Route::GET("/:system_id/events")]
166+
def index(system_id : String) : Array(JSON::Any)
167+
if user_token.guest_scope?
168+
raise Error::Forbidden.new unless user_token.user.roles.includes?(current_control_system.id)
169+
end
170+
171+
module_id = ::PlaceOS::Driver::Proxy::System.module_id?(
172+
system_id: system_id,
173+
module_name: MODULE_NAME,
174+
index: 1,
175+
)
176+
177+
return [] of JSON::Any unless module_id
178+
179+
storage = Driver::RedisStorage.new(module_id)
180+
raw = storage["public_events"]?
181+
return [] of JSON::Any unless raw
182+
183+
Array(JSON::Any).from_json(raw)
184+
rescue e : JSON::ParseException
185+
Log.warn(exception: e) { "failed to parse public_events cache for #{current_control_system.id}" }
186+
[] of JSON::Any
187+
end
188+
189+
# Registers an external attendee for a public calendar event.
190+
#
191+
# Delegates to the `register_attendee(event_id, name, email)` function on
192+
# the PublicEvents driver. The driver appends the attendee to the calendar
193+
# event and returns `true` on success.
194+
#
195+
# Guest JWTs are accepted provided the system id appears in their roles.
196+
@[AC::Route::POST("/:system_id/register", body: :params)]
197+
def register(
198+
system_id : String,
199+
params : RegistrationRequest,
200+
) : Nil
201+
if user_token.guest_scope?
202+
raise Error::Forbidden.new unless user_token.user.roles.includes?(current_control_system.id)
203+
end
204+
205+
module_id = ::PlaceOS::Driver::Proxy::System.module_id?(
206+
system_id: system_id,
207+
module_name: MODULE_NAME,
208+
index: 1,
209+
)
210+
raise Error::NotFound.new("PublicEvents module not found on system #{system_id}") unless module_id
211+
212+
remote_driver = RemoteDriver.new(
213+
sys_id: system_id,
214+
module_name: MODULE_NAME,
215+
index: 1,
216+
user_id: user_token.id,
217+
) do |mod_id|
218+
::PlaceOS::Model::Module.find!(mod_id).edge_id.as(String)
219+
end
220+
221+
result, status_code = remote_driver.exec(
222+
security: driver_clearance(user_token),
223+
function: "register_attendee",
224+
args: Array(JSON::Any).from_json([params.event_id, params.name, params.email].to_json),
225+
request_id: request_id,
226+
)
227+
228+
response.content_type = "application/json"
229+
render text: result, status: status_code
230+
rescue e : RemoteDriver::Error
231+
handle_execute_error(e)
232+
end
233+
end
234+
end

src/placeos-rest-api/controllers/webrtc.cr

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,8 @@ module PlaceOS::Api
4646
property? success : Bool
4747
end
4848

49-
class ::PlaceOS::Api::Error
50-
class RecaptchaFailed < Error
51-
end
52-
53-
class GuestAccessDisabled < Error
54-
end
55-
end
56-
5749
###############################################################################################
5850

59-
# 401 if recaptcha fails
60-
@[AC::Route::Exception(Error::RecaptchaFailed, status_code: HTTP::Status::UNAUTHORIZED)]
61-
def recaptcha_failed(error) : CommonError
62-
Log.debug { error.message }
63-
CommonError.new(error, false)
64-
end
65-
6651
JWT_SECRET = ENV["JWT_SECRET"]?.try { |k| Base64.decode_string(k) }
6752

6853
# this route provides guest access to an anonymous chat room
@@ -201,7 +186,7 @@ module PlaceOS::Api
201186
user_id : String,
202187
session_id : String? = nil,
203188
body : JSON::Any? = nil,
204-
) : Nil | Bool
189+
) : Bool?
205190
result = MANAGER.transfer(user_id, session_id, body.try &.to_json)
206191
case result
207192
in .signal_sent?

src/placeos-rest-api/error.cr

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ module PlaceOS::Api
2020

2121
record Field, field : Symbol, message : String
2222

23+
class RecaptchaFailed < Error
24+
end
25+
26+
class GuestAccessDisabled < Error
27+
end
28+
2329
class ModelValidation < Error
2430
getter failures : Array(NamedTuple(field: Symbol, reason: String))
2531

0 commit comments

Comments
 (0)