5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-16 21:46:22 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into ci

This commit is contained in:
f 2022-12-28 15:41:07 -03:00
commit b628badeb5
27 changed files with 364 additions and 538 deletions

View file

@ -0,0 +1,29 @@
# 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
private
# Normalizar los caracteres unicode en los nombres de archivos
# para que puedan propagarse correctamente a través de todo el
# stack.
def blob_args
params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys.tap do |ba|
ba[:filename] = ba[:filename].unicode_normalize
end
end
end
end
end
ActiveStorage::DirectUploadsController.include ActiveStorage::DirectUploadsControllerDecorator

View file

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

View file

@ -6,19 +6,22 @@ module Api
class CspReportsController < BaseController class CspReportsController < BaseController
skip_forgery_protection skip_forgery_protection
# No queremos indicar que algo salió mal
rescue_from ActionController::ParameterMissing, with: :csp_report_created
# Crea un reporte de CSP intercambiando los guiones medios por # Crea un reporte de CSP intercambiando los guiones medios por
# bajos # bajos
# #
# TODO: Aplicar rate_limit # TODO: Aplicar rate_limit
def create def create
csp = CspReport.new(csp_report_params.to_h.map do |k, v| csp = CspReport.new(csp_report_params.to_h.transform_keys do |k|
[k.tr('-', '_'), v] k.tr('-', '_')
end.to_h) end)
csp.id = SecureRandom.uuid csp.id = SecureRandom.uuid
csp.save csp.save
render json: {}, status: :created csp_report_created
end end
private private
@ -39,6 +42,10 @@ module Api
:'column-number', :'column-number',
:'source-file') :'source-file')
end end
def csp_report_created
render json: {}, status: :created
end
end end
end end
end end

View file

@ -6,6 +6,7 @@ class PostsController < ApplicationController
rescue_from Pundit::NilPolicyError, with: :page_not_found rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
before_action :service_for_direct_upload, only: %i[new edit]
# TODO: Traer los comunes desde ApplicationController # TODO: Traer los comunes desde ApplicationController
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
@ -166,4 +167,9 @@ class PostsController < ApplicationController
def post def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id]) @post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end end
# Recuerda el nombre del servicio de subida de archivos
def service_for_direct_upload
session[:service_name] = site.name.to_sym
end
end end

View file

@ -137,8 +137,10 @@ export function setupAuxiliaryToolbar(editor: Editor): void {
"click", "click",
(event) => { (event) => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files; const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
if (!files || !files.length) if (!files || !files.length) {
throw new Error("no hay archivos para subir"); console.info("no hay archivos para subir");
return;
}
const file = files[0]; const file = files[0];
const selectedEl = editor.contentEl.querySelector<HTMLElement>( const selectedEl = editor.contentEl.querySelector<HTMLElement>(

View file

@ -20,7 +20,6 @@ function makeParentBlock(
}; };
} }
// TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón // XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml // en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = { export const parentBlocks: { [propName: string]: EditorNode } = {

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActionDispatch
module Http
# Normaliza los nombres de archivo para que se propaguen
# correctamente a través de todo el stack.
module UploadedFileDecorator
extend ActiveSupport::Concern
included do
# Devolver el nombre de archivo con caracteres unicode
# normalizados
def original_filename
@original_filename.unicode_normalize
end
end
end
end
end
ActionDispatch::Http::UploadedFile.include ActionDispatch::Http::UploadedFileDecorator

View file

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

View file

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

View file

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

View file

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

View file

@ -13,12 +13,18 @@ class MetadataFile < MetadataTemplate
value == default_value value == default_value
end end
# No hay valores sugeridos para archivos subidos.
#
# XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end
def validate def validate
super super
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid? 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}.path_required") if path_missing?
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description? errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact! errors.compact!
errors.empty? errors.empty?
@ -34,12 +40,6 @@ class MetadataFile < MetadataTemplate
value['path'].is_a?(String) value['path'].is_a?(String)
end 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 # Asociar la imagen subida al sitio y obtener la ruta
# #
# XXX: Si evitamos guardar cambios con changed? no tenemos forma de # XXX: Si evitamos guardar cambios con changed? no tenemos forma de
@ -48,13 +48,7 @@ class MetadataFile < MetadataTemplate
# repetida. # repetida.
def save def save
value['description'] = sanitize value['description'] value['description'] = sanitize value['description']
value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil
if path?
hardlink
value['path'] = relative_destination_path
else
value['path'] = nil
end
true true
end end
@ -71,101 +65,88 @@ class MetadataFile < MetadataTemplate
# XXX: La última opción provoca archivos duplicados, pero es lo mejor # XXX: La última opción provoca archivos duplicados, pero es lo mejor
# que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213 # 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 def static_file
return unless path?
@static_file ||= @static_file ||=
case value['path'] case value['path']
when ActionDispatch::Http::UploadedFile when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path']) site.static_files.last if site.static_files.attach(value['path'])
when String when String
if (blob = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first) if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first)
site.static_files.find_by(blob_id: blob) site.static_files.find_by(blob_id: blob_id)
elsif site.static_files.attach(io: path.open, filename: path.basename) elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename)
site.static_files.last site.static_files.last.tap do |s|
s.blob.update(key: key_from_path)
end
end end
end 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 def key_from_path
path.dirname.basename.to_s pathname.dirname.basename.to_s
end end
def path? def path?
value['path'].present? value['path'].present?
end end
def description?
value['description'].present?
end
private private
def filemagic # Obtener la ruta al archivo relativa al sitio
@filemagic ||= FileMagic.new(FileMagic::MAGIC_MIME) #
end
# @return [Pathname] # @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 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
# Si el archivo no llegara a existir, en lugar de hacer fallar todo,
# devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno.
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e)
value['path']
end
def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath)
end
def static_file_path
case static_file.blob.service.name
when :local
File.join(site.path, 'public', static_file.key, static_file.filename.to_s)
else
static_file.blob.service.path_for(static_file.key)
end
end end
# No hay archivo pero se lo describió # No hay archivo pero se lo describió
def no_file_for_description? def no_file_for_description?
value['description'].present? && value['path'].blank? !path? && description?
end end
end end

