diff --git a/app/controllers/active_storage/direct_uploads_controller_decorator.rb b/app/controllers/active_storage/direct_uploads_controller_decorator.rb new file mode 100644 index 00000000..f27c4cfb --- /dev/null +++ b/app/controllers/active_storage/direct_uploads_controller_decorator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ActiveStorage + # Modifica la creación de un blob antes de subir el archivo para que + # incluya el JekyllService adecuado. + module DirectUploadsControllerDecorator + extend ActiveSupport::Concern + + included do + def create + blob = ActiveStorage::Blob.create_before_direct_upload!(service_name: session[:service_name], **blob_args) + render json: direct_upload_json(blob) + end + end + end +end + +ActiveStorage::DirectUploadsController.include ActiveStorage::DirectUploadsControllerDecorator diff --git a/app/controllers/active_storage/disk_controller_decorator.rb b/app/controllers/active_storage/disk_controller_decorator.rb new file mode 100644 index 00000000..14366a15 --- /dev/null +++ b/app/controllers/active_storage/disk_controller_decorator.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActiveStorage + # Modificar {DiskController} para poder asociar el blob a un sitio + module DiskControllerDecorator + extend ActiveSupport::Concern + + included do + # Asociar el archivo subido al sitio correspondiente. Cada sitio + # tiene su propio servicio de subida de archivos. + def update + if (token = decode_verified_token) + if acceptable_content?(token) + named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum] + + blob = ActiveStorage::Blob.find_by_key token[:key] + site = Site.find_by_name token[:service_name] + + site.static_files.attach(blob) + else + head :unprocessable_entity + end + else + head :not_found + end + rescue ActiveStorage::IntegrityError + head :unprocessable_entity + end + end + end +end + +ActiveStorage::DiskController.include ActiveStorage::DiskControllerDecorator diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index dfcdb806..e6f836bb 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -3,6 +3,7 @@ # Controlador para artículos class PostsController < ApplicationController before_action :authenticate_usuarie! + before_action :service_for_direct_upload, only: %i[new edit] # TODO: Traer los comunes desde ApplicationController breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path @@ -163,4 +164,9 @@ class PostsController < ApplicationController def post @post ||= site.posts(lang: locale).find(params[:post_id] || params[:id]) end + + # Recuerda el nombre del servicio de subida de archivos + def service_for_direct_upload + session[:service_name] = site.name.to_sym + end end diff --git a/app/lib/active_storage/attached/changes/create_one_decorator.rb b/app/lib/active_storage/attached/changes/create_one_decorator.rb new file mode 100644 index 00000000..bfb92478 --- /dev/null +++ b/app/lib/active_storage/attached/changes/create_one_decorator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ActiveStorage + module Attached::Changes::CreateOneDecorator + extend ActiveSupport::Concern + + included do + private + + # A partir de ahora todos los archivos se suben al servicio de + # cada sitio. + def attachment_service_name + record.name.to_sym + end + end + end +end + +ActiveStorage::Attached::Changes::CreateOne.include ActiveStorage::Attached::Changes::CreateOneDecorator diff --git a/app/lib/active_storage/service/jekyll_service.rb b/app/lib/active_storage/service/jekyll_service.rb new file mode 100644 index 00000000..92b26e0e --- /dev/null +++ b/app/lib/active_storage/service/jekyll_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module ActiveStorage + class Service + # Sube los archivos a cada repositorio y los agrega al LFS de su + # repositorio git. + # + # @todo: Implementar LFS. No nos gusta mucho la idea porque duplica + # el espacio en disco, pero es la única forma que tenemos (hasta que + # implementemos IPFS) para poder transferir los archivos junto con el + # sitio. + class JekyllService < Service::DiskService + # Genera un servicio para un sitio determinado + # + # @param :site [Site] + # @return [ActiveStorage::Service::JekyllService] + def self.build_for_site(site:) + new(root: File.join(site.path, 'public'), public: true).tap do |js| + js.name = site.name.to_sym + end + end + + # Lo mismo que en DiskService agregando el nombre de archivo en la + # firma. Esto permite que luego podamos guardar el archivo donde + # corresponde. + # + # @param :key [String] + # @param :expires_in [Integer] + # @param :content_type [String] + # @param :content_length [Integer] + # @param :checksum [String] + # @return [String] + def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:) + instrument :url, key: key do |payload| + verified_token_with_expiration = ActiveStorage.verifier.generate( + { + key: key, + content_type: content_type, + content_length: content_length, + checksum: checksum, + service_name: name, + filename: filename_for(key) + }, + expires_in: expires_in, + purpose: :blob_token + ) + + generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host) + + payload[:url] = generated_url + + generated_url + end + end + + # Mantener retrocompatibilidad con cómo gestionamos los archivos + # subidos hasta ahora. + # + # @param :key [String] + # @return [String] + def folder_for(key) + key + end + + # Obtiene el nombre de archivo para esta key + # + # @param :key [String] + # @return [String] + def filename_for(key) + ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first + end + + # Crea una ruta para la llave con un nombre conocido. + # + # @param :key [String] + # @return [String] + def path_for(key) + File.join root, folder_for(key), filename_for(key) + end + end + end +end diff --git a/app/lib/active_storage/service/registry_decorator.rb b/app/lib/active_storage/service/registry_decorator.rb new file mode 100644 index 00000000..c7096356 --- /dev/null +++ b/app/lib/active_storage/service/registry_decorator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActiveStorage + class Service + # Modificaciones a ActiveStorage::Service::Registry + module RegistryDecorator + extend ActiveSupport::Concern + + included do + # El mismo comportamiento que #fetch con el agregado de generar + # un {JekyllService} para cada sitio. + def fetch(name) + services.fetch(name.to_sym) do |key| + if configurations.include?(key) + services[key] = configurator.build(key) + elsif (site = Site.find_by_name(key)) + services[key] = ActiveStorage::Service::JekyllService.build_for_site(site: site) + elsif block_given? + yield key + else + raise KeyError, "Missing configuration for the #{key} Active Storage service. " \ + "Configurations available for the #{configurations.keys.to_sentence} services." + end + end + end + end + end + end +end + +ActiveStorage::Service::Registry.include ActiveStorage::Service::RegistryDecorator diff --git a/app/models/active_storage/blob_decorator.rb b/app/models/active_storage/blob_decorator.rb new file mode 100644 index 00000000..9c01251a --- /dev/null +++ b/app/models/active_storage/blob_decorator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveStorage + # Modificaciones a ActiveStorage::Blob + module BlobDecorator + extend ActiveSupport::Concern + + included do + # Permitir que llegue el nombre de archivo al servicio de subida de + # archivos. + # + # @return [Hash] + def service_metadata + if forcibly_serve_as_binary? + { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename } + elsif !allowed_inline? + { content_type: content_type, disposition: :attachment, filename: filename } + else + { content_type: content_type, filename: filename } + end + end + end + end +end + +ActiveStorage::Blob.include ActiveStorage::BlobDecorator diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb index 80cefa27..1c859481 100644 --- a/app/models/metadata_file.rb +++ b/app/models/metadata_file.rb @@ -19,6 +19,7 @@ class MetadataFile < MetadataTemplate errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid? errors << I18n.t("metadata.#{type}.path_required") if path_missing? errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description? + errors << I18n.t("metadata.#{type}.attachment_missing") unless static_file errors.compact! errors.empty? @@ -34,12 +35,6 @@ class MetadataFile < MetadataTemplate value['path'].is_a?(String) end - # Determina si la ruta es opcional pero deja pasar si la ruta se - # especifica - def path_optional? - !required && !path? - end - # Asociar la imagen subida al sitio y obtener la ruta # # XXX: Si evitamos guardar cambios con changed? no tenemos forma de @@ -48,13 +43,7 @@ class MetadataFile < MetadataTemplate # repetida. def save value['description'] = sanitize value['description'] - - if path? - hardlink - value['path'] = relative_destination_path - else - value['path'] = nil - end + value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil true end @@ -71,101 +60,74 @@ class MetadataFile < MetadataTemplate # XXX: La última opción provoca archivos duplicados, pero es lo mejor # que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213 # - # @return [ActiveStorage::Attachment] + # @todo encontrar una forma de obtener el attachment sin tener que + # recurrir al último subido. + # + # @return [ActiveStorage::Attachment,nil] def static_file - return unless path? - @static_file ||= case value['path'] when ActionDispatch::Http::UploadedFile site.static_files.last if site.static_files.attach(value['path']) when String - if (blob = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first) - site.static_files.find_by(blob_id: blob) - elsif site.static_files.attach(io: path.open, filename: path.basename) + if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first) + site.static_files.find_by(blob_id: blob_id) + elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename) site.static_files.last end end end + # Obtiene la ruta absoluta al archivo + # + # @return [Pathname] + def pathname + raise NoMethodError unless uploaded? + + @pathname ||= Pathname.new(File.join(site.path, value['path'])) + end + + # Obtiene la key del attachment a partir de la ruta + # + # @return [String] def key_from_path - path.dirname.basename.to_s + pathname.dirname.basename.to_s end def path? value['path'].present? end + def description? + value['description'].present? + end + private - def filemagic - @filemagic ||= FileMagic.new(FileMagic::MAGIC_MIME) - end - + # Obtener la ruta al archivo relativa al sitio + # # @return [Pathname] - def path - @path ||= Pathname.new(File.join(site.path, value['path'])) - end - - def file - return unless path? - - @file ||= - case value['path'] - when ActionDispatch::Http::UploadedFile then value['path'].tempfile.path - when String then File.join(site.path, value['path']) - end - end - - # Hacemos un link duro para colocar el archivo dentro del repositorio - # y no duplicar el espacio que ocupan. Esto requiere que ambos - # directorios estén dentro del mismo punto de montaje. - # - # XXX: Asumimos que el archivo destino no existe porque siempre - # contiene una key única. - # - # @return [Boolean] - def hardlink - return if hardlink? - return if File.exist? destination_path - - FileUtils.mkdir_p(File.dirname(destination_path)) - FileUtils.ln(uploaded_path, destination_path).zero? - end - - def hardlink? - File.stat(uploaded_path).ino == File.stat(destination_path).ino - rescue Errno::ENOENT - false - end - - # Obtener la ruta al archivo - # https://stackoverflow.com/a/53908358 - def uploaded_relative_path - ActiveStorage::Blob.service.path_for(static_file.key) - end - - # @return [String] - def uploaded_path - Rails.root.join uploaded_relative_path - end - - # La ruta del archivo mantiene el nombre original pero contiene el - # nombre interno y único del archivo para poder relacionarlo con el - # archivo subido en Sutty. - # - # @return [String] - def relative_destination_path - @relative_destination_path ||= File.join('public', static_file.key, static_file.filename.to_s) - end - - # @return [String] def destination_path - @destination_path ||= File.join(site.path, relative_destination_path) + Pathname.new(static_file_path) + end + + # Agrega el nombre de archivo a la ruta para tener retrocompatibilidad + # + # @return [Pathname] + def destination_path_with_filename + destination_path.realpath + end + + def relative_destination_path_with_filename + destination_path_with_filename.relative_path_from(site.path) + end + + def static_file_path + static_file.blob.service.path_for(static_file.key) end # No hay archivo pero se lo describió def no_file_for_description? - value['description'].present? && value['path'].blank? + !path? && description? end end diff --git a/app/models/metadata_image.rb b/app/models/metadata_image.rb index f91a6273..f86c5c26 100644 --- a/app/models/metadata_image.rb +++ b/app/models/metadata_image.rb @@ -13,8 +13,6 @@ class MetadataImage < MetadataFile # Determina si es una imagen def image? - return true unless file - - filemagic.file(file).starts_with? 'image/' + static_file&.blob&.send(:web_image?) end end diff --git a/app/models/site.rb b/app/models/site.rb index 5b78d625..7d4875e5 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -57,7 +57,7 @@ class Site < ApplicationRecord # Carga el sitio Jekyll una vez que se inicializa el modelo o después # de crearlo after_initialize :load_jekyll - after_create :load_jekyll, :static_file_migration! + after_create :load_jekyll # Cambiar el nombre del directorio before_update :update_name! before_save :add_private_key_if_missing! @@ -474,11 +474,6 @@ class Site < ApplicationRecord config.hostname = hostname end - # Migra los archivos a Sutty - def static_file_migration! - Site::StaticFileMigration.new(site: self).migrate! - end - # Valida si el sitio tiene al menos una forma de alojamiento asociada # y es la local # diff --git a/app/models/site/static_file_migration.rb b/app/models/site/static_file_migration.rb deleted file mode 100644 index 36a882bf..00000000 --- a/app/models/site/static_file_migration.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -class Site - # Obtiene todos los archivos relacionados en artículos del sitio y los - # sube a Sutty. - class StaticFileMigration - # Tipos de metadatos que contienen archivos - STATIC_TYPES = %i[file image].freeze - - attr_reader :site - - def initialize(site:) - @site = site - end - - def migrate! - modified = site.docs.map do |doc| - next unless STATIC_TYPES.map do |field| - next unless doc.attribute? field - next unless doc[field].path? - next unless doc[field].static_file - - true - end.any? - - log.write "#{doc.path.relative};no se pudo guardar\n" unless doc.save(validate: false) - - doc.path.absolute - end.compact - - log.close - - return if modified.empty? - - # TODO: Hacer la migración desde el servicio de creación de sitios? - site.repository.commit(file: modified, - message: I18n.t('sites.static_file_migration'), - usuarie: author) - end - - private - - def author - @author ||= GitAuthor.new email: "sutty@#{Site.domain}", - name: 'Sutty' - end - - def log - @log ||= File.open(File.join(site.path, 'migration.csv'), 'w') - end - end -end diff --git a/config/application.rb b/config/application.rb index 5b6e373c..031c1909 100644 --- a/config/application.rb +++ b/config/application.rb @@ -39,8 +39,7 @@ module Sutty config.active_storage.variant_processor = :vips config.to_prepare do - # Load application's model / class decorators - Dir.glob(File.join(File.dirname(__FILE__), '../app/**/*_decorator.rb')) do |c| + Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c| Rails.configuration.cache_classes ? require(c) : load(c) end end diff --git a/config/initializers/analyze_job.rb b/config/initializers/analyze_job.rb deleted file mode 100644 index f268e0dd..00000000 --- a/config/initializers/analyze_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# TODO: Estamos procesando el análisis de los archivos en el momento -# porque queremos obtener la ruta del archivo en el momento y no -# después. Necesitaríamos poder generar el vínculo en el -# repositorio a destiempo, modificando el Job de ActiveStorage -ActiveStorage::AnalyzeJob.queue_adapter = :inline diff --git a/config/locales/en.yml b/config/locales/en.yml index 639d9184..48ac4ecb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,10 +41,12 @@ en: not_an_image: 'Not an image' path_required: 'Missing image for upload' no_file_for_description: "Description with no associated image" + attachment_missing: "I couldn't save the image :(" file: site_invalid: 'The file cannot be stored if the site configuration is not valid' path_required: "Missing file for upload" no_file_for_description: "Description with no associated file" + attachment_missing: "I couldn't save the file :(" event: zone_missing: 'Inexistent timezone' date_missing: 'Event date is required' diff --git a/config/locales/es.yml b/config/locales/es.yml index 86e156df..14539dbc 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -41,10 +41,12 @@ es: not_an_image: 'No es una imagen' path_required: 'Se necesita una imagen' no_file_for_description: 'Se envió una descripción sin imagen asociada' + attachment_missing: 'no pude guardar el archivo :(' file: site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida' path_required: 'Se necesita un archivo' no_file_for_description: 'se envió una descripción sin archivo asociado' + attachment_missing: 'no pude guardar el archivo :(' event: zone_missing: 'El huso horario no es correcto' date_missing: 'La fecha es obligatoria'