diff --git a/.dockerignore b/.dockerignore index b9e4842e..afe4e8d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,4 @@ # Excluir todo * # Solo agregar lo que usamos en COPY -!./.git/ -!./rubygems-platform-musl.patch -!./Gemfile -!./Gemfile.lock -!./config/credentials.yml.enc -!./public/assets/ -!./public/packs/ +# !./archivo diff --git a/Dockerfile b/Dockerfile index ee6ba871..ecf43cbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,125 +1,21 @@ -# Este Dockerfile está armado pensando en una compilación lanzada desde -# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas -# como el tarball van a tener que cambiar porque ya vamos a haber hecho -# un clone/pull limpio. -FROM alpine:3.13.6 AS build -MAINTAINER "f " - -ARG RAILS_MASTER_KEY -ARG BRANCH - -# Un entorno base -ENV BRANCH=$BRANCH -ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake +FROM registry.nulo.in/sutty/rails:3.13.6-2.7.5 +ARG PANDOC_VERSION=2.17.1.1 ENV RAILS_ENV production -ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY - -RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake -RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3 - -RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'` - -# https://github.com/rubygems/rubygems/issues/2918 -# https://gitlab.alpinelinux.org/alpine/aports/issues/10808 -RUN apk add --no-cache patch -COPY ./rubygems-platform-musl.patch /tmp/ -RUN cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch - -# Agregar el usuario -RUN addgroup -g 82 -S www-data -RUN adduser -s /bin/sh -G www-data -h /home/app -D app -RUN install -dm750 -o app -g www-data /home/app/sutty -RUN gem install --no-document bundler:2.1.4 - -# Empezamos con la usuaria app -USER app -# Vamos a trabajar dentro de este directorio -WORKDIR /home/app/sutty - -# Copiamos solo el Gemfile para poder instalar las gemas necesarias -COPY --chown=app:www-data ./Gemfile . -COPY --chown=app:www-data ./Gemfile.lock . -RUN bundle config set no-cache true -RUN bundle config set specific_platform true -RUN bundle install --path=./vendor --without='test development' -# Vaciar la caché -RUN rm vendor/ruby/2.7.0/cache/*.gem - -# Copiar el repositorio git -COPY --chown=app:www-data ./.git/ ./.git/ -# Hacer un clon limpio del repositorio en lugar de copiar todos los -# archivos -RUN cd .. && git clone sutty checkout -RUN cd ../checkout && git checkout $BRANCH - -WORKDIR /home/app/checkout -# Traer las gemas: -RUN rm -rf ./vendor -RUN mv ../sutty/vendor ./vendor -RUN mv ../sutty/.bundle ./.bundle - -# Instalar secretos -COPY --chown=app:root ./config/credentials.yml.enc ./config/ - -RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc -# Eliminar archivos innecesarios -USER root -RUN apk add --no-cache findutils -RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf - -# Contenedor final -FROM registry.nulo.in/sutty/monit:3.13.6 -ENV RAILS_ENV production - -# Pandoc -RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/repositories # Instalar las dependencias, separamos la librería de base de datos para # poder reutilizar este primer paso desde otros contenedores -RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake ruby-irb ruby-io-console ruby-etc -RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips -RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc -RUN apk add --no-cache git-lfs openssh-client patch - -# Chequear que la versión de ruby sea la correcta -RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'` - -# https://github.com/rubygems/rubygems/issues/2918 -# https://gitlab.alpinelinux.org/alpine/aports/issues/10808 -COPY ./rubygems-platform-musl.patch /tmp/ -RUN apk add --no-cache patch && cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch && apk del patch - +# # Necesitamos yarn para que Jekyll pueda generar los sitios # XXX: Eliminarlo cuando extraigamos la generación de sitios del proceso # principal -RUN apk add --no-cache yarn -# Instalar foreman para poder correr los servicios -RUN gem install --no-document --no-user-install bundler:2.1.4 foreman +RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \ + rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \ + yarn daemonize ruby-webrick -# Agregar el grupo del servidor web y la usuaria -RUN addgroup -g 82 -S www-data -RUN adduser -s /bin/sh -G www-data -h /srv/http -D app +RUN gem install --no-document --no-user-install foreman +RUN wget https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz -O - | tar --strip-components 1 -xvzf - pandoc-${PANDOC_VERSION}/bin/pandoc && mv /bin/pandoc /usr/bin/pandoc -# Convertirse en app para instalar -USER app -COPY --from=build --chown=app:www-data /home/app/checkout /srv/http -COPY --chown=app:www-data ./.git/ ./.git/ -RUN rm -rf /srv/http/_sites /srv/http/_deploy -RUN ln -s data/_storage /srv/http/_storage -RUN ln -s data/_sites /srv/http/_sites -RUN ln -s data/_deploy /srv/http/_deploy -RUN ln -s data/_private /srv/http/_private +VOLUME "/srv" -# Volver a root para cerrar la compilación -USER root -# Instalar la configuración de monit -RUN install -m 640 -o root -g root /srv/http/monit.conf /etc/monit.d/sutty.conf -RUN apk add --no-cache daemonize ruby-webrick -RUN install -m 755 /srv/http/entrypoint.sh /usr/local/bin/sutty - -# Mantener estos directorios! -VOLUME "/srv/http/data" - -# El puerto de puma EXPOSE 3000 EXPOSE 9394 diff --git a/Makefile b/Makefile index 5a9ad7a8..584d07d1 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ hain ?= ENV_FILE=.env $(HAINISH)## Ubicación de Hainish # # Production es el entorno de panel.sutty.nl ifeq ($(env),production) -container ?= sutty +container ?= panel ## TODO: Cambiar a otra cosa branch ?= rails public ?= public @@ -115,15 +115,9 @@ ota-js: assets ## Actualizar Javascript en el nodo delegado ssh root@$(delegate) docker exec $(container) sh -c "cat /srv/http/tmp/puma.pid | xargs -r kill -USR2" ota: ## Actualizar Rails en el nodo delegado - umask 022; git format-patch $(commit) - ssh $(delegate) mkdir -p /tmp/patches-$(commit)/ - scp ./0*.patch $(delegate):/tmp/patches-$(commit)/ - scp ./ota.sh $(delegate):/tmp/ - ssh $(delegate) docker cp /tmp/patches-$(shell echo $(commit) | cut -d / -f 1) $(container):/tmp/ - ssh $(delegate) docker cp /tmp/ota.sh $(container):/usr/local/bin/ota - ssh $(delegate) docker exec $(container) apk add --no-cache patch - ssh $(delegate) docker exec $(container) ota $(commit) - rm ./0*.patch + ssh $(delegate) git -C /srv/sutty/srv/http/panel.sutty.nl pull ; true + ssh $(delegate) chown -R 1000:82 /srv/sutty/srv/http/panel.sutty.nl + ssh $(delegate) docker exec $(container) rails reload # Todos los archivos de assets. Si alguno cambia, se van a recompilar # los assets que luego se suben al nodo delegado. diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss index 30fab60a..85bf4471 100644 --- a/app/assets/stylesheets/editor.scss +++ b/app/assets/stylesheets/editor.scss @@ -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; } diff --git a/app/controllers/active_storage/direct_uploads_controller_decorator.rb b/app/controllers/active_storage/direct_uploads_controller_decorator.rb new file mode 100644 index 00000000..c62dae2a --- /dev/null +++ b/app/controllers/active_storage/direct_uploads_controller_decorator.rb @@ -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 diff --git a/app/controllers/active_storage/disk_controller_decorator.rb b/app/controllers/active_storage/disk_controller_decorator.rb new file mode 100644 index 00000000..14366a15 --- /dev/null +++ b/app/controllers/active_storage/disk_controller_decorator.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActiveStorage + # Modificar {DiskController} para poder asociar el blob a un sitio + module DiskControllerDecorator + extend ActiveSupport::Concern + + included do + # Asociar el archivo subido al sitio correspondiente. Cada sitio + # tiene su propio servicio de subida de archivos. + def update + if (token = decode_verified_token) + if acceptable_content?(token) + named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum] + + blob = ActiveStorage::Blob.find_by_key token[:key] + site = Site.find_by_name token[:service_name] + + site.static_files.attach(blob) + else + head :unprocessable_entity + end + else + head :not_found + end + rescue ActiveStorage::IntegrityError + head :unprocessable_entity + end + end + end +end + +ActiveStorage::DiskController.include ActiveStorage::DiskControllerDecorator diff --git a/app/controllers/api/v1/csp_reports_controller.rb b/app/controllers/api/v1/csp_reports_controller.rb index bc6cfae0..ea186729 100644 --- a/app/controllers/api/v1/csp_reports_controller.rb +++ b/app/controllers/api/v1/csp_reports_controller.rb @@ -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 diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index dbdd4d0a..2aff9ac9 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -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 diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts index 2e2dea7e..956b79d9 100644 --- a/app/javascript/editor/types/blocks.ts +++ b/app/javascript/editor/types/blocks.ts @@ -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 { diff --git a/app/javascript/editor/utils.ts b/app/javascript/editor/utils.ts index 167c0a6d..b0bed66e 100644 --- a/app/javascript/editor/utils.ts +++ b/app/javascript/editor/utils.ts @@ -10,6 +10,7 @@ export const blockNames = [ "h6", "unordered_list", "ordered_list", + "blockquote", ]; export const markNames = [ "bold", diff --git a/app/lib/action_dispatch/http/uploaded_file_decorator.rb b/app/lib/action_dispatch/http/uploaded_file_decorator.rb new file mode 100644 index 00000000..0bdebdc0 --- /dev/null +++ b/app/lib/action_dispatch/http/uploaded_file_decorator.rb @@ -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 diff --git a/app/lib/active_storage/attached/changes/create_one_decorator.rb b/app/lib/active_storage/attached/changes/create_one_decorator.rb new file mode 100644 index 00000000..bfb92478 --- /dev/null +++ b/app/lib/active_storage/attached/changes/create_one_decorator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ActiveStorage + module Attached::Changes::CreateOneDecorator + extend ActiveSupport::Concern + + included do + private + + # A partir de ahora todos los archivos se suben al servicio de + # cada sitio. + def attachment_service_name + record.name.to_sym + end + end + end +end + +ActiveStorage::Attached::Changes::CreateOne.include ActiveStorage::Attached::Changes::CreateOneDecorator diff --git a/app/lib/active_storage/service/jekyll_service.rb b/app/lib/active_storage/service/jekyll_service.rb new file mode 100644 index 00000000..92b26e0e --- /dev/null +++ b/app/lib/active_storage/service/jekyll_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module ActiveStorage + class Service + # Sube los archivos a cada repositorio y los agrega al LFS de su + # repositorio git. + # + # @todo: Implementar LFS. No nos gusta mucho la idea porque duplica + # el espacio en disco, pero es la única forma que tenemos (hasta que + # implementemos IPFS) para poder transferir los archivos junto con el + # sitio. + class JekyllService < Service::DiskService + # Genera un servicio para un sitio determinado + # + # @param :site [Site] + # @return [ActiveStorage::Service::JekyllService] + def self.build_for_site(site:) + new(root: File.join(site.path, 'public'), public: true).tap do |js| + js.name = site.name.to_sym + end + end + + # Lo mismo que en DiskService agregando el nombre de archivo en la + # firma. Esto permite que luego podamos guardar el archivo donde + # corresponde. + # + # @param :key [String] + # @param :expires_in [Integer] + # @param :content_type [String] + # @param :content_length [Integer] + # @param :checksum [String] + # @return [String] + def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:) + instrument :url, key: key do |payload| + verified_token_with_expiration = ActiveStorage.verifier.generate( + { + key: key, + content_type: content_type, + content_length: content_length, + checksum: checksum, + service_name: name, + filename: filename_for(key) + }, + expires_in: expires_in, + purpose: :blob_token + ) + + generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host) + + payload[:url] = generated_url + + generated_url + end + end + + # Mantener retrocompatibilidad con cómo gestionamos los archivos + # subidos hasta ahora. + # + # @param :key [String] + # @return [String] + def folder_for(key) + key + end + + # Obtiene el nombre de archivo para esta key + # + # @param :key [String] + # @return [String] + def filename_for(key) + ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first + end + + # Crea una ruta para la llave con un nombre conocido. + # + # @param :key [String] + # @return [String] + def path_for(key) + File.join root, folder_for(key), filename_for(key) + end + end + end +end diff --git a/app/lib/active_storage/service/registry_decorator.rb b/app/lib/active_storage/service/registry_decorator.rb new file mode 100644 index 00000000..c7096356 --- /dev/null +++ b/app/lib/active_storage/service/registry_decorator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActiveStorage + class Service + # Modificaciones a ActiveStorage::Service::Registry + module RegistryDecorator + extend ActiveSupport::Concern + + included do + # El mismo comportamiento que #fetch con el agregado de generar + # un {JekyllService} para cada sitio. + def fetch(name) + services.fetch(name.to_sym) do |key| + if configurations.include?(key) + services[key] = configurator.build(key) + elsif (site = Site.find_by_name(key)) + services[key] = ActiveStorage::Service::JekyllService.build_for_site(site: site) + elsif block_given? + yield key + else + raise KeyError, "Missing configuration for the #{key} Active Storage service. " \ + "Configurations available for the #{configurations.keys.to_sentence} services." + end + end + end + end + end + end +end + +ActiveStorage::Service::Registry.include ActiveStorage::Service::RegistryDecorator diff --git a/app/models/active_storage/blob_decorator.rb b/app/models/active_storage/blob_decorator.rb new file mode 100644 index 00000000..9c01251a --- /dev/null +++ b/app/models/active_storage/blob_decorator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveStorage + # Modificaciones a ActiveStorage::Blob + module BlobDecorator + extend ActiveSupport::Concern + + included do + # Permitir que llegue el nombre de archivo al servicio de subida de + # archivos. + # + # @return [Hash] + def service_metadata + if forcibly_serve_as_binary? + { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename } + elsif !allowed_inline? + { content_type: content_type, disposition: :attachment, filename: filename } + else + { content_type: content_type, filename: filename } + end + end + end + end +end + +ActiveStorage::Blob.include ActiveStorage::BlobDecorator diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb index 80cefa27..71d3f049 100644 --- a/app/models/metadata_file.rb +++ b/app/models/metadata_file.rb @@ -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 diff --git a/app/models/metadata_image.rb b/app/models/metadata_image.rb index f91a6273..fc61a4d7 100644 --- a/app/models/metadata_image.rb +++ b/app/models/metadata_image.rb @@ -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 diff --git a/app/models/metadata_markdown.rb b/app/models/metadata_markdown.rb index 1e8b4fc8..a09e351c 100644 --- a/app/models/metadata_markdown.rb +++ b/app/models/metadata_markdown.rb @@ -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 diff --git a/app/models/metadata_markdown_content.rb b/app/models/metadata_markdown_content.rb index 92a1ab21..75088e30 100644 --- a/app/models/metadata_markdown_content.rb +++ b/app/models/metadata_markdown_content.rb @@ -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 diff --git a/app/models/metadata_permalink.rb b/app/models/metadata_permalink.rb index 59b68461..30ad32cc 100644 --- a/app/models/metadata_permalink.rb +++ b/app/models/metadata_permalink.rb @@ -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? diff --git a/app/models/metadata_string.rb b/app/models/metadata_string.rb index 95aac4d4..c1d888b1 100644 --- a/app/models/metadata_string.rb +++ b/app/models/metadata_string.rb @@ -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 diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 5baa7a4a..c778e1b2 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -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 diff --git a/app/models/site.rb b/app/models/site.rb index 5b78d625..7d4875e5 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -57,7 +57,7 @@ class Site < ApplicationRecord # Carga el sitio Jekyll una vez que se inicializa el modelo o después # de crearlo after_initialize :load_jekyll - after_create :load_jekyll, :static_file_migration! + after_create :load_jekyll # Cambiar el nombre del directorio before_update :update_name! before_save :add_private_key_if_missing! @@ -474,11 +474,6 @@ class Site < ApplicationRecord config.hostname = hostname end - # Migra los archivos a Sutty - def static_file_migration! - Site::StaticFileMigration.new(site: self).migrate! - end - # Valida si el sitio tiene al menos una forma de alojamiento asociada # y es la local # diff --git a/app/models/site/static_file_migration.rb b/app/models/site/static_file_migration.rb deleted file mode 100644 index 36a882bf..00000000 --- a/app/models/site/static_file_migration.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -class Site - # Obtiene todos los archivos relacionados en artículos del sitio y los - # sube a Sutty. - class StaticFileMigration - # Tipos de metadatos que contienen archivos - STATIC_TYPES = %i[file image].freeze - - attr_reader :site - - def initialize(site:) - @site = site - end - - def migrate! - modified = site.docs.map do |doc| - next unless STATIC_TYPES.map do |field| - next unless doc.attribute? field - next unless doc[field].path? - next unless doc[field].static_file - - true - end.any? - - log.write "#{doc.path.relative};no se pudo guardar\n" unless doc.save(validate: false) - - doc.path.absolute - end.compact - - log.close - - return if modified.empty? - - # TODO: Hacer la migración desde el servicio de creación de sitios? - site.repository.commit(file: modified, - message: I18n.t('sites.static_file_migration'), - usuarie: author) - end - - private - - def author - @author ||= GitAuthor.new email: "sutty@#{Site.domain}", - name: 'Sutty' - end - - def log - @log ||= File.open(File.join(site.path, 'migration.csv'), 'w') - end - end -end diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 4ae70ba0..36b88872 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -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: '' } } diff --git a/config/application.rb b/config/application.rb index 7326ae0f..031c1909 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/initializers/analyze_job.rb b/config/initializers/analyze_job.rb deleted file mode 100644 index f268e0dd..00000000 --- a/config/initializers/analyze_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -# TODO: Estamos procesando el análisis de los archivos en el momento -# porque queremos obtener la ruta del archivo en el momento y no -# después. Necesitaríamos poder generar el vínculo en el -# repositorio a destiempo, modificando el Job de ActiveStorage -ActiveStorage::AnalyzeJob.queue_adapter = :inline diff --git a/config/locales/en.yml b/config/locales/en.yml index b814796d..530a9381 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,10 +41,12 @@ en: not_an_image: 'Not an image' path_required: 'Missing image for upload' no_file_for_description: "Description with no associated image" + attachment_missing: "I couldn't save the image :(" file: site_invalid: 'The file cannot be stored if the site configuration is not valid' path_required: "Missing file for upload" no_file_for_description: "Description with no associated file" + attachment_missing: "I couldn't save the file :(" event: zone_missing: 'Inexistent timezone' date_missing: 'Event date is required' @@ -568,6 +570,7 @@ en: left: Left right: Right center: Center + blockquote: Quote color: Color text-color: Text color multimedia: Media diff --git a/config/locales/es.yml b/config/locales/es.yml index a6fbd407..eaa23137 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -41,10 +41,12 @@ es: not_an_image: 'No es una imagen' path_required: 'Se necesita una imagen' no_file_for_description: 'Se envió una descripción sin imagen asociada' + attachment_missing: 'no pude guardar el archivo :(' file: site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida' path_required: 'Se necesita un archivo' no_file_for_description: 'se envió una descripción sin archivo asociado' + attachment_missing: 'no pude guardar el archivo :(' event: zone_missing: 'El huso horario no es correcto' date_missing: 'La fecha es obligatoria' @@ -576,6 +578,7 @@ es: left: Izquierda right: Derecha center: Centro + blockquote: Cita color: Color text-color: Color del texto multimedia: Multimedia diff --git a/entrypoint.sh b/entrypoint.sh index 3ae103bb..c6bfdb3b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,10 +1,38 @@ #!/bin/sh set -e +s_pid=/srv/tmp/puma.pid +p_pid=/tmp/prometheus.pid + case $1 in - sutty) - su app -c "cd /srv/http && foreman start migrate" - daemonize -c /srv/http -u app /usr/bin/foreman start sutty + start) + su rails -c "cd /srv && foreman run migrate" + daemonize -c /srv -u rails /usr/bin/foreman start sutty + ;; + + stop) + cat $s_pid | xargs -r kill + ;; + + reload) + cat $s_pid | xargs -r kill -USR2 + ;; + + prometheus) + case $2 in + start) + rm -f $p_pid + daemonize -c /srv -p $p_pid -l $p_pid -u rails /usr/bin/foreman start prometheus + ;; + stop) + cat $p_pid | xargs -r kill + rm -f $p_pid + ;; + esac + ;; + + blazer) + test -z "$2" || b="_$2" + su rails -c "cd /srv && foreman run blazer$b" ;; - prometheus) daemonize -c /srv/http -p /tmp/prometheus.pid -l /tmp/prometheus.pid -u app /usr/bin/foreman start prometheus ;; esac diff --git a/monit.conf b/monit.conf index f574c56d..96c08d8a 100644 --- a/monit.conf +++ b/monit.conf @@ -1,31 +1,27 @@ -check process sutty with pidfile /srv/http/tmp/puma.pid - start program = "/usr/local/bin/sutty sutty" - stop program = "/bin/sh -c 'cat /srv/http/tmp/puma.pid | xargs kill'" +check process sutty with pidfile /srv/tmp/puma.pid + start program = "/usr/local/bin/sutty start" + stop program = "/usr/local/bin/sutty stop" check process prometheus with pidfile /tmp/prometheus.pid - start program = "/usr/local/bin/sutty prometheus" - stop program = "/bin/sh -c 'cat /tmp/prometheus.pid | xargs kill'" + start program = "/usr/local/bin/sutty prometheus start" + stop program = "/usr/local/bin/sutty prometheus start" check program blazer_5m - with path "/bin/sh -c 'cd /srv/http && foreman start blazer_5m'" - as uid "app" and gid "www-data" + with path "/usr/local/bin/sutty blazer 5m" every 5 cycles if status != 0 then alert check program blazer_1h - with path "/bin/sh -c 'cd /srv/http && foreman start blazer_1h'" - as uid "app" and gid "www-data" + with path "/usr/local/bin/sutty blazer 1h" every 60 cycles if status != 0 then alert check program blazer_1d - with path "/bin/sh -c 'cd /srv/http && foreman start blazer_1d'" - as uid "app" and gid "www-data" + with path "/usr/local/bin/sutty blazer 1d" every 1440 cycles if status != 0 then alert check program blazer - with path "/bin/sh -c 'cd /srv/http && foreman start blazer'" - as uid "app" and gid "www-data" + with path "/usr/local/bin/sutty blazer" every 61 cycles if status != 0 then alert