View file

@ -5,7 +5,7 @@ class MetadataImage < MetadataFile
def validate def validate
super super
errors << I18n.t('metadata.image.not_an_image') unless image? errors << I18n.t('metadata.image.not_an_image') if path? && !image?
errors.compact! errors.compact!
errors.empty? errors.empty?
@ -13,8 +13,6 @@ class MetadataImage < MetadataFile
# Determina si es una imagen # Determina si es una imagen
def image? def image?
return true unless file static_file&.blob&.send(:web_image?)
filemagic.file(file).starts_with? 'image/'
end end
end end

View file

@ -12,6 +12,6 @@ class MetadataMarkdown < MetadataText
# markdown y se eliminan autolinks. Mejor es habilitar la generación # markdown y se eliminan autolinks. Mejor es habilitar la generación
# SAFE de CommonMark en la configuración del sitio. # SAFE de CommonMark en la configuración del sitio.
def sanitize(string) def sanitize(string)
string string.unicode_normalize
end end
end end

View file

@ -25,6 +25,6 @@ class MetadataMarkdownContent < MetadataText
# markdown y se eliminan autolinks. Mejor es deshabilitar la # markdown y se eliminan autolinks. Mejor es deshabilitar la
# generación SAFE de CommonMark en la configuración del sitio. # generación SAFE de CommonMark en la configuración del sitio.
def sanitize(string) def sanitize(string)
string.tr("\r", '') string.tr("\r", '').unicode_normalize
end end
end end

View file

@ -19,7 +19,7 @@ class MetadataPermalink < MetadataString
# puntos suspensivos, la primera / para que siempre sea relativa y # puntos suspensivos, la primera / para que siempre sea relativa y
# agregamos una / al final si la ruta no tiene extensión. # agregamos una / al final si la ruta no tiene extensión.
def sanitize(value) def sanitize(value)
value = value.strip.gsub('..', '/').gsub('./', '').squeeze('/') value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
value = value[1..-1] if value.start_with? '/' value = value[1..-1] if value.start_with? '/'
value += '/' if File.extname(value).blank? value += '/' if File.extname(value).blank?

View file

@ -17,7 +17,7 @@ class MetadataString < MetadataTemplate
def sanitize(string) def sanitize(string)
return '' if string.blank? return '' if string.blank?
sanitizer.sanitize(string.strip, sanitizer.sanitize(string.strip.unicode_normalize,
tags: [], tags: [],
attributes: []).strip.html_safe attributes: []).strip.html_safe
end end

View file

