WIP: Subida de archivos

This commit is contained in:
f 2019-08-21 22:09:29 -03:00
parent 80457588dd
commit 0a1cbb9fc2
No known key found for this signature in database
GPG key ID: 2AE5A13E321F953D
18 changed files with 265 additions and 43 deletions

View file

@ -35,9 +35,6 @@ gem 'bcrypt', '~> 3.1.7'
# gem 'capistrano-rails', group: :development # gem 'capistrano-rails', group: :development
gem 'bootstrap', '~> 4' gem 'bootstrap', '~> 4'
gem 'carrierwave'
gem 'carrierwave-bombshelter'
gem 'carrierwave-i18n'
gem 'commonmarker' gem 'commonmarker'
gem 'devise' gem 'devise'
gem 'devise-i18n' gem 'devise-i18n'
@ -48,6 +45,7 @@ gem 'font-awesome-rails'
gem 'friendly_id' gem 'friendly_id'
gem 'hamlit-rails' gem 'hamlit-rails'
gem 'hiredis' gem 'hiredis'
gem 'image_processing'
gem 'jekyll' gem 'jekyll'
gem 'jquery-rails' gem 'jquery-rails'
gem 'mini_magick' gem 'mini_magick'

View file

@ -80,15 +80,6 @@ GEM
rack (>= 1.0.0) rack (>= 1.0.0)
rack-test (>= 0.5.4) rack-test (>= 0.5.4)
xpath (>= 2.0, < 4.0) 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) childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11) ffi (~> 1.0, >= 1.0.11)
coderay (1.1.2) coderay (1.1.2)
@ -132,7 +123,6 @@ GEM
factory_bot_rails (5.0.2) factory_bot_rails (5.0.2)
factory_bot (~> 5.0.2) factory_bot (~> 5.0.2)
railties (>= 4.2.0) railties (>= 4.2.0)
fastimage (2.1.5)
ffi (1.11.1) ffi (1.11.1)
font-awesome-rails (4.7.0.4) font-awesome-rails (4.7.0.4)
railties (>= 3.2, < 6.0) railties (>= 3.2, < 6.0)
@ -165,6 +155,9 @@ GEM
http_parser.rb (0.6.0) http_parser.rb (0.6.0)
i18n (0.9.5) i18n (0.9.5)
concurrent-ruby (~> 1.0) 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) jaro_winkler (1.5.3)
jbuilder (2.8.0) jbuilder (2.8.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -209,9 +202,6 @@ GEM
mimemagic (~> 0.3.2) mimemagic (~> 0.3.2)
mercenary (0.3.6) mercenary (0.3.6)
method_source (0.9.2) 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) mimemagic (0.3.3)
mini_magick (4.9.4) mini_magick (4.9.4)
mini_mime (1.0.1) mini_mime (1.0.1)
@ -319,6 +309,8 @@ GEM
ruby-enum (0.7.2) ruby-enum (0.7.2)
i18n i18n
ruby-progressbar (1.10.1) ruby-progressbar (1.10.1)
ruby-vips (2.0.14)
ffi (~> 1.9)
ruby_dep (1.5.0) ruby_dep (1.5.0)
rubyzip (1.2.2) rubyzip (1.2.2)
rugged (0.28.2) rugged (0.28.2)
@ -416,9 +408,6 @@ DEPENDENCIES
capistrano-rails capistrano-rails
capistrano-rbenv capistrano-rbenv
capybara (~> 2.13) capybara (~> 2.13)
carrierwave
carrierwave-bombshelter
carrierwave-i18n
commonmarker commonmarker
database_cleaner database_cleaner
devise devise
@ -434,6 +423,7 @@ DEPENDENCIES
haml-lint haml-lint
hamlit-rails hamlit-rails
hiredis hiredis
image_processing
jbuilder (~> 2.5) jbuilder (~> 2.5)
jekyll jekyll
jquery-rails jquery-rails

View file

@ -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]));
});
});

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
# Define un campo de imagen # Define un campo de imagen
# TODO: Validar que sea una imagen
class MetadataImage < MetadataTemplate class MetadataImage < MetadataTemplate
# Una ruta vacía a la imagen con una descripción vacía # Una ruta vacía a la imagen con una descripción vacía
def default_value def default_value
@ -11,7 +12,73 @@ class MetadataImage < MetadataTemplate
value == default_value value == default_value
end 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 def to_param
{ name => %i[description path] } { name => %i[description path] }
end 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 end

View file

@ -55,6 +55,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
type == 'array' type == 'array'
end end
# En caso de que algún campo necesite realizar acciones antes de ser
# guardado
def save
true
end
private private
# Si es obligatorio no puede estar vacío # Si es obligatorio no puede estar vacío

View file

