diff --git a/app/controllers/api/event_preferences_controller.rb b/app/controllers/api/event_preferences_controller.rb index aa34bbd7..527fe3b1 100644 --- a/app/controllers/api/event_preferences_controller.rb +++ b/app/controllers/api/event_preferences_controller.rb @@ -87,6 +87,9 @@ def set_preferenceable id = params[:google_calendar_event_id] scope = GoogleCalendarEvent.eager_load(meeting_time: { course: :faculties }) @preferenceable = id.to_s.include?("_") ? scope.find_by_public_id!(id) : scope.find(id) + # A GoogleCalendarEvent belongs to a specific user's calendar; enforce + # ownership here so another user's event id can't leak preference data. + authorize @preferenceable, :show? else render json: { error: "Preferenceable not specified" }, status: :bad_request end diff --git a/app/controllers/api/university_calendar_events_controller.rb b/app/controllers/api/university_calendar_events_controller.rb index b9e6201c..6d236284 100644 --- a/app/controllers/api/university_calendar_events_controller.rb +++ b/app/controllers/api/university_calendar_events_controller.rb @@ -6,7 +6,7 @@ class UniversityCalendarEventsController < ApiController def index authorize UniversityCalendarEvent, :index? - @events = policy_scope(UniversityCalendarEvent).upcoming.order(:start_time) + @events = policy_scope(UniversityCalendarEvent).includes(:term).upcoming.order(:start_time) @events = @events.where(category: params[:category]) if params[:category].present? @@ -52,7 +52,7 @@ def categories def holidays authorize UniversityCalendarEvent, :index? - @holidays = UniversityCalendarEvent.holidays.upcoming.order(:start_time) + @holidays = UniversityCalendarEvent.holidays.includes(:term).upcoming.order(:start_time) if params[:term_id].present? term = Term.find_by_public_id(params[:term_id]) diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index b974e9ef..4ff3d2f1 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -5,33 +5,32 @@ class UsersController < ApiController skip_before_action :authenticate_user_from_token!, only: [ :onboard ] # POST /api/user/onboard + # + # The extension sends a Google OAuth access token for the user's (personal) + # Google account — the same account they sync calendars to. We verify it with + # Google and key the account to that verified email, so a caller can only ever + # reach the account tied to a Google identity they actually control (no + # domain restriction — personal accounts are the norm). See GoogleTokenVerifier. def onboard - email = params[:email] + access_token = params[:google_access_token] || params[:access_token] preferred_name = params[:preferred_name] - if email.blank? - render json: { error: "email is required" }, status: :bad_request + if access_token.blank? + render json: { error: "google_access_token is required" }, status: :bad_request return end - if preferred_name.blank? - render json: { error: "preferred_name is required" }, status: :bad_request + verification = GoogleTokenVerifier.verify_access_token(access_token) + unless verification.success? + Rails.logger.warn("Onboard token verification failed: #{verification.error}") + render json: { error: "Invalid Google token" }, status: :unauthorized return end - name_parts = preferred_name.strip.split(" ", 2) - first_name = name_parts[0] - last_name = name_parts[1] + google_email = verification.email + user = find_or_create_onboarding_user(google_email, preferred_name, params[:wit_email]) - user = User.find_by(email: email.to_s.strip.downcase) || - User.create!( - email: email.to_s.strip.downcase, - first_name: first_name, - last_name: last_name, - password: SecureRandom.hex(24) - ) - - token = JsonWebTokenService.encode({ user_id: user.id }, nil) + token = JsonWebTokenService.encode({ user_id: user.id }) render json: { pub_id: user.public_id.delete_prefix("usr_"), jwt: token }, status: :ok rescue => e @@ -240,7 +239,7 @@ def get_processed_events_by_term .where(term_id: term.id) .includes(course: [ :faculties, - { meeting_times: [ :event_preference, { course: :faculties } ] } + { meeting_times: [ :event_preference, { rooms: :building }, { course: :faculties } ] } ]) preference_resolver = PreferenceResolver.new(current_user) @@ -358,5 +357,43 @@ def enable_notifications Rails.logger.error("Error enabling notifications for user #{current_user.id}: #{e.message}") render json: { error: "Failed to enable notifications" }, status: :internal_server_error end + + private + + # Resolves the account for a verified Google email, in this order: + # 1. a user already keyed to that Google email + # 2. a user who has connected that Google account for calendar sync + # (oauth_credentials are OAuth-verified, so this link is trustworthy) + # 3. otherwise, a fresh account keyed to the Google email + # + # Note: we deliberately do NOT link by a client-supplied WIT email — that + # value isn't verified, so trusting it would let a caller attach their token + # to someone else's existing account. Legacy accounts that never connected a + # Google account can't be auto-linked safely and start fresh. + def find_or_create_onboarding_user(google_email, preferred_name, wit_email = nil) + wit_email = wit_email.to_s.strip.downcase.presence + + existing = User.find_by(email: google_email) || + User.joins(:oauth_credentials) + .find_by(oauth_credentials: { email: google_email, provider: "google" }) + if existing + # Record the WIT email as metadata on the caller's OWN account (resolved + # from the verified token). It's never used to decide which account this + # is, so a forged value can only mislabel the caller's own record. + if wit_email && existing.wit_email != wit_email + existing.update_column(:wit_email, wit_email) # rubocop:disable Rails/SkipsModelValidations + end + return existing + end + + first_name, last_name = preferred_name.to_s.strip.split(" ", 2) + User.create!( + email: google_email, + wit_email: wit_email, + first_name: first_name, + last_name: last_name, + password: SecureRandom.hex(24) + ) + end end end diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb index 1034259b..2e4c7729 100644 --- a/app/controllers/calendars_controller.rb +++ b/app/controllers/calendars_controller.rb @@ -118,7 +118,9 @@ def generate_ical(courses, final_exams) rule = IceCube::Rule.weekly.day(day_sym).until(until_time) e.rrule = rule.to_ical.gsub(/UNTIL=(\d{8})T\d{6}Z?/, 'UNTIL=\1') else - until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59) + # End-of-day in local (Eastern) zone as UTC, so evening classes keep + # their final occurrence instead of it falling past a bare-UTC UNTIL. + until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc rule = IceCube::Rule.weekly.day(day_sym).until(until_time) e.rrule = rule.to_ical end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb deleted file mode 100644 index f54a8b70..00000000 --- a/app/controllers/users/registrations_controller.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -class Users::RegistrationsController < Devise::RegistrationsController - before_action :configure_sign_up_params, only: [ :create ] - # before_action :configure_account_update_params, only: [:update] - - # GET /resource/sign_up - # def new - # super - # end - - # POST /resource - def create - super - end - - # GET /resource/edit - # def edit - # super - # end - - # PUT /resource - # def update - # super - # end - - # DELETE /resource - # def destroy - # super - # end - - # GET /resource/cancel - # Forces the session data which is usually expired after sign - # in to be expired now. This is useful if the user wants to - # cancel oauth signing in/up in the middle of the process, - # removing all OAuth session data. - # def cancel - # super - # end - - # protected - - # If you have extra params to permit, append them to the sanitizer. - def configure_sign_up_params - devise_parameter_sanitizer.permit(:sign_up, keys: [ :first_name, :last_name ]) - end - - # If you have extra params to permit, append them to the sanitizer. - # def configure_account_update_params - # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) - # end - - # The path used after sign up. - # def after_sign_up_path_for(resource) - # super(resource) - # end - - # The path used after sign up for inactive accounts. - # def after_inactive_sign_up_path_for(resource) - # super(resource) - # end -end diff --git a/app/jobs/google_calendar_create_job.rb b/app/jobs/google_calendar_create_job.rb index ce0deff0..cf3d33e3 100644 --- a/app/jobs/google_calendar_create_job.rb +++ b/app/jobs/google_calendar_create_job.rb @@ -19,6 +19,10 @@ def perform(user_id) end end - user.sync_course_schedule(force: true) + # Route the follow-up sync through GoogleCalendarSyncJob rather than syncing + # inline: that job carries limits_concurrency keyed per user, so a + # creation-triggered sync can't race a concurrent sync and double-create + # remote events. + GoogleCalendarSyncJob.perform_later(user, force: true) end end diff --git a/app/jobs/google_calendar_event_delete_job.rb b/app/jobs/google_calendar_event_delete_job.rb new file mode 100644 index 00000000..30ef8242 --- /dev/null +++ b/app/jobs/google_calendar_event_delete_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Deletes a single event from a Google Calendar. Enqueued when a +# GoogleCalendarEvent DB row is destroyed (e.g. its meeting time was removed +# during reconcile) so the live Google event is removed too, rather than left +# behind as an untracked orphan. +class GoogleCalendarEventDeleteJob < ApplicationJob + queue_as :high + + def perform(calendar_id, google_event_id) + return if calendar_id.blank? || google_event_id.blank? + + GoogleCalendarService.new.delete_calendar_event(calendar_id, google_event_id) + end +end diff --git a/app/models/calendar_preference.rb b/app/models/calendar_preference.rb index 76891a41..fe70ce7d 100644 --- a/app/models/calendar_preference.rb +++ b/app/models/calendar_preference.rb @@ -19,8 +19,9 @@ # # Indexes # -# index_calendar_preferences_on_user_id (user_id) -# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_preferences_on_user_id (user_id) +# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_prefs_one_global_per_user (user_id) UNIQUE WHERE (scope = 0) # # Foreign Keys # @@ -41,6 +42,10 @@ class CalendarPreference < ApplicationRecord validates :scope, presence: true validates :event_type, presence: true, if: -> { scope_event_type? || scope_uni_cal_category? } validates :event_type, absence: true, if: :scope_global? + # Exactly one global preference per user. The DB unique index on + # (user_id, scope, event_type) can't enforce this because event_type is NULL + # for global rows and Postgres treats NULLs as distinct. + validate :only_one_global_preference, if: :scope_global? validates :event_type, inclusion: { in: UNI_CAL_CATEGORIES }, if: :scope_uni_cal_category? validates :title_template, length: { maximum: 500 }, allow_blank: true validates :description_template, length: { maximum: 2000 }, allow_blank: true @@ -57,6 +62,12 @@ class CalendarPreference < ApplicationRecord private + def only_one_global_preference + existing = CalendarPreference.where(user_id: user_id, scope: :global) + existing = existing.where.not(id: id) if persisted? + errors.add(:base, "a global preference already exists for this user") if existing.exists? + end + def validate_template_syntax [ :title_template, :description_template, :location_template ].each do |field| value = send(field) diff --git a/app/models/concerns/course_change_trackable.rb b/app/models/concerns/course_change_trackable.rb index cffe8e8e..a6c2411d 100644 --- a/app/models/concerns/course_change_trackable.rb +++ b/app/models/concerns/course_change_trackable.rb @@ -17,11 +17,28 @@ def self.with_enrollment_cache included do # Mark all enrolled users' calendars as needing sync when course details change after_save :mark_enrolled_users_for_sync, if: :saved_change_to_relevant_attributes? - after_destroy :mark_enrolled_users_for_sync + + # On destroy, the enrollments are removed by a dependent-destroy cascade + # (a before_destroy callback), so by the time an after_destroy hook runs the + # enrollment query returns nobody. Capture the affected users BEFORE the + # cascade (prepend), then flag them once the destroy commits. + before_destroy :capture_enrolled_users_for_sync, prepend: true + after_commit :flag_captured_enrolled_users, on: :destroy end private + def capture_enrolled_users_for_sync + @captured_enrolled_user_ids = enrolled_google_user_ids + end + + def flag_captured_enrolled_users + ids = @captured_enrolled_user_ids + return if ids.blank? + + User.where(id: ids).update_all(calendar_needs_sync: true) # rubocop:disable Rails/SkipsModelValidations + end + def saved_change_to_relevant_attributes? # Track changes to any attributes that affect calendar display relevant_attrs = %w[title start_date end_date subject course_number section_number] @@ -40,14 +57,17 @@ def mark_enrolled_users_for_sync end return unless has_enrollments - user_ids = User.joins(:enrollments) - .joins(:oauth_credentials) - .where(enrollments: { course_id: id }) - .where(oauth_credentials: { provider: "google" }) - .where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL") - .distinct - .pluck(:id) - + user_ids = enrolled_google_user_ids User.where(id: user_ids).update_all(calendar_needs_sync: true) if user_ids.any? # rubocop:disable Rails/SkipsModelValidations end + + def enrolled_google_user_ids + User.joins(:enrollments) + .joins(:oauth_credentials) + .where(enrollments: { course_id: id }) + .where(oauth_credentials: { provider: "google" }) + .where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL") + .distinct + .pluck(:id) + end end diff --git a/app/models/concerns/course_schedule_syncable.rb b/app/models/concerns/course_schedule_syncable.rb index 0141652e..2aca91fa 100644 --- a/app/models/concerns/course_schedule_syncable.rb +++ b/app/models/concerns/course_schedule_syncable.rb @@ -306,8 +306,10 @@ def build_recurrence_rule(meeting_time) end # Build weekly recurrence rule using ice_cube and export to iCalendar format. - # UNTIL is set to end-of-day UTC so the last occurrence is fully included. - until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59) + # UNTIL must be end-of-day in the *local* zone expressed as UTC. Using bare + # Time.utc(...,23,59,59) drops the final occurrence of evening (Eastern) + # classes, whose start instant falls after midnight UTC. + until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc rule = IceCube::Rule.weekly.day(meeting_time.day_of_week.to_sym).until(until_time) "RRULE:#{rule.to_ical}" end @@ -645,7 +647,8 @@ def university_event_scope(time_scope) def tbd_room?(room) return false unless room - room.number == 0 + # Room#number is a string column, so compare against the string "0". + room.number.to_s == "0" # Note: Room model in production only has 'number', not 'name' # If room.name is added later, uncomment these lines: # room.name&.downcase&.include?("tbd") || diff --git a/app/models/concerns/meeting_time_change_trackable.rb b/app/models/concerns/meeting_time_change_trackable.rb index 4a354f11..d80bb2ea 100644 --- a/app/models/concerns/meeting_time_change_trackable.rb +++ b/app/models/concerns/meeting_time_change_trackable.rb @@ -20,11 +20,19 @@ def self.with_enrollment_cache after_destroy :mark_enrolled_users_for_sync end + # Public entry point for related records (e.g. the room join table) to flag + # this meeting time's enrolled users for a calendar resync. + def resync_enrolled_users! + mark_enrolled_users_for_sync + end + private def saved_change_to_relevant_attributes? - # Track changes to any attributes that affect calendar display - relevant_attrs = %w[begin_time end_time day_of_week start_date end_date room_id] + # Track changes to any attributes that affect calendar display. + # NOTE: rooms live in the course_meeting_time_rooms join table, not a + # room_id column here — room changes are flagged from that model instead. + relevant_attrs = %w[begin_time end_time day_of_week start_date end_date] relevant_attrs.any? { |attr| saved_change_to_attribute?(attr) } end diff --git a/app/models/course/meeting_time_room.rb b/app/models/course/meeting_time_room.rb index f1d691d1..0ff0710d 100644 --- a/app/models/course/meeting_time_room.rb +++ b/app/models/course/meeting_time_room.rb @@ -25,4 +25,15 @@ class Course::MeetingTimeRoom < ApplicationRecord belongs_to :meeting_time, class_name: "Course::MeetingTime" belongs_to :room + + # A room being added or removed changes the calendar event location, so flag + # the meeting time's enrolled users for resync (the meeting_time row itself + # doesn't change, so its own callbacks won't fire). + after_commit :resync_enrolled_users, on: [ :create, :destroy ] + + private + + def resync_enrolled_users + meeting_time&.resync_enrolled_users! + end end diff --git a/app/models/friendship.rb b/app/models/friendship.rb index e72affe7..31b37967 100644 --- a/app/models/friendship.rb +++ b/app/models/friendship.rb @@ -18,6 +18,7 @@ # index_friendships_on_requester_id (requester_id) # index_friendships_on_requester_id_and_addressee_id (requester_id,addressee_id) UNIQUE # index_friendships_on_requester_id_and_status (requester_id,status) +# index_friendships_on_unordered_pair (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)) UNIQUE # # Foreign Keys # diff --git a/app/models/google_calendar.rb b/app/models/google_calendar.rb index a96862f5..edc87935 100644 --- a/app/models/google_calendar.rb +++ b/app/models/google_calendar.rb @@ -16,9 +16,9 @@ # # Indexes # -# index_google_calendars_on_google_calendar_id (google_calendar_id) UNIQUE -# index_google_calendars_on_last_synced_at (last_synced_at) -# index_google_calendars_on_oauth_credential_id (oauth_credential_id) +# index_google_calendars_on_google_calendar_id (google_calendar_id) UNIQUE +# index_google_calendars_on_last_synced_at (last_synced_at) +# index_google_calendars_on_oauth_credential_id_unique (oauth_credential_id) UNIQUE # # Foreign Keys # @@ -34,6 +34,8 @@ class GoogleCalendar < ApplicationRecord has_one :user, through: :oauth_credential validates :google_calendar_id, presence: true, uniqueness: true + # One course calendar per OAuth credential — the app treats this as a has_one. + validates :oauth_credential_id, uniqueness: true before_destroy :enqueue_google_calendar_deletion diff --git a/app/models/google_calendar_event.rb b/app/models/google_calendar_event.rb index 671c458e..fca24c04 100644 --- a/app/models/google_calendar_event.rb +++ b/app/models/google_calendar_event.rb @@ -60,6 +60,13 @@ class GoogleCalendarEvent < ApplicationRecord serialize :recurrence, coder: JSON + # Set by the sync service when it has already deleted the remote event itself, + # so destroying the DB row doesn't enqueue a redundant deletion. + attr_accessor :skip_remote_deletion + + before_destroy :capture_remote_event_ref + after_commit :enqueue_remote_event_deletion, on: :destroy + TRACKABLE_FIELDS = %w[summary location description start_time end_time].freeze scope :for_meeting_time, ->(id) { where(meeting_time_id: id) } @@ -140,6 +147,26 @@ def clear_edited_fields!(fields = nil) private + # Records the (calendar, event) pair before the row is destroyed so we can + # delete the live Google event after the transaction commits. Skipped when the + # sync service already handled the remote delete, or when the whole calendar is + # being torn down (delete_calendar cleans up its events server-side). + def capture_remote_event_ref + return if skip_remote_deletion + return if destroyed_by_association&.foreign_key.to_s == "google_calendar_id" + + @remote_event_ref = [ google_calendar&.google_calendar_id, google_event_id ] + end + + def enqueue_remote_event_deletion + return if @remote_event_ref.blank? + + calendar_id, event_id = @remote_event_ref + return if calendar_id.blank? || event_id.blank? + + GoogleCalendarEventDeleteJob.perform_later(calendar_id, event_id) + end + def only_one_event_type_associated event_types = [ meeting_time_id, final_exam_id, university_calendar_event_id ].compact return unless event_types.size != 1 diff --git a/app/models/user.rb b/app/models/user.rb index 009a0b96..4b8c9815 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,6 +29,7 @@ # sign_in_count :integer default(0), not null # unconfirmed_email :string # unlock_token :string +# wit_email :string # created_at :datetime not null # updated_at :datetime not null # @@ -41,6 +42,7 @@ # index_users_on_email (email) UNIQUE # index_users_on_last_calendar_sync_at (last_calendar_sync_at) # index_users_on_reset_password_token (reset_password_token) UNIQUE +# index_users_on_wit_email (wit_email) # class User < ApplicationRecord include GoogleOauthable @@ -48,7 +50,10 @@ class User < ApplicationRecord include CalendarTokenable include EncodedIds::HashidIdentifiable - devise :database_authenticatable, :registerable, + # No :registerable — accounts are provisioned only via Google OAuth + # (see AuthController#handle_user_login, which enforces the @wit.edu gate). + # Self-service password signup would bypass that domain restriction. + devise :database_authenticatable, :recoverable, :rememberable, :validatable, :confirmable, :trackable, :timeoutable, :lockable set_public_id_prefix :usr diff --git a/app/models/user_extension_config.rb b/app/models/user_extension_config.rb index fd83b4fd..3b8d778f 100644 --- a/app/models/user_extension_config.rb +++ b/app/models/user_extension_config.rb @@ -19,7 +19,7 @@ # # Indexes # -# index_user_extension_configs_on_user_id (user_id) +# index_user_extension_configs_on_user_id_unique (user_id) UNIQUE # # Foreign Keys # @@ -32,6 +32,9 @@ class UserExtensionConfig < ApplicationRecord belongs_to :user + # One config per user (the app treats this as a has_one). + validates :user_id, uniqueness: true + before_save :clear_categories_when_sync_disabled after_update :sync_calendar_if_settings_changed diff --git a/app/serializers/calendar_preference_serializer.rb b/app/serializers/calendar_preference_serializer.rb index 62675248..51e09978 100644 --- a/app/serializers/calendar_preference_serializer.rb +++ b/app/serializers/calendar_preference_serializer.rb @@ -19,8 +19,9 @@ # # Indexes # -# index_calendar_preferences_on_user_id (user_id) -# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_preferences_on_user_id (user_id) +# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_prefs_one_global_per_user (user_id) UNIQUE WHERE (scope = 0) # # Foreign Keys # diff --git a/app/services/calendar_template_renderer.rb b/app/services/calendar_template_renderer.rb index a68c5888..a0ccde23 100644 --- a/app/services/calendar_template_renderer.rb +++ b/app/services/calendar_template_renderer.rb @@ -217,10 +217,32 @@ def format_datetime(datetime) datetime.strftime("%-I:%M %p") end - def check_for_disallowed_tags(_parsed_template) + # Templates may only contain plain text and {{ variable }} interpolation. + # Liquid tags ({% ... %}) — loops, includes, conditionals — are rejected so + # a user-supplied template can't do control flow, resource exhaustion, or + # pull in other content. + def check_for_disallowed_tags(parsed_template) + tags = collect_tag_names(parsed_template.root) + if tags.any? + raise InvalidTemplateError, "Disallowed tags: #{tags.uniq.join(', ')}" + end + true end + def collect_tag_names(node, names = []) + return names unless node.respond_to?(:nodelist) && node.nodelist + + node.nodelist.each do |child| + if child.is_a?(Liquid::Tag) + names << (child.respond_to?(:tag_name) ? child.tag_name : child.class.name.demodulize.underscore) + end + collect_tag_names(child, names) + end + + names + end + def extract_variables(parsed_template) variables = Set.new extract_variables_from_node(parsed_template.root, variables) diff --git a/app/services/catalog_import_service.rb b/app/services/catalog_import_service.rb index 7834f25d..27aa3c8a 100644 --- a/app/services/catalog_import_service.rb +++ b/app/services/catalog_import_service.rb @@ -154,10 +154,15 @@ def process_course(course_data) end if meeting_times.any? - MeetingTimesIngestService.call( + kept_ids = MeetingTimesIngestService.call( course: course, raw_meeting_times: meeting_times ) + + # Remove meeting times that no longer exist upstream (e.g. Banner changed a + # section's day/time), but only when the ingest produced rows — never wipe + # the course from an empty result. Preserves untouched rows and their events. + course.meeting_times.where.not(id: kept_ids).destroy_all if kept_ids.any? else Rails.logger.warn("No meeting times found for course CRN #{crn}") end diff --git a/app/services/course_processor_service.rb b/app/services/course_processor_service.rb index aae0a7db..c3c0887a 100644 --- a/app/services/course_processor_service.rb +++ b/app/services/course_processor_service.rb @@ -191,13 +191,18 @@ def call Rails.logger.info("Linked FinalExam for CRN #{course.crn} to course #{course.id}") end - course.meeting_times.destroy_all - - MeetingTimesIngestService.call( + # Reconcile rather than destroy_all: the Course/MeetingTime graph is + # shared across every student in the CRN, and destroying it would orphan + # all their Google Calendar events. Upsert by content key, then remove + # only rows the ingest didn't touch — and only when the scrape actually + # produced meeting times, so a partial/empty scrape can't wipe the course. + kept_ids = MeetingTimesIngestService.call( course: course, raw_meeting_times: meeting_times ) + course.meeting_times.where.not(id: kept_ids).destroy_all if kept_ids.any? + process_faculty(course, faculty_data) Enrollment.find_or_create_by!(user: user, course: course, term: term) diff --git a/app/services/google_calendar_service.rb b/app/services/google_calendar_service.rb index 2083a13b..d11a572a 100644 --- a/app/services/google_calendar_service.rb +++ b/app/services/google_calendar_service.rb @@ -183,6 +183,19 @@ def list_calendars with_rate_limit_handling { service.list_calendar_lists } end + # Deletes a single event from a calendar using the service account (which owns + # all app-created calendars). Used when a GoogleCalendarEvent row is destroyed + # so the live Google event doesn't linger as an orphan. Treats a missing event + # as success. + def delete_calendar_event(calendar_id, google_event_id) + service = service_account_calendar_service + with_rate_limit_handling { service.delete_event(calendar_id, google_event_id) } + rescue Google::Apis::ClientError => e + raise unless e.status_code == 404 + + Rails.logger.info("Event #{google_event_id} already absent from calendar #{calendar_id}") + end + def delete_calendar(calendar_id) google_calendar = GoogleCalendar.find_by(google_calendar_id: calendar_id) @@ -432,6 +445,14 @@ def create_event_in_calendar(service, google_calendar, course_event, preference_ end google_calendar.google_calendar_events.create!(event_attributes) + rescue ActiveRecord::RecordNotUnique + # A concurrent sync already created the tracking row for this event, so the + # insert_event above produced a duplicate remote event. Remove the duplicate + # we just created rather than leaving it orphaned on the calendar. + Rails.logger.warn({ message: "Duplicate event race — removing redundant remote event", + user_id: user&.id, calendar_id: calendar_id, google_event_id: created_event&.id }.to_json) + with_rate_limit_handling { service.delete_event(calendar_id, created_event.id) } if created_event&.id + nil end def update_event_in_calendar(service, google_calendar, db_event, course_event, force: false, preference_resolver: nil, template_renderer: nil) @@ -512,12 +533,14 @@ def update_event_in_calendar(service, google_calendar, db_event, course_event, f def delete_event_from_calendar(service, google_calendar, db_event) calendar_id = google_calendar.google_calendar_id with_rate_limit_handling { service.delete_event(calendar_id, db_event.google_event_id) } + db_event.skip_remote_deletion = true db_event.destroy rescue Google::Apis::ClientError => e raise unless e.status_code == 404 Rails.logger.warn({ message: "Event not found in Google Calendar, removing from database", user_id: user.id, google_event_id: db_event.google_event_id }.to_json) + db_event.skip_remote_deletion = true db_event.destroy end diff --git a/app/services/google_token_verifier.rb b/app/services/google_token_verifier.rb new file mode 100644 index 00000000..ad68abd8 --- /dev/null +++ b/app/services/google_token_verifier.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Verifies a Google-issued OAuth access token server-side. +# +# The Chrome extension obtains a token for the signed-in Google user (via +# chrome.identity) and sends it to /api/user/onboard. We validate it against +# Google's tokeninfo endpoint, confirm it was issued to *our* OAuth client +# (audience check, prevents token replay from other apps), and return the +# Google-verified email. This is what lets onboard trust the email instead of +# accepting whatever string the caller sends. +class GoogleTokenVerifier + TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + + Result = Struct.new(:success, :email, :email_verified, :error, keyword_init: true) do + def success? + success + end + end + + def self.verify_access_token(access_token) + new.verify_access_token(access_token) + end + + def verify_access_token(access_token) + return failure("missing token") if access_token.blank? + + response = connection.get(TOKENINFO_URL, access_token: access_token) + + unless response.success? + return failure("token rejected by Google (status #{response.status})") + end + + info = response.body + info = JSON.parse(info) if info.is_a?(String) + + audience = info["aud"] || info["azp"] || info["audience"] || info["issued_to"] + unless audience.present? && allowed_client_ids.include?(audience) + return failure("token audience mismatch") + end + + email = info["email"].to_s.strip.downcase + return failure("token has no email") if email.blank? + + Result.new( + success: true, + email: email, + email_verified: info["email_verified"].to_s == "true" || info["verified_email"].to_s == "true" + ) + rescue Faraday::Error, JSON::ParserError => e + failure("token verification failed: #{e.message}") + end + + private + + # The token may be issued to the web OAuth client (dashboard/calendar flow) or + # the Chrome extension's own OAuth client. Both are accepted; add the + # extension client id to GOOGLE_OAUTH_CLIENT_IDS (comma-separated). Mirrors the + # audience allowlist used by RiscValidationService. + def allowed_client_ids + @allowed_client_ids ||= begin + ids = ENV["GOOGLE_OAUTH_CLIENT_IDS"].to_s.split(",").map(&:strip).reject(&:blank?) + web_client_id = Rails.application.credentials.dig(:google, :client_id) + ids << web_client_id if web_client_id.present? + ids.uniq + end + end + + def failure(message) + Result.new(success: false, error: message) + end + + def connection + @connection ||= Faraday.new do |f| + f.options.open_timeout = OPEN_TIMEOUT + f.options.timeout = READ_TIMEOUT + f.response :json, content_type: /\bjson$/ + end + end +end diff --git a/app/services/json_web_token_service.rb b/app/services/json_web_token_service.rb index 805602ff..54063ed7 100644 --- a/app/services/json_web_token_service.rb +++ b/app/services/json_web_token_service.rb @@ -5,18 +5,22 @@ class JsonWebTokenService Rails.application.credentials.secret_key_base || Rails.application.secret_key_base - def self.encode(payload, exp = 24.hours.from_now) - payload[:exp] = exp.to_i if exp.present? + # Default lifetime for API tokens. Every token gets an expiry — a token + # without an `exp` claim would never expire, which is a security hazard. + DEFAULT_TTL = 14.days + + def self.encode(payload, exp = DEFAULT_TTL.from_now) + raise ArgumentError, "JWT expiry is required" if exp.blank? + + payload = payload.dup + payload[:exp] = exp.to_i JWT.encode(payload, SECRET_KEY) end + # Verifies signature (HS256) and expiration. Returns nil for any invalid, + # tampered, or expired token. def self.decode(token) - decoded = JWT.decode(token, SECRET_KEY, true, { verify_expiration: false, algorithm: "HS256" })[0] - - if decoded["exp"] - decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: "HS256" })[0] - end - + decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: "HS256" })[0] ActiveSupport::HashWithIndifferentAccess.new(decoded) rescue JWT::DecodeError, JWT::ExpiredSignature nil diff --git a/app/services/meeting_times_ingest_service.rb b/app/services/meeting_times_ingest_service.rb index ac9c970f..79e65f95 100644 --- a/app/services/meeting_times_ingest_service.rb +++ b/app/services/meeting_times_ingest_service.rb @@ -9,14 +9,20 @@ def initialize(course:, raw_meeting_times:, **options) @compute_hours_week = options.fetch(:compute_hours_week, true) @building_cache = {} @room_cache = {} + @kept_meeting_time_ids = [] super() end + # Upserts meeting times by content key (never destroys) and returns the ids of + # the meeting times it created or updated. Callers use this to reconcile — + # deleting only the stale rows the ingest didn't touch — instead of a blanket + # destroy_all, which would orphan every enrolled student's calendar events. def call preload_buildings_and_rooms raw_meeting_times.each do |mt| ingest_one(mt) end + @kept_meeting_time_ids.uniq end private @@ -158,6 +164,7 @@ def ingest_one(mt) meeting_time = @meeting_time_cache&.[](cache_key) || Course::MeetingTime.new(lookup_attrs) meeting_time.assign_attributes(update_attrs) meeting_time.save! + @kept_meeting_time_ids << meeting_time.id desired_room_ids = rooms_for_mt.map(&:id).to_set existing_room_ids = meeting_time.meeting_time_rooms.map(&:room_id).to_set diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb deleted file mode 100644 index 19bb019b..00000000 --- a/app/views/devise/registrations/edit.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -
<%= f.label :email %>
-<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
-<%= f.label :password %> (leave blank if you don't want to change it)
-<%= f.password_field :password, autocomplete: "new-password" %>
- <% if @minimum_password_length %> -<%= @minimum_password_length %> characters minimum
- <% end %> -<%= f.label :password_confirmation %>
-<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
-<%= f.label :current_password %> (we need your current password to confirm your changes)
-<%= f.password_field :current_password, autocomplete: "current-password" %>
-<%= f.label :email %>
-<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
-<%= f.label :password %>
- <% if @minimum_password_length %> -(<%= @minimum_password_length %> characters minimum)
- <% end %> -<%= f.password_field :password, autocomplete: "new-password" %>
-<%= f.label :password_confirmation %>
-<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
-