@ -184,9 +184,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
return if string.nil? return if string.nil?
return string unless string.is_a? String return string unless string.is_a? String
sanitizer.sanitize(string.tr("\r", ''), sanitizer
.sanitize(string.tr("\r", '').unicode_normalize,
tags: allowed_tags, tags: allowed_tags,
attributes: allowed_attributes).strip.html_safe attributes: allowed_attributes)
.strip
.html_safe
end end
def sanitizer def sanitizer

View file

@ -1,360 +0,0 @@
# frozen_string_literal: true
# Representa los distintos tipos de campos que pueden venir de una
# plantilla compleja
class Post
class TemplateField
attr_reader :post, :contents, :key
STRING_VALUES = %w[string text url number email password date year
image video audio document].freeze
# Tipo de valores que son archivos
FILE_TYPES = %w[image video audio document].freeze
def initialize(post, key, contents)
@post = post
@key = key
@contents = contents
end
def title
contents.dig('title') if complex?
end
def subtitle
contents.dig('subtitle') if complex?
end
# Obtiene el valor
def value
complex? ? contents.dig('value') : contents
end
def max
return 0 if simple?
contents.fetch('max', 0)
end
def min
return 0 if simple?
contents.fetch('min', 0)
end
# TODO: volver elegante!
def type
return @type if @type
if image?
@type = 'image'
elsif email?
@type = 'email'
elsif url?
@type = 'url'
elsif number?
@type = 'number'
elsif password?
@type = 'password'
elsif date?
@type = 'date'
elsif year?
@type = 'year'
elsif text_area?
@type = 'text_area'
elsif check_box_group?
@type = 'check_box_group'
elsif radio_group?
@type = 'radio_group'
elsif string?
@type = 'text'
# TODO: volver a hacer funcionar esto y ahorranos los multiple:
# false
elsif string? && contents.split('/', 2).count == 2
@type = 'select'
elsif nested?
@type = 'table'
elsif array?
@type = 'select'
elsif boolean?
@type = 'check_box'
end
@type
end
# Devuelve los valores vacíos según el tipo
def empty_value
if string?
''
elsif nested?
# TODO: devolver las keys también
{}
elsif array?
[]
elsif boolean?
false
end
end
def cols
complex? && contents.dig('cols')
end
def align
complex? && contents.dig('align')
end
# El campo es requerido si es complejo y se especifica que lo sea
def required?
complex? && contents.dig('required')
end
def boolean?
value.is_a?(FalseClass) || value.is_a?(TrueClass)
end
def string?
value.is_a? String
end
def text_area?
value == 'text'
end
def url?
value == 'url'
end
def email?
value == 'email' || value == 'mail'
end
alias mail? email?
def date?
value == 'date'
end
def password?
value == 'password'
end
def number?
value == 'number'
end
def year?
value == 'year'
end
def file?
string? && FILE_TYPES.include?(value)
end
def image?
array? ? value.first == 'image' : value == 'image'
end
# Si la plantilla es simple no está admitiendo Hashes como valores
def simple?
!complex?
end
def complex?
contents.is_a? Hash
end
# XXX Retrocompatibilidad
def to_s
key
end
# Convierte el campo en un parámetro
def to_param
if nested?
{ key.to_sym => {} }
elsif array? && multiple?
{ key.to_sym => [] }
else
key.to_sym
end
end
# Convierte la plantilla en el formato de front_matter
def to_front_matter
{ key => empty_value }
end
def check_box_group?
array? && (complex? && contents.fetch('checkbox', false))
end
def radio_group?
array? && (complex? && contents.fetch('radio', false))
end
def array?
value.is_a? Array
end
# TODO: detectar cuando es complejo y tomar el valor de :multiple
def multiple?
# si la plantilla es simple, es multiple cuando tenemos un array
return array? if simple?
array? && contents.fetch('multiple', true)
end
# Detecta si el valor es una tabla de campos
def nested?
value.is_a?(Hash) || (array? && value.first.is_a?(Hash))
end
# Un campo acepta valores abiertos si no es un array con múltiples
# elementos
def open?
# Todos los valores simples son abiertos
return true unless complex?
return false unless array?
# La cosa se complejiza cuando tenemos valores complejos
#
# Si tenemos una lista cerrada de valores, necesitamos saber si el
# campo es abierto o cerrado. Si la lista tiene varios elementos,
# es una lista cerrada, opcionalmente abierta. Si la lista tiene
# un elemento, quiere decir que estamos autocompletando desde otro
# lado.
contents.fetch('open', value.count < 2)
end
def closed?
!open?
end
# Determina si los valores del campo serán públicos después
#
# XXX Esto es solo una indicación, el theme Jekyll tiene que
# respetarlos por su lado luego
def public?
# Todos los campos son públicos a menos que se indique lo
# contrario
simple? || contents.fetch('public', true)
end
def private?
!public?
end
def human
h = key.humanize
h
end
def label
h = (complex? && contents.dig('label')) || human
h += ' *' if required?
h
end
def help
complex? && contents.dig('help')
end
def nested_fields
return unless nested?
v = value
v = value.first if array?
@nested_fields ||= v.map do |k, sv|
Post::TemplateField.new post, k, sv
end
end
# Obtiene los valores posibles para el campo de la plantilla
def values
return 'false' if value == false
return 'true' if value == true
# XXX por alguna razón `value` no refiere a value() :/
return '' if STRING_VALUES.include? value
# Las listas cerradas no necesitan mayor procesamiento
return value if array? && closed? && value.count > 1
# Y las vacías tampoco
return value if array? && value.empty?
# Ahorrarnos el trabajo
return @values if @values
# Duplicar el valor para no tener efectos secundarios luego (?)
value = self.value.dup
# Para obtener los valores posibles, hay que procesar la string y
# convertirla a parametros
# Si es una array de un solo elemento, es un indicador de que
# tenemos que rellenarla con los valores que indica.
#
# El primer valor es el que trae la string de autocompletado
values = array? ? value.shift : value
# Si el valor es un array con más de un elemento, queremos usar
# esas opciones. Pero si además es abierto, queremos traer los
# valores cargados anteriormente.
# Procesamos el valor, buscando : como separador de campos que
# queremos encontrar y luego los unimos
_value = (values&.split(':', 2) || []).map do |v|
# Tenemos hasta tres niveles de búsqueda
collection, attr, subattr = v.split('/', 3)
if collection == 'site'
# TODO: puede ser peligroso permitir acceder a cualquier
# atributo de site? No estamos trayendo nada fuera de
# lo normal
post.site.send(attr.to_sym)
# Si hay un subatributo, tenemos que averiguar todos los
# valores dentro de el
# TODO volver elegante!
# TODO volver recursivo!
elsif subattr
post.site.everything_of(attr, lang: collection)
.compact
.map { |sv| sv[subattr] }
.flatten
.compact
.uniq
else
post.site.everything_of(attr, lang: collection).compact
end
end
# Si el valor es abierto, sumar los valores auto-completados a
# lo pre-cargados.
#
# En este punto _value es un array de 1 o 2 arrays, si es de uno,
# value tambien tiene que serlo. Si es de 2, hay que unir cada
# una
if open?
if _value.count == 1
_value = [(_value.first + value).uniq]
elsif _value.count == 2
_value = _value.each_with_index.map do |v, i|
v + value.fetch(i, [])
end
end
end
# Crea un array de arrays, útil para los select
# [ [ 1, a ], [ 2, b ] ]
# aunque si no hay un : en el autocompletado, el array queda
# [ [ 1, 1 ], [ 2, 2 ] ]
values = _value.empty? ? [] : _value.last.zip(_value.first)
# En última instancia, traer el valor por defecto y ahorrarnos
# volver a procesar
@values = values
end
end
end

