# frozen_string_literal: true module Decidim # Short links are a way to reference specific locations within Decidim with # shorted URLs, similar to the popular link shortening services. The original # reason for creating the feature was to reference long calendar URLs in a # more compact way for the URLs to be compatible with the calendar programs. # When the URL is long with lots of filtering parameters included in it, it # may be too long for specific 3rd party programs. # # This feature can be used to link to any URLs or resources in Decidim with a # short reference. class ShortLink < ApplicationRecord belongs_to :organization, foreign_key: "decidim_organization_id", class_name: "Decidim::Organization" belongs_to :target, polymorphic: true validates :identifier, presence: true, uniqueness: true before_validation :generate_identifier # Finds a matching short link to the same target with exactly the same # parameters if it already exists or creates a new one if it does not exist. # # @param target [ActiveRecord::Base] The target where this short link should # link to. Most of the times [Decidim::Organization], [Decidim::Component] # or some other top-level record. # @param mounted_engine [String] The mounted engine helper name that will be # used to generate the link. # @param route_name [String, nil] The route name to be linked to. If not # defined, the short link will be generated for the root URL for the # mounted engine. # @param params [Hash] The query parameters that should be included in the # URL where this short link will redirect to. # @return [Decidim::ShortLink] The short link instance to link to. def self.to(target, mounted_engine, route_name: nil, params: {}) organization = if target.is_a?(Decidim::Organization) target else target.try(:organization) end values = { organization:, target:, mounted_engine_name: mounted_engine, route_name: } existing = if params where(values).find_by("params = ?::jsonb", params.to_json) else find_by(values.merge(params: nil)) end existing || create!(values.merge(params:)) end # Creates a random unique identifier for any new links. Raises an # OutOfCandidatesError if a free candidate cannot be found with 20 tries. # In this situation the older records should be removed from the database. # # @return [String] A new unique identifier. def self.unique_identifier_within(organization) 1.step do |n| raise OutOfCandidatesError if n > 20 # A-Z, a-z and 0-9 # 26 + 26 + 10 = 62 possibilities per character # 62^10 ≈ 8×10¹⁷ total possibilities candidate = SecureRandom.alphanumeric(10) next if where(organization:, identifier: candidate).any? return candidate end end # Overrides the route_name method to add a default route name for the "root" # path in case the route name is not defined for the record. # # @return [String] The route name to link to. def route_name super || "root" end # Generates the short URL referencing this link. # # @return [String] The short URL that can be used to link to the target. def short_url EngineRouter.new("decidim", default_url_options).short_link_url(id: identifier) end # Generates the full long URL to the resource matching this short link. # # @return [String] The target full URL that the short link should link to. def target_url url_helpers.send("#{route_name}_url", **params) end private # Generates a new identifier for new records. # # @return [void] def generate_identifier return if identifier.present? self.identifier = self.class.unique_identifier_within(organization) end # Returns the default URL options for the organization. The participatory # space and component related parameters are excluded from the # default_url_options because these are also needed for the short URL # generation. # # @return [Hash] A hash of the default URL options for the links. def default_url_options { host: organization&.host }.compact end # Returns the options for the URL helper call against the routes proxy. # The options include the mounted params for the target, such as # participatory space slug and component ID, depending what the target is. # # @return [Hash] The mounted options for the target record that will be used # to generate the final link. def mounted_options if target.is_a?(Decidim::Participable) # The mounted_params method returns always e.g. participatory_space_slug # assembly_slug, etc. But when we want to link to the space itself, we # only need the `slug` parameter. { host: target.organization.host, slug: target.slug } elsif target.respond_to?(:mounted_params) target.mounted_params elsif target.respond_to?(:component) target.component.mounted_params.merge(id: target.id) elsif target.respond_to?(:participatory_space) target.participatory_space.mounted_params elsif target.respond_to?(:slug) # E.g. Decidim::StaticPage uses the slug as the `:id` parameter. default_url_options.merge(id: target.slug) elsif !target.is_a?(Decidim::Organization) default_url_options.merge(id: target.id) else default_url_options end end # Returns the routes helpers for the target engine. # # @return [Decidim::EngineRouter] An instance of the engine router that will # be used to generate the links to the correct context. def url_helpers @url_helpers ||= EngineRouter.new(mounted_engine_name, mounted_options) end # The OutOfCandidatesError is an error class that will be raised when the # short link identifiers are running out and the short link cannot be # generated. class OutOfCandidatesError < StandardError; end end end