@ -149,10 +149,12 @@ class Post < OpenStruct
alias destroy! destroy alias destroy! destroy
# Guarda los cambios # Guarda los cambios
# rubocop:disable Metrics/CyclomaticComplexity
def save def save
return false unless valid? return false unless valid?
# Salir si tenemos que cambiar el nombre del archivo y no pudimos # Salir si tenemos que cambiar el nombre del archivo y no pudimos
return false if !new? && path_changed? && !update_path! return false if !new? && path_changed? && !update_path!
return false unless save_attributes!
return false unless write return false unless write
# Vuelve a leer el post para tomar los cambios # Vuelve a leer el post para tomar los cambios
@ -160,6 +162,7 @@ class Post < OpenStruct
true true
end end
# rubocop:enable Metrics/CyclomaticComplexity
alias save! save alias save! save
# Lee el documento a menos que estemos trabajando con un documento en # Lee el documento a menos que estemos trabajando con un documento en
@ -282,6 +285,13 @@ class Post < OpenStruct
type: :path, post: self, type: :path, post: self,
required: true) required: true)
end end
# Ejecuta la acción de guardado en cada atributo
def save_attributes!
attributes.map do |attr|
send(attr).save
end.all?
end
end end
# rubocop:enable Metrics/ClassLength # rubocop:enable Metrics/ClassLength
# rubocop:enable Style/MethodMissingSuper # rubocop:enable Style/MethodMissingSuper

View file

@ -25,6 +25,9 @@ class Site < ApplicationRecord
has_many :invitades, -> { where('roles.rol = ?', 'invitade') }, has_many :invitades, -> { where('roles.rol = ?', 'invitade') },
through: :roles, source: :usuarie 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 # Clonar el directorio de esqueleto antes de crear el sitio
before_create :clone_skel! before_create :clone_skel!
# Elimina el directorio al destruir un sitio # Elimina el directorio al destruir un sitio

View file

@ -3,5 +3,7 @@
%td %td
- if metadata.value['path'].present? - if metadata.value['path'].present?
%figure %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'] %figcaption= metadata.value['description']

View file

@ -1,10 +1,20 @@
.form-group{ class: invalid(post, attribute) } .form-group{ class: invalid(post, attribute) }
- if metadata.value['path'].present? - 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 .custom-file
= file_field(*field_name_for('post', attribute, :path), = 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", = label_tag "post_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label' post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',

View file

@ -13,6 +13,7 @@ require 'action_view/railtie'
# require "action_cable/engine" # require "action_cable/engine"
require 'sprockets/railtie' require 'sprockets/railtie'
require 'rails/test_unit/railtie' require 'rails/test_unit/railtie'
require 'active_storage/engine'
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # you've limited to :test, :development, or :production.
@ -32,5 +33,7 @@ module Sutty
config.action_dispatch config.action_dispatch
.rescue_responses['Pundit::NotAuthorizedError'] = :forbidden .rescue_responses['Pundit::NotAuthorizedError'] = :forbidden
config.active_record.sqlite3.represent_boolean_as_integer = true config.active_record.sqlite3.represent_boolean_as_integer = true
config.active_storage.variant_processor = :vips
end end
end end

View file

@ -57,15 +57,11 @@ Rails.application.configure do
# listen gem. # listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker 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.perform_caching = false
config.action_mailer.delivery_method = :letter_opener config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true config.action_mailer.perform_deliveries = true
config.action_mailer.default_url_options = { host: 'localhost', config.action_mailer.default_url_options = { host: 'localhost',
port: 3000 } port: 3000 }
config.active_storage.service = :local
end end

View file

@ -107,4 +107,5 @@ Rails.application.configure do
sender_address: ENV['DEFAULT_FROM'], sender_address: ENV['DEFAULT_FROM'],
exception_recipients: ENV['EXCEPTION_TO'] exception_recipients: ENV['EXCEPTION_TO']
} }
config.active_storage.service = :local
end end

View file

@ -45,4 +45,5 @@ Rails.application.configure do
# Raises error for missing translations # Raises error for missing translations
# config.action_view.raise_on_missing_translations = true # config.action_view.raise_on_missing_translations = true
config.active_storage.service = :test
end end

View file

@ -1,7 +0,0 @@
# frozen_string_literal: true
CarrierWave.configure do |config|
config.permissions = 0o640
config.directory_permissions = 0o750
config.storage = :file
end

15
config/storage.yml Normal file
View file

@ -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') %>

View file

@ -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

View file

@ -12,7 +12,28 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table 'build_stats', force: :cascade do |t|
t.datetime 'created_at', null: false t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false t.datetime 'updated_at', null: false

View file

@ -5,17 +5,83 @@ plantilla:
```yaml ```yaml
--- ---
cover: image cover:
attachment: document type: image
video: video attachment:
audio: audio type: document
video:
type: video
audio:
type: audio
--- ---
``` ```
Donde `image` solo admite imágenes, `document` cualquier tipo de documento y Donde `image` solo admite imágenes, `document` cualquier tipo de
`video` y `audio` medios. documento y `video` y `audio` medios.
Al subir los archivos, se guardan en el directorio `public/` en la raíz 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. 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