diff --git a/Gemfile b/Gemfile index ffd3d77..1b99d57 100644 --- a/Gemfile +++ b/Gemfile @@ -35,9 +35,6 @@ gem 'bcrypt', '~> 3.1.7' # gem 'capistrano-rails', group: :development gem 'bootstrap', '~> 4' -gem 'carrierwave' -gem 'carrierwave-bombshelter' -gem 'carrierwave-i18n' gem 'commonmarker' gem 'devise' gem 'devise-i18n' @@ -48,6 +45,7 @@ gem 'font-awesome-rails' gem 'friendly_id' gem 'hamlit-rails' gem 'hiredis' +gem 'image_processing' gem 'jekyll' gem 'jquery-rails' gem 'mini_magick' diff --git a/Gemfile.lock b/Gemfile.lock index dbb8a16..c4e2083 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,15 +80,6 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (>= 2.0, < 4.0) - carrierwave (1.3.1) - activemodel (>= 4.0.0) - activesupport (>= 4.0.0) - mime-types (>= 1.16) - carrierwave-bombshelter (0.2.2) - activesupport (>= 3.2.0) - carrierwave - fastimage - carrierwave-i18n (0.2.0) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) coderay (1.1.2) @@ -132,7 +123,6 @@ GEM factory_bot_rails (5.0.2) factory_bot (~> 5.0.2) railties (>= 4.2.0) - fastimage (2.1.5) ffi (1.11.1) font-awesome-rails (4.7.0.4) railties (>= 3.2, < 6.0) @@ -165,6 +155,9 @@ GEM http_parser.rb (0.6.0) i18n (0.9.5) concurrent-ruby (~> 1.0) + image_processing (1.9.1) + mini_magick (>= 4.9.3, < 5) + ruby-vips (>= 2.0.13, < 3) jaro_winkler (1.5.3) jbuilder (2.8.0) activesupport (>= 4.2.0) @@ -209,9 +202,6 @@ GEM mimemagic (~> 0.3.2) mercenary (0.3.6) method_source (0.9.2) - mime-types (3.2.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2018.0812) mimemagic (0.3.3) mini_magick (4.9.4) mini_mime (1.0.1) @@ -319,6 +309,8 @@ GEM ruby-enum (0.7.2) i18n ruby-progressbar (1.10.1) + ruby-vips (2.0.14) + ffi (~> 1.9) ruby_dep (1.5.0) rubyzip (1.2.2) rugged (0.28.2) @@ -416,9 +408,6 @@ DEPENDENCIES capistrano-rails capistrano-rbenv capybara (~> 2.13) - carrierwave - carrierwave-bombshelter - carrierwave-i18n commonmarker database_cleaner devise @@ -434,6 +423,7 @@ DEPENDENCIES haml-lint hamlit-rails hiredis + image_processing jbuilder (~> 2.5) jekyll jquery-rails diff --git a/app/assets/javascripts/image_preview.js b/app/assets/javascripts/image_preview.js new file mode 100644 index 0000000..c3f639d --- /dev/null +++ b/app/assets/javascripts/image_preview.js @@ -0,0 +1,11 @@ +$(document).on('turbolinks:load', function() { + $('input[type=file]').on('change', function(event) { + if (event.target.files.length == 0) return; + + var input = $(event.target); + var preview = $(`#${input.data('preview')}`); + + preview.attr('src', + window.URL.createObjectURL(event.target.files[0])); + }); +}); diff --git a/app/models/metadata_image.rb b/app/models/metadata_image.rb index e93e3fe..6ca63c0 100644 --- a/app/models/metadata_image.rb +++ b/app/models/metadata_image.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true # Define un campo de imagen +# TODO: Validar que sea una imagen class MetadataImage < MetadataTemplate # Una ruta vacía a la imagen con una descripción vacía def default_value @@ -11,7 +12,73 @@ class MetadataImage < MetadataTemplate value == default_value end + # Asociar la imagen subida al sitio y obtener la ruta + # rubocop:disable Metrics/CyclomaticComplexity + def save + return true if !required && value['path'].blank? + return false if required && value['path'].blank? + return true if value['path'].is_a? String + return false unless hardlink.zero? + + # Modificar el valor actual + value['path'] = relative_destination_path + + true + end + # rubocop:enable Metrics/CyclomaticComplexity + def to_param { name => %i[description path] } end + + # Almacena el archivo en el sitio y lo devuelve + # XXX: ActiveStorage devuelve un Array al guardar + # + # @return ActiveStorage::Attachment + def static_file + if value['path'].is_a? String + blob = ActiveStorage::Blob.find_by(key: key_from_path) + @static_file ||= site.static_files.find_by(blob_id: blob.id) + else + @static_file ||= site.static_files.attach(value['path']).first + end + end + + private + + def key_from_path + # XXX: No podemos usar self#extension porque en este punto todavía + # no sabemos el static_file + File.basename(value['path'], '.*') + 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. + def hardlink + FileUtils.mkdir_p File.dirname(destination_path) + FileUtils.ln uploaded_path, destination_path + end + + def extension + static_file.blob.content_type.split('/').last + end + + # Obtener la ruta al archivo + # https://stackoverflow.com/a/53908358 + def uploaded_relative_path + ActiveStorage::Blob.service.path_for(static_file.key) + end + + def uploaded_path + Rails.root.join uploaded_relative_path + end + + def relative_destination_path + File.join('public', [static_file.key, extension].join('.')) + end + + def destination_path + File.join(site.path, relative_destination_path) + end end diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 3f3a769..de5007b 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -55,6 +55,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, type == 'array' end + # En caso de que algún campo necesite realizar acciones antes de ser + # guardado + def save + true + end + private # Si es obligatorio no puede estar vacío diff --git a/app/models/post.rb b/app/models/post.rb index 5222ef0..4a4cff0 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -149,10 +149,12 @@ class Post < OpenStruct alias destroy! destroy # Guarda los cambios + # rubocop:disable Metrics/CyclomaticComplexity def save return false unless valid? # Salir si tenemos que cambiar el nombre del archivo y no pudimos return false if !new? && path_changed? && !update_path! + return false unless save_attributes! return false unless write # Vuelve a leer el post para tomar los cambios @@ -160,6 +162,7 @@ class Post < OpenStruct true end + # rubocop:enable Metrics/CyclomaticComplexity alias save! save # Lee el documento a menos que estemos trabajando con un documento en @@ -282,6 +285,13 @@ class Post < OpenStruct type: :path, post: self, required: true) end + + # Ejecuta la acción de guardado en cada atributo + def save_attributes! + attributes.map do |attr| + send(attr).save + end.all? + end end # rubocop:enable Metrics/ClassLength # rubocop:enable Style/MethodMissingSuper diff --git a/app/models/site.rb b/app/models/site.rb index 6571ccd..69e3451 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -25,6 +25,9 @@ class Site < ApplicationRecord has_many :invitades, -> { where('roles.rol = ?', 'invitade') }, through: :roles, source: :usuarie + # Mantenemos el nombre que les da Jekyll + has_many_attached :static_files + # Clonar el directorio de esqueleto antes de crear el sitio before_create :clone_skel! # Elimina el directorio al destruir un sitio diff --git a/app/views/posts/attribute_ro/_image.haml b/app/views/posts/attribute_ro/_image.haml index c400985..3663597 100644 --- a/app/views/posts/attribute_ro/_image.haml +++ b/app/views/posts/attribute_ro/_image.haml @@ -3,5 +3,7 @@ %td - if metadata.value['path'].present? %figure - = image_tag metadata.value['path'], alt: metadata.value['description'] + = image_tag url_for(metadata.static_file), + alt: metadata.value['description'], + class: 'img-fluid' %figcaption= metadata.value['description'] diff --git a/app/views/posts/attributes/_image.haml b/app/views/posts/attributes/_image.haml index 5922e42..b24a2a2 100644 --- a/app/views/posts/attributes/_image.haml +++ b/app/views/posts/attributes/_image.haml @@ -1,10 +1,20 @@ .form-group{ class: invalid(post, attribute) } - if metadata.value['path'].present? - = image_tag metadata.value[:path], alt: metadata.value['description'] + = image_tag url_for(metadata.static_file), + alt: metadata.value['description'], + class: 'img-fluid', + id: "#{attribute}-preview" + + -# + Mantener el valor si no enviamos ninguna imagen + TODO: Agregar checkbox para eliminarla + = hidden_field_tag "post[#{attribute}][path]", metadata.value['path'] .custom-file = file_field(*field_name_for('post', attribute, :path), - **field_options(attribute, metadata), class: 'custom-file-input') + **field_options(attribute, metadata), + class: 'custom-file-input', accept: 'image/*', + data: { preview: "#{attribute}-preview" }) = label_tag "post_#{attribute}_path", post_label_t(attribute, :path, post: post), class: 'custom-file-label' = render 'posts/attribute_feedback', diff --git a/config/application.rb b/config/application.rb index 579177e..ba77c0c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -13,6 +13,7 @@ require 'action_view/railtie' # require "action_cable/engine" require 'sprockets/railtie' require 'rails/test_unit/railtie' +require 'active_storage/engine' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -32,5 +33,7 @@ module Sutty config.action_dispatch .rescue_responses['Pundit::NotAuthorizedError'] = :forbidden config.active_record.sqlite3.represent_boolean_as_integer = true + + config.active_storage.variant_processor = :vips end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 68c0679..75351fd 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -57,15 +57,11 @@ Rails.application.configure do # listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker - CarrierWave.configure do |config| - config.ignore_integrity_errors = false - config.ignore_processing_errors = false - config.ignore_download_errors = false - end - config.action_mailer.perform_caching = false config.action_mailer.delivery_method = :letter_opener config.action_mailer.perform_deliveries = true config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + + config.active_storage.service = :local end diff --git a/config/environments/production.rb b/config/environments/production.rb index dcdd555..ace39e7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -107,4 +107,5 @@ Rails.application.configure do sender_address: ENV['DEFAULT_FROM'], exception_recipients: ENV['EXCEPTION_TO'] } + config.active_storage.service = :local end diff --git a/config/environments/test.rb b/config/environments/test.rb index 455b905..9c2c0a5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -45,4 +45,5 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + config.active_storage.service = :test end diff --git a/config/initializers/carrierwave.rb b/config/initializers/carrierwave.rb deleted file mode 100644 index 8b11537..0000000 --- a/config/initializers/carrierwave.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -CarrierWave.configure do |config| - config.permissions = 0o640 - config.directory_permissions = 0o750 - config.storage = :file -end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..e21ac5c --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,15 @@ +# XXX: Dirty hack +# +# Almacenamos los archivos dentro del directorio de sitios para poder +# hacer links duros. Si montamos en Docker los dos directorios por +# separado, aunque en el servidor estén los directorios dentro del mismo +# sistema de archivos, para Docker son dos distintos. +# +# Ver app/models/metadata_image.rb +local: + service: Disk + root: <%= Rails.root.join('_sites/_storage') %> + +test: + service: Disk + root: <%= Rails.root.join('tmp/storage') %> diff --git a/db/migrate/20190820225238_create_active_storage_tables.active_storage.rb b/db/migrate/20190820225238_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..98317c9 --- /dev/null +++ b/db/migrate/20190820225238_create_active_storage_tables.active_storage.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [:key], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index %i[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 9455733..7f401cc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,28 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_730_211_756) do +ActiveRecord::Schema.define(version: 20_190_820_225_238) do + create_table 'active_storage_attachments', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.integer 'record_id', null: false + t.integer 'blob_id', null: false + t.datetime 'created_at', null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true + end + + create_table 'active_storage_blobs', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + end + create_table 'build_stats', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false diff --git a/doc/uploads.md b/doc/uploads.md index 4771e31..4686004 100644 --- a/doc/uploads.md +++ b/doc/uploads.md @@ -5,17 +5,83 @@ plantilla: ```yaml --- -cover: image -attachment: document -video: video -audio: audio +cover: + type: image +attachment: + type: document +video: + type: video +audio: + type: audio --- ``` -Donde `image` solo admite imágenes, `document` cualquier tipo de documento y -`video` y `audio` medios. +Donde `image` solo admite imágenes, `document` cualquier tipo de +documento y `video` y `audio` medios. Al subir los archivos, se guardan en el directorio `public/` en la raíz -del proyecto Jekyll. +del sitio. En el frontmatter solo sale la URL del archivo asociado. + +## ActiveStorage + +ActiveStorage es el método por defecto para subida de archivos en Ruby +on Rails. Sin embargo, no nos permite decidir dónde queremos guardar +los archivos, sino que los guarda directamente relacionados a un modelo +de la base de datos, en una ruta generada automáticamente. + +Podríamos implementar un proveedor de ActiveStorage, pero la +documentación para hacerlo es escasa. + +Como tenemos que asociarlo a un modelo, lo correcto sería usar el modelo +Site, de forma que tengamos acceso a todas las imágenes. Esto abriría +la posibilidad de tener una galería de imágenes y poder seleccionar +imágenes entre las ya subidas o subir nuevas. + +Pero como los archivos físicos se guardan directamente en una ruta +indicada por ActiveStorage, empezamos a utilizar más espacio porque para +que los sitios sean autocontenidos, deberíamos copiarlos al directorio +del sitio. También podemos utilizar enlaces duros (_hardlinks_) para no +ocupar espacio extra. + +Ya que las imágenes van a estar dentro del directorio de Sutty en lugar +de cada archivo, es más fácil poder visualizarlas, sin tener que hacer +hacks enviando el archivo a través de Rails. Con AS podemos vincularlas +directamente desde el sistema de archivos. + +## CarrierWave + +CarrierWave es la forma en que estamos subiendo los archivos hasta el +momento. Puede transformar las imágenes a distintas resoluciones +automáticamente (aunque quisiéramos hacer esto durante la generación del +sitio). También hemos tenido problemas con los nombres de los archivos +y encontrado errores no documentados con respecto a cuándo cambia el +nombre del archivo o no, con lo que usar CW se nos hizo un poco endeble. + +## Subida + +Entonces: + +* Activamos ActiveStorage local (ofrece nubes, pero no queremos ninguna + nube) + +* Asociamos archivos al modelo Site + +* Al subir un archivo desde el editor de Post, se asocia al Site, no al + Post. + +* Para asociar el archivo al Post, generamos un hardlink con la misma + ruta, dentro del directorio del sitio. Esto nos permite vincular + ambos archivos sin agregar metadatos adicionales. + +* Para generar variantes del archivo, lo hacemos con un plugin Jekyll + que las genera dentro del sitio si no existen y que agrega los + atributos srcset para imágenes responsive. + +* Si encontramos forma, aplicamos `oxipng` y otros optimizadores. Sino, + lo haremos desde el plugin. + +## Dependencias + +Usamos VIPS para procesar imágenes con bajo consumo de recursos