mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-25 04:56:23 +00:00
Merge branch 'rails' of 0xacab.org:sutty/sutty into deploy-rsync
This commit is contained in:
commit
b0848dbf40
31 changed files with 383 additions and 541 deletions
|
@ -67,7 +67,11 @@
|
|||
|
||||
.editor-content {
|
||||
min-height: 480px;
|
||||
p, h1, h2, h3, h4, h5, h6, ul, li, figcaption { outline: #ccc solid thin; }
|
||||
p, h1, h2, h3, h4, h5, h6, ul, li, blockquote, figcaption { outline: #ccc solid thin; }
|
||||
blockquote {
|
||||
border-left: #555 solid .25em;
|
||||
padding: .75em;
|
||||
}
|
||||
strong, em, del, u, sub, sup, small { background: #0002; }
|
||||
a { background: #13fefe50; }
|
||||
[data-editor-selected] { outline: #f206f9 solid thick; }
|
||||
|
|
|
@ -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
|
33
app/controllers/active_storage/disk_controller_decorator.rb
Normal file
33
app/controllers/active_storage/disk_controller_decorator.rb
Normal 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
|
|
@ -6,19 +6,22 @@ module Api
|
|||
class CspReportsController < BaseController
|
||||
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
|
||||
# bajos
|
||||
#
|
||||
# TODO: Aplicar rate_limit
|
||||
def create
|
||||
csp = CspReport.new(csp_report_params.to_h.map do |k, v|
|
||||
[k.tr('-', '_'), v]
|
||||
end.to_h)
|
||||
csp = CspReport.new(csp_report_params.to_h.transform_keys do |k|
|
||||
k.tr('-', '_')
|
||||
end)
|
||||
|
||||
csp.id = SecureRandom.uuid
|
||||
csp.save
|
||||
|
||||
render json: {}, status: :created
|
||||
csp_report_created
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -39,6 +42,10 @@ module Api
|
|||
:'column-number',
|
||||
:'source-file')
|
||||
end
|
||||
|
||||
def csp_report_created
|
||||
render json: {}, status: :created
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ class PostsController < ApplicationController
|
|||
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
||||
|
||||
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
|
||||
|
@ -166,4 +167,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
|
||||
|
|
|
@ -21,11 +21,12 @@ function makeBlock(tag: string): EditorBlock {
|
|||
}
|
||||
|
||||
export const li: EditorBlock = makeBlock("li");
|
||||
const paragraph: EditorBlock = makeBlock("p");
|
||||
|
||||
// XXX: si agregás algo acá, agregalo a blockNames
|
||||
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
|
||||
export const blocks: { [propName: string]: EditorBlock } = {
|
||||
paragraph: makeBlock("p"),
|
||||
paragraph,
|
||||
h1: makeBlock("h1"),
|
||||
h2: makeBlock("h2"),
|
||||
h3: makeBlock("h3"),
|
||||
|
@ -42,6 +43,11 @@ export const blocks: { [propName: string]: EditorBlock } = {
|
|||
allowedChildren: ["li"],
|
||||
handleEmpty: li,
|
||||
},
|
||||
blockquote: {
|
||||
...makeBlock("blockquote"),
|
||||
allowedChildren: blockNames,
|
||||
handleEmpty: paragraph,
|
||||
},
|
||||
};
|
||||
|
||||
export function setupButtons(editor: Editor): void {
|
||||
|
|
|
@ -137,8 +137,10 @@ export function setupAuxiliaryToolbar(editor: Editor): void {
|
|||
"click",
|
||||
(event) => {
|
||||
const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
|
||||
if (!files || !files.length)
|
||||
throw new Error("no hay archivos para subir");
|
||||
if (!files || !files.length) {
|
||||
console.info("no hay archivos para subir");
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
|
||||
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||
|
|
|
@ -20,7 +20,6 @@ function makeParentBlock(
|
|||
};
|
||||
}
|
||||
|
||||
// TODO: añadir blockquote
|
||||
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
|
||||
// en app/views/posts/attributes/_content.haml
|
||||
export const parentBlocks: { [propName: string]: EditorNode } = {
|
||||
|
|
|
@ -10,6 +10,7 @@ export const blockNames = [
|
|||
"h6",
|
||||
"unordered_list",
|
||||
"ordered_list",
|
||||
"blockquote",
|
||||
];
|
||||
export const markNames = [
|
||||
"bold",
|
||||
|
|
21
app/lib/action_dispatch/http/uploaded_file_decorator.rb
Normal file
21
app/lib/action_dispatch/http/uploaded_file_decorator.rb
Normal 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
|
|
@ -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
|
82
app/lib/active_storage/service/jekyll_service.rb
Normal file
82
app/lib/active_storage/service/jekyll_service.rb
Normal 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
|
31
app/lib/active_storage/service/registry_decorator.rb
Normal file
31
app/lib/active_storage/service/registry_decorator.rb
Normal 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
|
26
app/models/active_storage/blob_decorator.rb
Normal file
26
app/models/active_storage/blob_decorator.rb
Normal 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
|
|
@ -13,12 +13,18 @@ class MetadataFile < MetadataTemplate
|
|||
value == default_value
|
||||
end
|
||||
|
||||
# No hay valores sugeridos para archivos subidos.
|
||||
#
|
||||
# XXX: Esto ayuda a deserializar en {Site#everything_of}
|
||||
def values; end
|
||||
|
||||
def validate
|
||||
super
|
||||
|
||||
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") if path? && !static_file
|
||||
|
||||
errors.compact!
|
||||
errors.empty?
|
||||
|
@ -34,12 +40,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 +48,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 +65,88 @@ 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)
|
||||
site.static_files.last
|
||||
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.tap do |s|
|
||||
s.blob.update(key: key_from_path)
|
||||
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
|
||||
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
|
||||
# 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
|
||||
|
||||
# No hay archivo pero se lo describió
|
||||
def no_file_for_description?
|
||||
value['description'].present? && value['path'].blank?
|
||||
!path? && description?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ class MetadataImage < MetadataFile
|
|||
def validate
|
||||
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.empty?
|
||||
|
@ -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
|
||||
|
|
|
@ -12,6 +12,6 @@ class MetadataMarkdown < MetadataText
|
|||
# markdown y se eliminan autolinks. Mejor es habilitar la generación
|
||||
# SAFE de CommonMark en la configuración del sitio.
|
||||
def sanitize(string)
|
||||
string
|
||||
string.unicode_normalize
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,6 +25,6 @@ class MetadataMarkdownContent < MetadataText
|
|||
# markdown y se eliminan autolinks. Mejor es deshabilitar la
|
||||
# generación SAFE de CommonMark en la configuración del sitio.
|
||||
def sanitize(string)
|
||||
string.tr("\r", '')
|
||||
string.tr("\r", '').unicode_normalize
|
||||
end
|
||||
end
|
||||
|
|
|
@ -19,7 +19,7 @@ class MetadataPermalink < MetadataString
|
|||
# puntos suspensivos, la primera / para que siempre sea relativa y
|
||||
# agregamos una / al final si la ruta no tiene extensión.
|
||||
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 += '/' if File.extname(value).blank?
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class MetadataString < MetadataTemplate
|
|||
def sanitize(string)
|
||||
return '' if string.blank?
|
||||
|
||||
sanitizer.sanitize(string.strip,
|
||||
sanitizer.sanitize(string.strip.unicode_normalize,
|
||||
tags: [],
|
||||
attributes: []).strip.html_safe
|
||||
end
|
||||
|
|
|
@ -184,9 +184,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
return if string.nil?
|
||||
return string unless string.is_a? String
|
||||
|
||||
sanitizer.sanitize(string.tr("\r", ''),
|
||||
tags: allowed_tags,
|
||||
attributes: allowed_attributes).strip.html_safe
|
||||
sanitizer
|
||||
.sanitize(string.tr("\r", '').unicode_normalize,
|
||||
tags: allowed_tags,
|
||||
attributes: allowed_attributes)
|
||||
.strip
|
||||
.html_safe
|
||||
end
|
||||
|
||||
def sanitizer
|
||||
|
@ -199,7 +202,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
end
|
||||
|
||||
def allowed_tags
|
||||
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure
|
||||
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote
|
||||
figcaption a sub sup small].freeze
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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!
|
||||
|
@ -166,8 +166,10 @@ class Site < ApplicationRecord
|
|||
end
|
||||
|
||||
# Similar a site.i18n en jekyll-locales
|
||||
#
|
||||
# @return [Hash]
|
||||
def i18n
|
||||
data[I18n.locale.to_s]
|
||||
data[I18n.locale.to_s] || {}
|
||||
end
|
||||
|
||||
# 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.description = description
|
||||
config.title = title
|
||||
config.url = url
|
||||
config.url = url(slash: false)
|
||||
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
|
||||
#
|
||||
|
|
|
@ -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
|
|
@ -95,6 +95,9 @@
|
|||
%button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }>
|
||||
%i.fa.fa-fw.fa-align-right>
|
||||
%span.sr-only>= t('editor.right')
|
||||
%button.btn{ type: 'button', title: t('editor.blockquote'), data: { editor_button: 'block-blockquote' } }>
|
||||
%i.fa.fa-fw.fa-quote-left>
|
||||
%span.sr-only>= t('editor.blockquote')
|
||||
|
||||
-# HAML cringe
|
||||
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } }
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
.form-group
|
||||
- if metadata.static_file
|
||||
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
|
||||
|
||||
- case metadata.static_file.blob.content_type
|
||||
- when %r{\Avideo/}
|
||||
= video_tag url_for(metadata.static_file),
|
||||
|
@ -14,13 +12,17 @@
|
|||
- else
|
||||
= link_to t('posts.attribute_ro.file.download'),
|
||||
url_for(metadata.static_file)
|
||||
.custom-control.custom-switch
|
||||
= 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'
|
||||
-# 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
|
||||
= 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'
|
||||
|
||||
.custom-file
|
||||
= 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)}",
|
||||
data: { preview: "#{attribute}-preview" })
|
||||
= label_tag "#{base}_#{attribute}_path",
|
||||
|
@ -30,7 +32,7 @@
|
|||
|
||||
.form-group
|
||||
= 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),
|
||||
value: metadata.value['description'],
|
||||
dir: dir, lang: locale,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.form-group
|
||||
- if metadata.uploaded?
|
||||
- if metadata.static_file
|
||||
= image_tag url_for(metadata.static_file),
|
||||
alt: metadata.value['description'],
|
||||
class: 'img-fluid',
|
||||
|
@ -37,4 +37,3 @@
|
|||
**field_options(attribute, metadata, required: false))
|
||||
= render 'posts/attribute_feedback',
|
||||
post: post, attribute: [attribute, :description], metadata: metadata
|
||||
|
||||
|
|
|
@ -38,6 +38,12 @@ module Sutty
|
|||
|
||||
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
|
||||
ActiveStorage::DirectUploadsController.include ActiveStorage::AuthenticatedDirectUploadsController
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
@ -572,6 +574,7 @@ en:
|
|||
left: Left
|
||||
right: Right
|
||||
center: Center
|
||||
blockquote: Quote
|
||||
color: Color
|
||||
text-color: Text color
|
||||
multimedia: Media
|
||||
|
|
|
@ -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'
|
||||
|
@ -580,6 +582,7 @@ es:
|
|||
left: Izquierda
|
||||
right: Derecha
|
||||
center: Centro
|
||||
blockquote: Cita
|
||||
color: Color
|
||||
text-color: Color del texto
|
||||
multimedia: Multimedia
|
||||
|
|
Loading…
Reference in a new issue