discourse/app/controllers/calendar_subscriptions_controller.rb
Rafael dos Santos Silva cda4b34e2c
FEATURE: Add calendar subscription URLs to user preferences (#38598)
- Adds a **Calendar** tab to user preferences where users can generate
ICS subscription URLs for external calendar apps (Google Calendar, Apple
Calendar, Outlook, etc.)
- Core provides a **Bookmarked Reminders** feed (always available)
- When the discourse-calendar plugin is enabled, **All Events** and **My
Events** feeds are also shown
- URLs use scoped user API keys (read-only, ICS format only) with rate
limiting and scope validation
- Adds `include_interested` parameter to EventFinder so "My Events"
includes both going and interested events
- Adds `register_calendar_subscription_feed` plugin API for plugins to
register additional ICS feeds


Start State
<img width="1328" height="362" alt="image"
src="https://github.com/user-attachments/assets/60918591-7845-4cad-90c2-0493040d76c7"
/>

After clicking on Generate
<img width="1326" height="808" alt="image"
src="https://github.com/user-attachments/assets/aa3455fc-93c7-447b-8f82-16ef95580b37"
/>


After refreshing page
<img width="1323" height="415" alt="image"
src="https://github.com/user-attachments/assets/f22c02f8-02fd-49e5-b713-99dbba794037"
/>

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2026-03-17 10:28:20 -03:00

87 lines
2.1 KiB
Ruby

# frozen_string_literal: true
class CalendarSubscriptionsController < ApplicationController
requires_login
CLIENT_ID = "calendar-subscriptions"
before_action :rate_limit_create, only: :create
def show
render json: { has_subscription: find_calendar_api_key.present?, feeds: feed_names }
end
def create
revoke_existing_key!
client =
UserApiKeyClient.find_or_create_by!(client_id: CLIENT_ID) do |c|
c.application_name = I18n.t("calendar_subscriptions.application_name")
end
scopes = build_scopes
api_key = client.keys.create!(user_id: current_user.id, scopes: scopes)
raw_key = api_key.key
render json: { key: raw_key, urls: build_urls(raw_key) }
end
def destroy
revoke_existing_key!
head :no_content
end
private
def find_calendar_api_key
UserApiKey
.active
.joins(:client)
.where(user_id: current_user.id, user_api_key_clients: { client_id: CLIENT_ID })
.first
end
def revoke_existing_key!
find_calendar_api_key&.update!(revoked_at: Time.zone.now)
end
def build_scopes
scope_names = ["bookmarks_calendar"] + plugin_feeds.map { |f| f[:scope] }
all_scopes = UserApiKeyScope.all_scopes
scope_names.uniq.filter_map do |name|
if all_scopes.key?(name.to_sym)
UserApiKeyScope.new(name: name)
else
Rails.logger.warn("Calendar subscription feed references unknown scope: #{name}. Skipping.")
nil
end
end
end
def build_urls(raw_key)
base = Discourse.base_url
username = current_user.username_lower
urls = { bookmarks: "#{base}/u/#{username}/bookmarks.ics?user_api_key=#{raw_key}" }
plugin_feeds.each do |feed|
urls[feed[:name].to_sym] = feed[:url].call(base, current_user, raw_key)
end
urls
end
def feed_names
names = ["bookmarks"]
names += plugin_feeds.map { |f| f[:name] }
names
end
def plugin_feeds
@plugin_feeds ||= DiscoursePluginRegistry.calendar_subscription_feeds
end
def rate_limit_create
RateLimiter.new(current_user, "calendar-subscriptions-create", 5, 1.minute).performed!
end
end