FeedBunch-app/app/models/user.rb
# frozen_string_literal: true
require 'folder_manager'
require 'url_subscriber'
require 'feed_refresh_manager'
require 'entry_state_manager'
require 'entries_pagination'
require 'feeds_pagination'
require 'opml_importer'
require 'opml_exporter'
require 'subscriptions_manager'
require 'etag_calculator'
##
# User model. Each instance of this class represents a single user that can log in to the application
# (or at least that has passed through the signup process but has not yet confirmed his email).
#
# This class has been created by installing the Devise[https://github.com/plataformatec/devise] gem and
# running the following commands:
# rails generate devise:install
# rails generate devise User
#
# The Devise[https://github.com/plataformatec/devise] gem manages authentication in this application. To
# learn more about Devise visit:
# {https://github.com/plataformatec/devise}[https://github.com/plataformatec/devise]
#
# Beyond the attributes added to this class by Devise[https://github.com/plataformatec/devise] for authentication,
# Feedbunch establishes relationships between the User model and the following models:
#
# - FeedSubscription: Each user can be subscribed to many feeds, but a single subscription belongs to a single user (one-to-many relationship).
# - Feed, through the FeedSubscription model: This enables us to retrieve the feeds a user is subscribed to.
# - Folder: Each user can have many folders and each folder belongs to a single user (one-to-many relationship).
# - Entry, through the Feed model: This enables us to retrieve all entries for all feeds a user is subscribed to.
# - EntryState: This enables us to retrieve the state (read or unread) of all entries for all feeds a user is subscribed to.
# - OpmlImportJobState: This indicates whether the user has ever started an OPML import, and in this case it gives information about the import
# process (whether it's still running or not, number of feeds processed, etc).
# - OpmlExportJobState: This indicates whether the user has ever started an OPML export, and in this case it gives information about the import
# process state.
# - RefreshFeedJobState: Each instance of this class associated with a user represents an ocurrence of the user requesting
# a refresh of a feed. The state attribute of the instance indicates if the refresh is running, successfully finished,
# or finished with an error.
# - SubscribeJobState: Each instance of this class associated with a user represents an ocurrence of the user trying
# to subscribe to a feed. The state attribute of the instance indicates if the subscription is running, successfully
# finished or finished with an error.
#
# Also, the User model has the following attributes:
#
# - admin: Boolean that indicates whether the user is an administrator. This attribute is used to restrict access to certain
# functionality, like ActiveAdmin and Sidekiq administration.
# - name: text with the username, to be displayed in the app. Usernames are unique. Defaults to the value of the "email" attribute.
# - locale: locale (en, es etc) in which the user wants to see the application. By default "en".
# - timezone: name of the timezone (Europe/Madrid, UTC etc) to which the user wants to see times localized. By default "UTC".
# - quick_reading: boolean indicating whether the user has enabled Quick Reading mode (in which entries are marked as read
# as soon as they are scrolled by) or not. False by default.
# - open_all_entries: boolean indicating whether the user wants to see all entries open by default when they are loaded.
# False by default.
# - show_main_tour: boolean indicating whether the main app tour should be shown when the user enters the application. True
# by default
# - show_mobile_tour: boolean indicating whether the mobile app tour should be shown when the user enters the application.
# True by default.
# - show_feed_tour: boolean indicating whether the feed tour should be shown. True by default.
# - show_entry_tour: boolean indicating whether the entry tour should be shown. True by default.
# - show_kb_shortcuts_tour: boolean indicating whether the keyboard shortcuts tour should be shown. True by default.
# - subscriptions_updated_at: datetime when subscriptions were updated for the last time. Events that
# update this attribute are:
# - subscribing to a new feed
# - unsubscribing from a feed
# - changing the unread entries count for a feed
# - changing a feed title
# - changing a feed URL
# - moving a feed into or out of a folder
# - folders_updated_at: datetime when folders were updated for the last time. Events that
# update this attribute are:
# - creating a folder
# - destroying a folder
# - refresh_feed_jobs_updated_at: datetime when refresh feed jobs for this user were updated for the last time.
# Events that update this attribute are:
# - creating a refresh feed job state
# - destroying a refresh feed job state
# - updating a refresh feed job state
# - subscribe_jobs_updated_at: datetime when subscribe feed jobs for this user were updated for the last time.
# Events that update this attribute are:
# - creating a subscribe feed job state
# - destroying a subscribe feed job state
# - updating a subscribe feed job state
# - config_updated_at: datetime when the config for this user was last updated. This attribute is
# updated every time one of these attributes is changed:
# - quick_reading
# - open_all_entries
# - show_main_tour
# - show_mobile_tour
# - show_feed_tour
# - show_entry_tour
# - show_kb_shortcuts_tour
# - kb_shortcuts_enabled
# - user_data_updated_at: datetime when user data for this user was last updated. This attribute is
# updated every time one of these happens:
# - user subscribes to a new feed
# - user unsubscribes from a feed
# - first_confirmation_reminder_sent, first_confirmation_reminder_sent: booleans that indicates if the first and second
# confirmation reminder emails have been sent to a user. This happens when a user signs up but never clicks on the link
# in the confirmation email. Each of the two confirmation reminders will be sent just once.
# - kb_shortcuts_enabled: boolean that indicates if keyboard shortcuts are enabled for a user. True by default.
#
# When a user is subscribed to a feed (this is, when a feed is added to the user.feeds array), EntryState instances
# are saved to mark all its entries as unread for this user.
#
# Conversely when a user unsubscribes from a feed (this is, when a feed is removed from the user.feeds array), all
# EntryState instances for its entries and for this user are deleted; the app does not store read/unread state for
# entries that belong to feeds to which the user is not subscribed.
#
# It is not mandatory that a user be suscribed to any feeds (in fact when a user first signs up he won't
# have any suscriptions).
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :token_authenticatable, :confirmable,
# :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable,
:confirmable, :lockable, :timeoutable
has_many :feed_subscriptions, dependent: :destroy,
after_add: :mark_unread_entries
has_many :feeds, through: :feed_subscriptions
has_many :folders, dependent: :destroy
has_many :entries, through: :feeds
has_many :entry_states, dependent: :delete_all
has_one :opml_import_job_state, dependent: :destroy
has_one :opml_export_job_state, dependent: :destroy
has_many :refresh_feed_job_states, dependent: :destroy
has_many :subscribe_job_states, dependent: :destroy
validates :admin, inclusion: {in: [true, false]}
validates :name, presence: true, uniqueness: {case_sensitive: true}
validates :locale, presence: true
validates :timezone, presence: true
validates :quick_reading, inclusion: {in: [true, false]}
validates :open_all_entries, inclusion: {in: [true, false]}
validates :show_main_tour, inclusion: {in: [true, false]}
validates :show_mobile_tour, inclusion: {in: [true, false]}
validates :show_feed_tour, inclusion: {in: [true, false]}
validates :show_entry_tour, inclusion: {in: [true, false]}
validates :show_kb_shortcuts_tour, inclusion: {in: [true, false]}
validates :first_confirmation_reminder_sent, inclusion: {in: [true, false]}
validates :second_confirmation_reminder_sent, inclusion: {in: [true, false]}
validates :kb_shortcuts_enabled, inclusion: {in: [true, false]}
before_save :before_save_user
after_save :after_save_user
before_validation :default_values
before_destroy :before_destroy_user, prepend: true
##
# Retrieves feeds subscribed by the user. See FeedsPagination#subscribed_feeds.
def subscribed_feeds(include_read: false, page: nil)
FeedsPagination.subscribed_feeds self, include_read: include_read, page: page
end
##
# Retrieves feeds subscribed by the user. See FolderManager#folder_feeds.
def folder_feeds(folder, include_read: false)
FolderManager.folder_feeds folder, self, include_read: include_read
end
##
# Retrieve entries from a feed. See EntriesPagination#feed_entries
def feed_entries(feed, include_read: false, page: nil)
EntriesPagination.feed_entries feed, self, include_read: include_read, page: page
end
##
# Retrieve unread entries from a folder. See EntriesPagination#folder_entries
def folder_entries(folder, include_read: false, page: nil)
EntriesPagination.folder_entries folder, self, include_read: include_read, page: page
end
##
# Retrieve the number of unread entries in a feed for this user.
# See SubscriptionsManager#unread_feed_entries_count
def feed_unread_count(feed)
SubscriptionsManager.feed_unread_count feed, self
end
##
# Move a feed to a folder. See FolderManager#move_feed_to_folder
def move_feed_to_folder(feed, folder: nil, folder_title: nil)
FolderManager.move_feed_to_folder feed, self, folder: folder, folder_title: folder_title
end
##
# Refresh a single feed. See FeedRefreshManager#refresh
def refresh_feed(feed)
FeedRefreshManager.refresh feed, self
end
##
# Find a refresh_feed_job_state belonging to the user
def find_refresh_feed_job_state(job_id)
return self.refresh_feed_job_states.find job_id
end
##
# Subscribe to a feed. See UrlSubscriber#subscribe
def subscribe(url)
subscribed_feed = UrlSubscriber.subscribe url, self
end
##
# Find a subscribe_job_state belonging to the user
def find_subscribe_job_state(job_id)
return self.subscribe_job_states.find job_id
end
##
# Enqueue a job to subscribe to a feed. See UrlSubscriber#enqueue_subscribe_job
def enqueue_subscribe_job(url)
UrlSubscriber.enqueue_subscribe_job url, self
end
##
# Unsubscribe from a feed. See FeedUnsubscriber#unsubscribe
def unsubscribe(feed)
SubscriptionsManager.remove_subscription feed, self
end
##
# Enqueue a job to unsubscribe from a feed. See UrlSubscriber#enqueue_unsubscribe_job
def enqueue_unsubscribe_job(feed)
SubscriptionsManager.enqueue_unsubscribe_job feed, self
end
##
# Change the read/unread state of entries for this user. See EntryStateManager#change_entries_state
def change_entries_state(entry, state, whole_feed: false, whole_folder: false, all_entries: false)
EntryStateManager.change_entries_state entry, state, self, whole_feed: whole_feed, whole_folder: whole_folder, all_entries: all_entries
end
##
# Import an OPML (optionally zipped) with subscription data, and subscribe the user to the feeds
# in it. See OpmlImporter#enqueue_import_job
def import_subscriptions(file)
OpmlImporter.enqueue_import_job file, self
end
##
# Export an OPML file with the user's subscriptions.
# See OpmlExporter#enqueue_export_job
def export_subscriptions
OpmlExporter.enqueue_export_job self
end
##
# Get a previously exported OPML file.
# See OpmlExporter.get_export
def get_opml_export
OpmlExporter.get_export self
end
##
# Change the visibility of the alert related to the OPML import state.
# Receives a boolean argument and sets the alert to visible (if true) or hidden (if false).
def set_opml_import_job_state_visible(visible)
self.opml_import_job_state.update show_alert: visible
end
##
# Change the visibility of the alert related to the OPML export state.
# Receives a boolean argument and sets the alert to visible (if true) or hidden (if false).
def set_opml_export_job_state_visible(visible)
self.opml_export_job_state.update show_alert: visible
end
##
# Immediately lock the user account so that it cannot log in. Enqueue a job to destroy
# the user.
# Exception: if the user being deleted is the demo user, this method does nothing. The demo user cannot be deleted.
def delete_profile
if Feedbunch::Application.config.demo_enabled
demo_email = Feedbunch::Application.config.demo_email
return nil if self.email == demo_email
end
self.lock_access! send_instructions: false
DestroyUserWorker.perform_async self.id
end
##
# Update the user configuration.
# Receives as optional named arguments the supported config values that can be set:
# - show_main_tour (boolean): whether to show the main application tour
# - show_mobile_tour (boolean): whether to show the mobile application tour
# - show_feed_tour (boolean): whether to show the feed application tour
# - show_entry_tour (boolean): whether to show the entry application tour
# - show_kb_shortcuts_tour (boolean): whether to show the keyboard shortcuts application tour
def update_config(show_main_tour: nil, show_mobile_tour: nil, show_feed_tour: nil, show_entry_tour: nil,
show_kb_shortcuts_tour: nil)
new_config = {}
new_config[:show_main_tour] = show_main_tour if !show_main_tour.nil?
new_config[:show_mobile_tour] = show_mobile_tour if !show_mobile_tour.nil?
new_config[:show_feed_tour] = show_feed_tour if !show_feed_tour.nil?
new_config[:show_entry_tour] = show_entry_tour if !show_entry_tour.nil?
new_config[:show_kb_shortcuts_tour] = show_kb_shortcuts_tour if !show_kb_shortcuts_tour.nil?
Rails.logger.info "Updating user #{self.id} - #{self.email} with show_main_tour #{show_main_tour}, " +
"show_mobile_tour #{show_mobile_tour}, show_feed_tour #{show_feed_tour}, " +
"show_entry_tour #{show_entry_tour}, show_kb_shortcuts_tour #{show_kb_shortcuts_tour}"
self.update new_config if new_config.length > 0
end
private
##
# Operations necessary before saving a User in the database:
# - ensure that the encrypted_password is encoded as utf-8
def before_save_user
self.encrypted_password.encode! 'utf-8'
# If demo is enabled, demo user cannot change email or password nor be locked
if Feedbunch::Application.config.demo_enabled
demo_email = Feedbunch::Application.config.demo_email
if email_changed? && self.email_was == demo_email
Rails.logger.info 'Somebody attempted to change the demo user email. Blocking the attempt.'
self.errors.add :email, 'Cannot change demo user email'
self.email = demo_email
end
demo_password = Feedbunch::Application.config.demo_password
if encrypted_password_changed? && self.email == demo_email
Rails.logger.info 'Somebody attempted to change the demo user password. Blocking the attempt.'
self.errors.add :password, 'Cannot change demo user password'
self.password = demo_password
end
if locked_at_changed? && self.email == demo_email
Rails.logger.info 'Keeping demo user from being locked because of too many authentication failures'
self.locked_at = nil
end
if unlock_token_changed? && self.email == demo_email
Rails.logger.info 'Removing unlock token for demo user, demo user cannot be locked out'
self.unlock_token = nil
end
end
end
##
# Do not destroy demo user.
# When destroying user, mass-destroy objects that depend on it (feed subscriptions etc) bypassing validations
# and callbacks for performance.
def before_destroy_user
indestructible_demo_user
self.opml_import_job_state.opml_import_failures.delete_all
self.opml_import_job_state.delete
self.opml_export_job_state.delete
self.refresh_feed_job_states.delete_all
self.subscribe_job_states.delete_all
self.folders.delete_all
self.entry_states.delete_all
# feed subscriptions are deleted WITH callbacks, to take care of the possible deletion of feeds with no more
# subscribed users
self.feed_subscriptions.destroy_all
end
##
# If the user being destroyed is the demo user, throw an error. This prevents the demo user from being actually destroyed.
def indestructible_demo_user
if Feedbunch::Application.config.demo_enabled
demo_email = Feedbunch::Application.config.demo_email
throw :abort if self.email == demo_email
end
return true
end
##
# Operations after saving a user in the db:
# - create a new OpmlImportJobState instance for the user with state "NONE" if it doesn't already exist (to indicate that
# the user has never ran an OPML import).
# - create a new OpmlExportJobState instance for the user with state "NONE" if it doesn't already exist (to indicate that
# the user has never ran an OPML export).
# - update the config_updated_at attribute to the current datetime if one of these attributes has changed value:
# - quick_reading
# - open_all_entries
# - show_main_tour
# - show_mobile_tour
# - show_feed_tour
# - show_entry_tour
# - show_kb_shortcuts_tour
# - kb_shortcuts_enabled
def after_save_user
if self.opml_import_job_state.blank?
self.create_opml_import_job_state state: OpmlImportJobState::NONE
Rails.logger.debug "User #{self.email} has no OpmlImportJobState, creating one with state NONE"
end
if self.opml_export_job_state.blank?
self.create_opml_export_job_state state: OpmlExportJobState::NONE
Rails.logger.debug "User #{self.email} has no OpmlExportJobState, creating one with state NONE"
end
if saved_change_to_quick_reading? || saved_change_to_open_all_entries? ||
saved_change_to_show_main_tour? || saved_change_to_show_mobile_tour? ||
saved_change_to_show_feed_tour? || saved_change_to_show_entry_tour? ||
saved_change_to_show_kb_shortcuts_tour? || saved_change_to_kb_shortcuts_enabled?
update_column :config_updated_at, Time.zone.now
end
end
##
# Give the following default values to the user, in case no value or an invalid value is set:
# - locale: 'en'
# - timezone: 'UTC'
# - admin: false
# - quick_reading: false
# - open_all_entries: false
# - show_main_tour, show_mobile_tour, show_feed_tour: show_entry_tour, show_kb_shortcuts_tour: true
# - name: defaults to the value of the "email" attribute
# - subscriptions_updated_at: current date/time
# - first_confirmation_reminder_sent, second_confirmation_reminder_sent: false
def default_values
# Convert the symbols for the available locales to strings, to be able to compare with the user locale
# NOTE.- don't do the opposite (converting the user locale to a symbol before checking if it's included in the
# array of available locales) because memory allocated for symbols is never released by ruby, which means an
# attacker could cause a memory leak by creating users with weird unavailable locales.
available_locales = I18n.available_locales.map {|l| l.to_s}
if !available_locales.include? self.locale
Rails.logger.info "User #{self.email} has unsupported locale #{self.locale}. Defaulting to locale 'en' instead"
self.locale = 'en'
end
timezone_names = ActiveSupport::TimeZone.all.map{|tz| tz.name}
if !timezone_names.include? self.timezone
Rails.logger.info "User #{self.email} has unsupported timezone #{self.timezone}. Defaulting to timezone 'UTC' instead"
self.timezone = 'UTC'
end
if self.admin == nil
Rails.logger.info "User #{self.email} has unsupported admin #{self.admin}. Defaulting to admin 'false' instead"
self.admin = false
end
if self.quick_reading == nil
Rails.logger.info "User #{self.email} has unsupported quick_reading #{self.quick_reading}. Defaulting to quick_reading 'false' instead"
self.quick_reading = false
end
if self.open_all_entries == nil
Rails.logger.info "User #{self.email} has unsupported open_all_entries #{self.open_all_entries}. Defaulting to open_all_entries 'false' instead"
self.open_all_entries = false
end
if self.show_main_tour == nil
Rails.logger.info "User #{self.email} has unsupported show_main_tour #{self.show_main_tour}. Defaulting to show_main_tour 'true' instead"
self.show_main_tour = true
end
if self.show_mobile_tour == nil
Rails.logger.info "User #{self.email} has unsupported show_mobile_tour #{self.show_mobile_tour}. Defaulting to show_mobile_tour 'true' instead"
self.show_mobile_tour = true
end
if self.show_feed_tour == nil
Rails.logger.info "User #{self.email} has unsupported show_feed_tour #{self.show_feed_tour}. Defaulting to show_feed_tour 'true' instead"
self.show_feed_tour = true
end
if self.show_entry_tour == nil
Rails.logger.info "User #{self.email} has unsupported show_entry_tour #{self.show_entry_tour}. Defaulting to show_entry_tour 'true' instead"
self.show_entry_tour = true
end
if self.show_kb_shortcuts_tour == nil
Rails.logger.info "User #{self.email} has unsupported show_kb_shortcuts_tour #{self.show_kb_shortcuts_tour}. Defaulting to show_entry_tour 'true' instead"
self.show_kb_shortcuts_tour = true
end
if self.name.blank?
Rails.logger.info "User #{self.email} has no name set. Using the email by default."
self.name = self.email
end
if self.subscriptions_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported subscriptions_updated_at value, using current datetime by default"
self.subscriptions_updated_at = Time.zone.now
end
if self.folders_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported folders_updated_at value, using current datetime by default"
self.folders_updated_at = Time.zone.now
end
if self.refresh_feed_jobs_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported refresh_feed_jobs_updated_at value, using current datetime by default"
self.refresh_feed_jobs_updated_at = Time.zone.now
end
if self.subscribe_jobs_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported subscribe_jobs_updated_at value, using current datetime by default"
self.subscribe_jobs_updated_at = Time.zone.now
end
if self.config_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported config_updated_at value, using current datetime by default"
self.config_updated_at = Time.zone.now
end
if self.user_data_updated_at == nil
Rails.logger.info "User #{self.email} has unsupported user_data_updated_at value, using current datetime by default"
self.user_data_updated_at = Time.zone.now
end
if self.first_confirmation_reminder_sent == nil
Rails.logger.info "User #{self.email} has unsupported first_confirmation_reminder_sent #{self.first_confirmation_reminder_sent}. Defaulting to 'false' instead"
self.first_confirmation_reminder_sent = false
end
if self.second_confirmation_reminder_sent == nil
Rails.logger.info "User #{self.email} has unsupported second_confirmation_reminder_sent #{self.second_confirmation_reminder_sent}. Defaulting to 'false' instead"
self.second_confirmation_reminder_sent = false
end
if self.kb_shortcuts_enabled == nil
Rails.logger.info "User #{self.email} has unsupported kb_shortcuts_enabled #{self.kb_shortcuts_enabled}. Defaulting to 'true' instead"
self.kb_shortcuts_enabled = true
end
return true
end
##
# Mark as unread for this user all entries of the feed passed as argument.
def mark_unread_entries(feed_subscription)
feed = feed_subscription.feed
feed.entries.find_each do |entry|
if !EntryState.exists? user_id: self.id, entry_id: entry.id
entry_state = self.entry_states.create! entry_id: entry.id, read: false
end
end
end
end