View file

@ -57,7 +57,7 @@ class Site < ApplicationRecord
# Carga el sitio Jekyll una vez que se inicializa el modelo o después # Carga el sitio Jekyll una vez que se inicializa el modelo o después
# de crearlo # de crearlo
after_initialize :load_jekyll after_initialize :load_jekyll
after_create :load_jekyll, :static_file_migration! after_create :load_jekyll
# Cambiar el nombre del directorio # Cambiar el nombre del directorio
before_update :update_name! before_update :update_name!
before_save :add_private_key_if_missing! before_save :add_private_key_if_missing!
@ -166,8 +166,10 @@ class Site < ApplicationRecord
end end
# Similar a site.i18n en jekyll-locales # Similar a site.i18n en jekyll-locales
#
# @return [Hash]
def i18n def i18n
data[I18n.locale.to_s] data[I18n.locale.to_s] || {}
end end
# Devuelve el idioma por defecto del sitio, el primero de la lista. # Devuelve el idioma por defecto del sitio, el primero de la lista.
@ -470,15 +472,10 @@ class Site < ApplicationRecord
config.theme = design.gem unless design.no_theme? config.theme = design.gem unless design.no_theme?
config.description = description config.description = description
config.title = title config.title = title
config.url = url config.url = url(slash: false)
config.hostname = hostname config.hostname = hostname
end 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 # Valida si el sitio tiene al menos una forma de alojamiento asociada
# y es la local # y es la local
# #

View file

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

View file

@ -1,7 +1,5 @@
.form-group .form-group
- if metadata.static_file - if metadata.static_file
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
- case metadata.static_file.blob.content_type - case metadata.static_file.blob.content_type
- when %r{\Avideo/} - when %r{\Avideo/}
= video_tag url_for(metadata.static_file), = video_tag url_for(metadata.static_file),
@ -14,13 +12,17 @@
- else - else
= link_to t('posts.attribute_ro.file.download'), = link_to t('posts.attribute_ro.file.download'),
url_for(metadata.static_file) url_for(metadata.static_file)
-# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
-# Los archivos requeridos solo se pueden reemplazar
- unless metadata.required
.custom-control.custom-switch .custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input' = check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input'
= label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label' = label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label'
.custom-file .custom-file
= file_field(*field_name_for(base, attribute, :path), = file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata), **field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}", class: "custom-file-input #{invalid(post, attribute)}",
data: { preview: "#{attribute}-preview" }) data: { preview: "#{attribute}-preview" })
= label_tag "#{base}_#{attribute}_path", = label_tag "#{base}_#{attribute}_path",
@ -30,7 +32,7 @@
.form-group .form-group
= label_tag "#{base}_#{attribute}_description", = label_tag "#{base}_#{attribute}_description",
post_label_t(attribute, :description, post: post) post_label_t(attribute, :description, post: post, required: false)
= text_field(*field_name_for(base, attribute, :description), = text_field(*field_name_for(base, attribute, :description),
value: metadata.value['description'], value: metadata.value['description'],
dir: dir, lang: locale, dir: dir, lang: locale,

View file

@ -1,5 +1,5 @@
.form-group .form-group
- if metadata.uploaded? - if metadata.static_file
= image_tag url_for(metadata.static_file), = image_tag url_for(metadata.static_file),
alt: metadata.value['description'], alt: metadata.value['description'],
class: 'img-fluid', class: 'img-fluid',
@ -37,4 +37,3 @@
**field_options(attribute, metadata, required: false)) **field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata post: post, attribute: [attribute, :description], metadata: metadata

View file

@ -38,6 +38,12 @@ module Sutty
config.active_storage.variant_processor = :vips config.active_storage.variant_processor = :vips
config.to_prepare do
Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.after_initialize do config.after_initialize do
ActiveStorage::DirectUploadsController.include ActiveStorage::AuthenticatedDirectUploadsController ActiveStorage::DirectUploadsController.include ActiveStorage::AuthenticatedDirectUploadsController

View file

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

View file

@ -41,10 +41,12 @@ en:
not_an_image: 'Not an image' not_an_image: 'Not an image'
path_required: 'Missing image for upload' path_required: 'Missing image for upload'
no_file_for_description: "Description with no associated image" no_file_for_description: "Description with no associated image"
attachment_missing: "I couldn't save the image :("
file: file:
site_invalid: 'The file cannot be stored if the site configuration is not valid' site_invalid: 'The file cannot be stored if the site configuration is not valid'
path_required: "Missing file for upload" path_required: "Missing file for upload"
no_file_for_description: "Description with no associated file" no_file_for_description: "Description with no associated file"
attachment_missing: "I couldn't save the file :("
event: event:
zone_missing: 'Inexistent timezone' zone_missing: 'Inexistent timezone'
date_missing: 'Event date is required' date_missing: 'Event date is required'

View file

@ -41,10 +41,12 @@ es:
not_an_image: 'No es una imagen' not_an_image: 'No es una imagen'
path_required: 'Se necesita una imagen' path_required: 'Se necesita una imagen'
no_file_for_description: 'Se envió una descripción sin imagen asociada' no_file_for_description: 'Se envió una descripción sin imagen asociada'
attachment_missing: 'no pude guardar el archivo :('
file: file:
site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida' site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida'
path_required: 'Se necesita un archivo' path_required: 'Se necesita un archivo'
no_file_for_description: 'se envió una descripción sin archivo asociado' no_file_for_description: 'se envió una descripción sin archivo asociado'
attachment_missing: 'no pude guardar el archivo :('
event: event:
zone_missing: 'El huso horario no es correcto' zone_missing: 'El huso horario no es correcto'
date_missing: 'La fecha es obligatoria' date_missing: 'La fecha es obligatoria'