diff --git a/.dockerignore b/.dockerignore index afe4e8d7..7b84d429 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ * # Solo agregar lo que usamos en COPY # !./archivo +!./monit.conf diff --git a/.env.example b/.env.example index fb086224..f3cf48d9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ +# pwgen -1 32 +RAILS_MASTER_KEY=11111111111111111111111111111111 RAILS_GROUPS=assets DELEGATE=athshe.sutty.nl HAINISH=../haini.sh/haini.sh -DATABASE= +DATABASE_URL=postgres://suttier@postgresql.sutty.local/sutty RAILS_ENV=development IMAP_SERVER= DEFAULT_FROM= diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 00000000..2e775624 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,71 @@ +pipeline: + publish: + image: "docker.io/woodpeckerci/plugin-docker-buildx" + settings: + registry: "gitea.nulo.in" + username: "sutty" + repo: "gitea.nulo.in/sutty/panel" + tags: + - "${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}" + - "latest" + build_args: + - "RUBY_VERSION=${RUBY_VERSION}" + - "RUBY_PATCH=${RUBY_PATCH}" + - "ALPINE_VERSION=${ALPINE_VERSION}" + - "BASE_IMAGE=gitea.nulo.in/sutty/rails" + purge: false + secrets: + - "DOCKER_PASSWORD" + when: + branch: + - "rails" + - "panel.sutty.nl" + event: "push" + path: + include: + - "Dockerfile" + - ".dockerignore" + assets: + image: "gitea.nulo.in/sutty/panel:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}" + commands: + - "apk add python2 dotenv openssh-client brotli" + - "install -d -m 700 ~/.ssh/" + - "echo \"$${KNOWN_HOSTS}\" | base64 -d >> ~/.ssh/known_hosts" + - "chmod 600 ~/.ssh/known_hosts" + - "eval $(ssh-agent -s)" + - "echo \"$${SSH_KEY}\" | base64 -d | ssh-add -" + - "ssh $${ORIGIN%:*}" + - "git config user.name Woodpecker" + - "git config user.email ci@sutty.coop.ar" + - "git remote add upstream $${ORIGIN}" + - "git checkout -B ${CI_COMMIT_BRANCH}" + - "mv config/credentials.yml.enc.ci config/credentials.yml.enc" + - "yarn" + - "cp .env.example .env" + - "dotenv bundle install --path=vendor" + - "dotenv RAILS_ENV=production bundle exec rails webpacker:clobber" + - "dotenv RAILS_ENV=production bundle exec rails assets:precompile" + - "dotenv RAILS_ENV=production bundle exec rails assets:clean" + - "find public -type f -print0 | xargs -r0 brotli -k9f" + - "git add public && git commit -m \"ci: assets [skip ci]\"" + - "git pull upstream ${CI_COMMIT_BRANCH}" + - "git push upstream ${CI_COMMIT_BRANCH}" + secrets: + - "SSH_KEY" + - "KNOWN_HOSTS" + - "ORIGIN" + when: + branch: + - "rails" + - "panel.sutty.nl" + path: + include: + - "app/assets/**/*" + - "app/javascript/**/*" + - "package.json" + - "yarn.lock" +matrix: + include: + - ALPINE_VERSION: "3.14.10" + RUBY_VERSION: "2.7" + RUBY_PATCH: "8" diff --git a/Dockerfile b/Dockerfile index ecf43cbc..15abee59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ -FROM registry.nulo.in/sutty/rails:3.13.6-2.7.5 -ARG PANDOC_VERSION=2.17.1.1 +ARG RUBY_VERSION=2.7 +ARG RUBY_PATCH=6 +ARG ALPINE_VERSION=3.13.10 +ARG BASE_IMAGE=registry.nulo.in/sutty/rails +FROM ${BASE_IMAGE}:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH} +ARG PANDOC_VERSION=2.18 ENV RAILS_ENV production # Instalar las dependencias, separamos la librería de base de datos para @@ -10,10 +14,13 @@ ENV RAILS_ENV production # principal RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \ rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \ - yarn daemonize ruby-webrick + yarn daemonize ruby-webrick postgresql-client dateutils file 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 +RUN apk add npm && npm install -g pnpm@~7 && apk del npm + +COPY ./monit.conf /etc/monit.d/sutty.conf VOLUME "/srv" diff --git a/Gemfile b/Gemfile index 78eb020c..560401a5 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,8 @@ gem 'commonmarker' gem 'devise' gem 'devise-i18n' gem 'devise_invitable' +gem 'distributed-press-api-client', '~> 0.2.2' +gem 'njalla-api-client' gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n' gem 'exception_notification' gem 'fast_blank' @@ -89,7 +91,7 @@ gem 'stackprof' gem 'prometheus_exporter' # debug -gem 'fast_jsonparser' +gem 'fast_jsonparser', '~> 0.5.0' gem 'down' gem 'sourcemap' gem 'rack-cors' diff --git a/Gemfile.lock b/Gemfile.lock index dffe90bf..3f8be3a1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,6 +115,7 @@ GEM xpath (>= 2.0, < 4.0) chartkick (4.1.2) childprocess (4.1.0) + climate_control (1.2.0) coderay (1.1.3) colorator (1.1.0) commonmarker (0.21.2-x86_64-linux-musl) @@ -153,12 +154,45 @@ GEM devise_invitable (2.0.5) actionmailer (>= 5.0) devise (>= 4.6) + distributed-press-api-client (0.2.2) + addressable (~> 2.3, >= 2.3.0) + climate_control + dry-schema + httparty (~> 0.18) + json (~> 2.1, >= 2.1.0) + jwt (~> 2.6.0) dotenv (2.7.6) dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) down (5.2.4) addressable (~> 2.8) + dry-configurable (1.0.1) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-core (1.0.0) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-initializer (3.1.1) + dry-logic (1.5.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-schema (1.13.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.0, < 2) + dry-initializer (~> 3.0) + dry-logic (>= 1.5, < 2) + dry-types (>= 1.7, < 2) + zeitwerk (~> 2.6) + dry-types (1.7.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + dry-inflector (~> 1.0, < 2) + dry-logic (>= 1.4, < 2) + zeitwerk (~> 2.6) ed25519 (1.2.4-x86_64-linux-musl) em-websocket (0.5.3) eventmachine (>= 0.12.9) @@ -216,8 +250,8 @@ GEM thor hiredis (0.6.3-x86_64-linux-musl) http_parser.rb (0.8.0-x86_64-linux-musl) - httparty (0.18.1) - mime-types (~> 3.0) + httparty (0.21.0) + mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) i18n (1.8.11) concurrent-ruby (~> 1.0) @@ -261,7 +295,7 @@ GEM jekyll (~> 4) jekyll-ignore-layouts (0.1.2) jekyll (~> 4) - jekyll-images (0.3.0) + jekyll-images (0.3.2) jekyll (~> 4) ruby-filemagic (~> 0.7) ruby-vips (~> 2) @@ -292,6 +326,7 @@ GEM jekyll-write-and-commit-changes (0.2.1) jekyll (~> 4) rugged (~> 1) + jwt (2.6.0) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -352,6 +387,9 @@ GEM nokogiri (1.12.5-x86_64-linux-musl) mini_portile2 (~> 2.6.1) racc (~> 1.4) + njalla-api-client (0.1.0) + dry-schema + httparty (~> 0.18) orm_adapter (0.5.0) parallel (1.21.0) parser (3.0.2.0) @@ -578,6 +616,7 @@ DEPENDENCIES devise devise-i18n devise_invitable + distributed-press-api-client (~> 0.2.2) dotenv-rails down ed25519 @@ -612,6 +651,7 @@ DEPENDENCIES mini_magick mobility net-ssh + njalla-api-client nokogiri pg pg_search diff --git a/Procfile b/Procfile index 8f6c7741..25a1639d 100644 --- a/Procfile +++ b/Procfile @@ -5,4 +5,6 @@ blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour" blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day" blazer: bundle exec rake blazer:send_failing_checks prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_" +distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew +cleanup: bundle exec rake cleanup:everything stats: bundle exec rake stats:process_all diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index ba8de715..bba48558 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -25,6 +25,10 @@ $spacers: ( 2-plus: 0.75rem ); +$sizes: ( + "70ch": 70ch, +); + @import "bootstrap"; @import "editor"; @@ -410,6 +414,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1); @each $prop, $abbrev in (width: w, height: h) { @each $size, $length in $sizes { .#{$abbrev}-#{$grid-breakpoint}-#{$size} { #{$prop}: $length !important; } + .min-#{$abbrev}-#{$grid-breakpoint}-#{$size} { min-#{$prop}: $length !important; } + .max-#{$abbrev}-#{$grid-breakpoint}-#{$size} { max-#{$prop}: $length !important; } } } diff --git a/app/controllers/api/v1/notices_controller.rb b/app/controllers/api/v1/notices_controller.rb index cd44130c..436c78b5 100644 --- a/app/controllers/api/v1/notices_controller.rb +++ b/app/controllers/api/v1/notices_controller.rb @@ -15,7 +15,7 @@ module Api params: airbrake_params.to_h end - render status: 201, json: { id: 1, url: root_url } + render status: 201, json: { id: 1, url: '' } end private diff --git a/app/controllers/api/v1/sites_controller.rb b/app/controllers/api/v1/sites_controller.rb index 6abff704..10a92907 100644 --- a/app/controllers/api/v1/sites_controller.rb +++ b/app/controllers/api/v1/sites_controller.rb @@ -9,7 +9,7 @@ module Api # Lista de nombres de dominios a emitir certificados def index - render json: sites_names + alternative_names + api_names + render json: sites_names + alternative_names + api_names + www_names end # Sitios con hidden service de Tor @@ -28,7 +28,7 @@ module Api site = Site.find_by(name: params[:name]) if site - usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor' + usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor' service = SiteService.new site: site, usuarie: usuarie, params: params service.add_onion @@ -39,14 +39,22 @@ module Api private + def canonicalize(name) + name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}" + end + # Nombres de los sitios def sites_names - Site.all.order(:name).pluck(:name) + Site.all.order(:name).pluck(:name).map do |name| + canonicalize name + end end # Dominios alternativos def alternative_names - DeployAlternativeDomain.all.map(&:hostname) + (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name| + canonicalize name + end end # Obtener todos los sitios con API habilitada, es decir formulario @@ -56,7 +64,16 @@ module Api def api_names Site.where(contact: true) .or(Site.where(colaboracion_anonima: true)) - .select("'api.' || name as name").map(&:name) + .select("'api.' || name as name").map(&:name).map do |name| + canonicalize name + end + end + + # Todos los dominios con WWW habilitado + def www_names + Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name| + canonicalize name + end end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d8498218..e80c279d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -46,17 +46,19 @@ class ApplicationController < ActionController::Base # defecto. # # Esto se refiere al idioma de la interfaz, no de los artículos. - def current_locale(include_params: true, site: nil) - return params[:locale] if include_params && params[:locale].present? + # + # @return [String,Symbol] + def current_locale + session[:locale] = params[:change_locale_to] if params[:change_locale_to].present? - current_usuarie&.lang || I18n.locale + session[:locale] || current_usuarie&.lang || I18n.locale end # El idioma es el preferido por le usuarie, pero no necesariamente se # corresponde con el idioma de los artículos, porque puede querer # traducirlos. def set_locale(&action) - I18n.with_locale(current_locale(include_params: false), &action) + I18n.with_locale(current_locale, &action) end # Muestra una página 404 @@ -88,4 +90,12 @@ class ApplicationController < ActionController::Base def prepare_exception_notifier request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie } end + + # Olvidar el idioma elegido antes de iniciar la sesión y reenviar a + # los sitios en el idioma de le usuarie. + def after_sign_in_path_for(resource) + session[:locale] = nil + + sites_path + end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index c5dc0f54..3c529c24 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -12,7 +12,7 @@ class PostsController < ApplicationController # Las URLs siempre llevan el idioma actual o el de le usuarie def default_url_options - { locale: current_locale } + { locale: locale } end def index diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index b4826226..f0eff0dc 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -68,9 +68,7 @@ class SitesController < ApplicationController def enqueue authorize site - # XXX: Convertir en una máquina de estados? - site.enqueue! - DeployJob.perform_async site.id + SiteService.new(site: site).deploy redirect_to site_posts_path(site, locale: site.default_locale) end diff --git a/app/javascript/controllers/non_geo_controller.js b/app/javascript/controllers/non_geo_controller.js new file mode 100644 index 00000000..1c618fcb --- /dev/null +++ b/app/javascript/controllers/non_geo_controller.js @@ -0,0 +1,81 @@ +import { Controller } from 'stimulus' + +require("leaflet/dist/leaflet.css") +import L from 'leaflet' +delete L.Icon.Default.prototype._getIconUrl + +L.Icon.Default.mergeOptions({ + iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'), + iconUrl: require('leaflet/dist/images/marker-icon.png'), + shadowUrl: require('leaflet/dist/images/marker-shadow.png'), +}) + +export default class extends Controller { + static targets = [ 'lat', 'lng', 'map', 'overlay' ] + + async connect () { + this.marker() + + this.latTarget.addEventListener('change', event => this.marker()) + this.lngTarget.addEventListener('change', event => this.marker()) + window.addEventListener('resize', event => this.map.invalidateSize()) + + this.map.on('click', event => { + this.latTarget.value = event.latlng.lat + this.lngTarget.value = event.latlng.lng + + this.latTarget.dispatchEvent(new Event('change')) + }) + } + + marker () { + if (this._marker) this.map.removeLayer(this._marker) + + this._marker = L.marker(this.coords).addTo(this.map) + + return this._marker + } + + get lat () { + const lat = parseFloat(this.latTarget.value) + + return isNaN(lat) ? 0 : lat + } + + get lng () { + const lng = parseFloat(this.lngTarget.value) + + return isNaN(lng) ? 0 : lng + } + + get coords () { + return [this.lat, this.lng] + } + + get bounds () { + return [ + [0, 0], + [ + this.svgOverlay.viewBox.baseVal.height, + this.svgOverlay.viewBox.baseVal.width, + ] + ]; + } + + get map () { + if (!this._map) { + this._map = L.map(this.mapTarget, { + minZoom: 0, + maxZoom: 5 + }).setView(this.coords, 0); + + this._layer = L.tileLayer(`${this.element.dataset.site}public/map/{z}/{y}/{x}.png`, { + minNativeZoom: 0, + maxNativeZoom: 5, + noWrap: true + }).addTo(this._map); + } + + return this._map + } +} diff --git a/app/jobs/deploy_job.rb b/app/jobs/deploy_job.rb index 70997ce1..e018533c 100644 --- a/app/jobs/deploy_job.rb +++ b/app/jobs/deploy_job.rb @@ -3,9 +3,14 @@ # Realiza el deploy de un sitio class DeployJob < ApplicationJob class DeployException < StandardError; end + class DeployTimedOutException < DeployException; end + + discard_on ActiveRecord::RecordNotFound # rubocop:disable Metrics/MethodLength - def perform(site, notify = true, time = Time.now) + def perform(site, notify: true, time: Time.now, output: false) + @output = output + ActiveRecord::Base.connection_pool.with_connection do @site = Site.find(site) @@ -16,32 +21,39 @@ class DeployJob < ApplicationJob # hora original para poder ir haciendo timeouts. if @site.building? if 10.minutes.ago >= time - @site.update status: 'waiting' - raise DeployException, + notify = false + raise DeployTimedOutException, "#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" end - DeployJob.perform_in(60, site, notify, time) + DeployJob.perform_in(60, site, notify: notify, time: time, output: output) return end @site.update status: 'building' # Asegurarse que DeployLocal sea el primero! - @deployed = { deploy_local: deploy_locally } + @deployed = { + deploy_local: { + status: deploy_locally, + seconds: deploy_local.build_stats.last.seconds, + size: deploy_local.size, + urls: [deploy_local.url] + } + } # No es opcional - unless @deployed[:deploy_local] - @site.update status: 'waiting' - notify_usuaries if notify - + unless @deployed[:deploy_local][:status] # Hacer fallar la tarea - raise DeployException, deploy_local.build_stats.last.log + raise DeployException, "#{@site.name}: Falló la compilación" end deploy_others - - # Volver a la espera - @site.update status: 'waiting' + rescue DeployTimedOutException => e + notify_exception e + rescue DeployException => e + notify_exception e, deploy_local + ensure + @site&.update status: 'waiting' notify_usuaries if notify end @@ -50,17 +62,44 @@ class DeployJob < ApplicationJob private + # @param :exception [StandardError] + # @param :deploy [Deploy] + def notify_exception(exception, deploy = nil) + data = { + site: @site.id, + deploy: deploy&.type, + log: deploy&.build_stats&.last&.log + } + + ExceptionNotifier.notify_exception(exception, data: data) + end + def deploy_local @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal') end def deploy_locally - deploy_local.deploy + deploy_local.deploy(output: @output) end def deploy_others @site.deploys.where.not(type: 'DeployLocal').find_each do |d| - @deployed[d.type.underscore.to_sym] = d.deploy + begin + status = d.deploy(output: @output) + seconds = d.build_stats.last.try(:seconds) + rescue StandardError => e + status = false + seconds = 0 + + notify_exception e, d + end + + @deployed[d.type.underscore.to_sym] = { + status: status, + seconds: seconds || 0, + size: d.size, + urls: d.respond_to?(:urls) ? d.urls : [d.url].compact + } end end diff --git a/app/jobs/gitlab_notifier_job.rb b/app/jobs/gitlab_notifier_job.rb index 7218f68a..701c6789 100644 --- a/app/jobs/gitlab_notifier_job.rb +++ b/app/jobs/gitlab_notifier_job.rb @@ -3,6 +3,8 @@ # Notifica excepciones a una instancia de Gitlab, como incidencias # nuevas o como comentarios a las incidencias pre-existentes. class GitlabNotifierJob < ApplicationJob + class GitlabNotifierError < StandardError; end + include ExceptionNotifier::BacktraceCleaner # Variables que vamos a acceder luego @@ -18,22 +20,28 @@ class GitlabNotifierJob < ApplicationJob @issue_data = { count: 1 } # Necesitamos saber si el issue ya existía @cached = false + @issue = {} # Traemos los datos desde la caché si existen, sino generamos un # issue nuevo e inicializamos la caché @issue_data = Rails.cache.fetch(cache_key) do - issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident' + @issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident' @cached = true { count: 1, - issue: issue['iid'], + issue: @issue['iid'], user_agents: [user_agent].compact, params: [request&.filtered_parameters].compact, urls: [url].compact } end + unless @issue['iid'] + Rails.cache.delete(cache_key) + raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ') + end + # No seguimos actualizando si acabamos de generar el issue return if cached @@ -104,6 +112,7 @@ class GitlabNotifierJob < ApplicationJob # @return [String] def description @description ||= ''.dup.tap do |d| + d << log_section d << request_section d << javascript_section d << javascript_footer @@ -151,6 +160,19 @@ class GitlabNotifierJob < ApplicationJob @client ||= GitlabApiClient.new end + # @return [String] + def log_section + return '' unless options[:log] + + <<~LOG + # Log + + ``` + #{options[:log]} + ``` + LOG + end + # Muestra información de la petición # # @return [String] diff --git a/app/jobs/renew_distributed_press_tokens_job.rb b/app/jobs/renew_distributed_press_tokens_job.rb new file mode 100644 index 00000000..5664d9fa --- /dev/null +++ b/app/jobs/renew_distributed_press_tokens_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Renueva los tokens de Distributed Press antes que se venzan, +# activando los callbacks que hacen que se refresque el token. +class RenewDistributedPressTokensJob < ApplicationJob + # Renueva todos los tokens a punto de vencer o informa el error sin + # detener la tarea si algo pasa. + def perform + DistributedPressPublisher.with_about_to_expire_tokens.find_each do |publisher| + publisher.touch + rescue DistributedPress::V1::Error => e + data = { instance: publisher.instance, expires_at: publisher.client.token.expires_at } + + ExceptionNotifier.notify_exception(e, data: data) + end + end +end diff --git a/app/lib/active_storage/service/jekyll_service.rb b/app/lib/active_storage/service/jekyll_service.rb index 92b26e0e..88ffa83c 100644 --- a/app/lib/active_storage/service/jekyll_service.rb +++ b/app/lib/active_storage/service/jekyll_service.rb @@ -20,6 +20,18 @@ module ActiveStorage end end + # Solo copiamos el archivo si no existe + # + # @param :key [String] + # @param :io [IO] + # @param :checksum [String] + def upload(key, io, checksum: nil, **) + instrument :upload, key: key, checksum: checksum do + IO.copy_stream(io, make_path_for(key)) unless exist?(key) + ensure_integrity_of(key, checksum) if checksum + 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. @@ -67,7 +79,9 @@ module ActiveStorage # @param :key [String] # @return [String] def filename_for(key) - ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first + ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first.tap do |filename| + raise ArgumentError, "Filename for key #{key} is blank" if filename.blank? + end end # Crea una ruta para la llave con un nombre conocido. diff --git a/app/mailers/deploy_mailer.rb b/app/mailers/deploy_mailer.rb index 1d0c7308..b7b464cb 100644 --- a/app/mailers/deploy_mailer.rb +++ b/app/mailers/deploy_mailer.rb @@ -8,21 +8,66 @@ # TODO: Agregar firma GPG y header Autocrypt # TODO: Cifrar con GPG si le usuarie nos dio su llave class DeployMailer < ApplicationMailer + include ActionView::Helpers::NumberHelper + include ActionView::Helpers::DateHelper + # rubocop:disable Metrics/AbcSize - def deployed(which_ones) - @usuarie = Usuarie.find(params[:usuarie]) - @site = @usuarie.sites.find(params[:site]) - @deploys = which_ones - @deploy_local = @site.deploys.find_by(type: 'DeployLocal') + def deployed(deploys = {}) + usuarie = Usuarie.find(params[:usuarie]) + site = usuarie.sites.find(params[:site]) + hostname = site.hostname + deploys ||= {} # Informamos a cada quien en su idioma y damos una dirección de # respuesta porque a veces les usuaries nos escriben - I18n.with_locale(@usuarie.lang) do - mail(to: @usuarie.email, - reply_to: "sutty@#{Site.domain}", - subject: I18n.t('deploy_mailer.deployed.subject', - site: @site.name)) + I18n.with_locale(usuarie.lang) do + subject = t('.subject', site: site.name) + + @hi = t('.hi') + @explanation = t('.explanation', fqdn: hostname) + @help = t('.help') + + @headers = %w[type status url seconds size].map do |header| + t(".th.#{header}") + end + + @table = deploys.each_pair.map do |deploy, value| + { + title: t(".#{deploy}.title"), + status: t(".#{deploy}.#{value[:status] ? 'success' : 'error'}"), + urls: value[:urls], + seconds: { + human: distance_of_time_in_words(value[:seconds].seconds), + machine: "PT#{value[:seconds]}S" + }, + size: number_to_human_size(value[:size], precision: 2) + } + end + + @terminal_table = Terminal::Table.new do |t| + t << @headers + t.add_separator + @table.each do |row| + row[:urls].each do |url| + t << (row.map do |k, v| + case k + when :seconds then v[:human] + when :urls then url + else v + end + end) + end + end + end + + mail(to: usuarie.email, reply_to: "sutty@#{Site.domain}", subject: subject) end end # rubocop:enable Metrics/AbcSize + + private + + def t(key, **args) + I18n.t("deploy_mailer.deployed#{key}", **args) + end end diff --git a/app/models/deploy.rb b/app/models/deploy.rb index 3f034ad5..1c2683fc 100644 --- a/app/models/deploy.rb +++ b/app/models/deploy.rb @@ -11,7 +11,11 @@ class Deploy < ApplicationRecord belongs_to :site has_many :build_stats, dependent: :destroy - def deploy + def deploy(**) + raise NotImplementedError + end + + def url raise NotImplementedError end @@ -23,6 +27,9 @@ class Deploy < ApplicationRecord raise NotImplementedError end + # Realizar tareas de limpieza. + def cleanup!; end + def time_start @start = Time.now end @@ -39,6 +46,7 @@ class Deploy < ApplicationRecord site.path end + # XXX: Ver DeployLocal#bundle def gems_dir @gems_dir ||= Rails.root.join('_storage', 'gems', site.name) end @@ -48,20 +56,26 @@ class Deploy < ApplicationRecord # # @param [String] # @return [Boolean] - def run(cmd) + def run(cmd, output: false) r = nil lines = [] time_start Dir.chdir(site.path) do Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| - r = t.value - # XXX: Tenemos que leer línea por línea porque en salidas largas - # se cuelga la IO # TODO: Enviar a un websocket para ver el proceso en vivo? - o.each do |line| - lines << line + Thread.new do + o.each do |line| + lines << line + + puts line if output + end + rescue IOError => e + lines << e.message + puts e.message if output end + + r = t.value end end time_stop @@ -75,6 +89,13 @@ class Deploy < ApplicationRecord r&.success? end + # Variables de entorno + # + # @return [Hash] + def local_env + @local_env ||= {} + end + private # @param [String] @@ -82,4 +103,12 @@ class Deploy < ApplicationRecord def readable_cmd(cmd) cmd.split(' -', 2).first.tr(' ', '_') end + + def deploy_local + @deploy_local ||= site.deploys.find_by(type: 'DeployLocal') + end + + def non_local_deploys + @non_local_deploys ||= site.deploys.where.not(type: 'DeployLocal') + end end diff --git a/app/models/deploy_alternative_domain.rb b/app/models/deploy_alternative_domain.rb index e4960e65..5ad381d5 100644 --- a/app/models/deploy_alternative_domain.rb +++ b/app/models/deploy_alternative_domain.rb @@ -5,7 +5,7 @@ class DeployAlternativeDomain < Deploy store :values, accessors: %i[hostname], coder: JSON # Generar un link simbólico del sitio principal al alternativo - def deploy + def deploy(**) File.symlink?(destination) || File.symlink(site.hostname, destination).zero? end @@ -18,6 +18,10 @@ class DeployAlternativeDomain < Deploy end def destination - File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, '')) + @destination ||= File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, '')) + end + + def url + "https://#{File.basename destination}" end end diff --git a/app/models/deploy_distributed_press.rb b/app/models/deploy_distributed_press.rb new file mode 100644 index 00000000..6940a83e --- /dev/null +++ b/app/models/deploy_distributed_press.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'distributed_press/v1/client/site' +require 'njalla/v1' + +# Soportar Distributed Press APIv1 +# +# Usa tokens de publicación efímeros para todas las acciones. +# +# Al ser creado, genera el sitio en la instancia de Distributed Press +# configurada y almacena el ID. +# +# Al ser publicado, envía los archivos en un tarball y actualiza la +# información. +class DeployDistributedPress < Deploy + store :values, accessors: %i[hostname remote_site_id remote_info], coder: JSON + + before_create :create_remote_site!, :create_njalla_records! + + # Actualiza la información y luego envía los cambios + # + # @param :output [Bool] + # @return [Bool] + def deploy + status = false + log = [] + + time_start + + create_remote_site! if remote_site_id.blank? + create_njalla_records! if remote_info['njalla'].blank? + save + + if remote_site_id.blank? || remote_info['njalla'].blank? + raise DeployJob::DeployException, '' + end + + site_client.tap do |c| + stdout = Thread.new(publisher.logger_out) do |io| + until io.eof? + line = io.gets + + puts line if output + log << line + end + end + + update remote_info: c.show(publishing_site).to_h + + status = c.publish(publishing_site, deploy_local.destination) + + publisher.logger.close + stdout.join + end + + time_stop + + create_stat! status, log.join + + status + end + + def limit; end + + def size + deploy_local.size + end + + def destination; end + + # Devuelve las URLs de todos los protocolos + def urls + remote_info[:links].values.map do |protocol| + [ protocol[:link], protocol[:gateway] ] + end.flatten.compact.select do |link| + link.include? '://' + end + end + + private + + # El cliente de la API + # + # TODO: cuando soportemos más, tiene que haber una relación entre + # DeployDistributedPress y DistributedPressPublisher. + # + # @return [DistributedPressPublisher] + def publisher + @publisher ||= DistributedPressPublisher.first + end + + # El cliente para actualizar el sitio + # + # @return [DistributedPress::V1::Client::Site] + def site_client + DistributedPress::V1::Client::Site.new(publisher.client) + end + + # Genera el esquema de datos para poder publicar el sitio + # + # @return [DistributedPress::V1::Schemas::PublishingSite] + def publishing_site + DistributedPress::V1::Schemas::PublishingSite.new.call(id: remote_site_id) + end + + # Genera el esquema de datos para crear el sitio + # + # @return [DistributedPressPublisher::V1::Schemas::NewSite] + def create_site + DistributedPress::V1::Schemas::NewSite.new.call(domain: hostname, protocols: { http: true, ipfs: true, hyper: true }) + end + + # Crea el sitio en la instancia con el hostname especificado + # + # @return [nil] + def create_remote_site! + created_site = site_client.create(create_site) + + self.remote_site_id = created_site[:id] + self.remote_info = created_site.to_h + rescue DistributedPress::V1::Error => e + ExceptionNotifier.notify_exception(e, data: { site: site.name }) + ensure + nil + end + + # Crea los registros en Njalla + # + # @return [nil] + def create_njalla_records! + # XXX: Esto depende de nuestro DNS actual, cuando lo migremos hay + # que eliminarlo. + unless site.name.end_with? '.' + self.remote_info['njalla'] = {} + self.remote_info['njalla']['a'] = njalla.add_record(name: site.name, type: 'CNAME', content: "#{Site.domain}.").to_h + self.remote_info['njalla']['ns'] = njalla.add_record(name: "_dnslink.#{site.name}", type: 'NS', content: "#{publisher.hostname}.").to_h + end + rescue HTTParty::Error => e + ExceptionNotifier.notify_exception(e, data: { site: site.name }) + self.remote_info['njalla'] = nil + ensure + nil + end + + # Registra lo que sucedió + # + # @param status [Bool] + # @param log [String] + # @return [nil] + def create_stat!(status, log) + build_stats.create action: publisher.to_s,log: log, seconds: time_spent_in_seconds, bytes: size, status: status + nil + end + + # Actualizar registros en Njalla + # + # @return [Njalla::V1::Domain] + def njalla + @njalla ||= + begin + client = Njalla::V1::Client.new(token: ENV['NJALLA_TOKEN']) + + Njalla::V1::Domain.new(domain: Site.domain, client: client) + end + end +end diff --git a/app/models/deploy_hidden_service.rb b/app/models/deploy_hidden_service.rb index d4d2b822..dc9549f5 100644 --- a/app/models/deploy_hidden_service.rb +++ b/app/models/deploy_hidden_service.rb @@ -2,7 +2,7 @@ # Genera una versión onion class DeployHiddenService < DeployWww - def deploy + def deploy(**) return true if fqdn.blank? super @@ -13,6 +13,6 @@ class DeployHiddenService < DeployWww end def url - 'http://' + fqdn + "http://#{fqdn}" end end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index 66a8345a..9c0ba492 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -12,12 +12,13 @@ class DeployLocal < Deploy # # Pasamos variables de entorno mínimas para no filtrar secretos de # Sutty - def deploy + def deploy(output: false) return false unless mkdir - return false unless yarn - return false unless bundle + return false unless yarn(output: output) + return false unless pnpm(output: output) + return false unless bundle(output: output) - jekyll_build + jekyll_build(output: output) end # Sólo permitimos un deploy local @@ -25,6 +26,10 @@ class DeployLocal < Deploy 1 end + def url + site.url + end + # Obtener el tamaño de todos los archivos y directorios (los # directorios son archivos :) def size @@ -45,6 +50,17 @@ class DeployLocal < Deploy File.join(Rails.root, '_deploy', site.hostname) end + # Libera espacio eliminando archivos temporales + # + # @return [nil] + def cleanup! + FileUtils.rm_rf(gems_dir) + FileUtils.rm_rf(yarn_cache_dir) + FileUtils.rm_rf(File.join(site.path, 'node_modules')) + FileUtils.rm_rf(File.join(site.path, '.sass-cache')) + FileUtils.rm_rf(File.join(site.path, '.jekyll-cache')) + end + private def mkdir @@ -52,27 +68,34 @@ class DeployLocal < Deploy end # Un entorno que solo tiene lo que necesitamos + # + # @return [Hash] def env # XXX: This doesn't support Windows paths :B - paths = [File.dirname(`which bundle`), '/usr/bin', '/bin'] + paths = [File.dirname(`which bundle`), '/usr/local/bin', '/usr/bin', '/bin'] - { - 'HOME' => home_dir, - 'PATH' => paths.join(':'), - 'SPREE_API_KEY' => site.tienda_api_key, - 'SPREE_URL' => site.tienda_url, - 'AIRBRAKE_PROJECT_ID' => site.id.to_s, - 'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key, - 'JEKYLL_ENV' => Rails.env, - 'LANG' => ENV['LANG'], - 'YARN_CACHE_FOLDER' => yarn_cache_dir - } + # Las variables de entorno extra no pueden superponerse al local. + extra_env.merge({ + 'HOME' => home_dir, + 'PATH' => paths.join(':'), + 'SPREE_API_KEY' => site.tienda_api_key, + 'SPREE_URL' => site.tienda_url, + 'AIRBRAKE_PROJECT_ID' => site.id.to_s, + 'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key, + 'JEKYLL_ENV' => Rails.env, + 'LANG' => ENV['LANG'], + 'YARN_CACHE_FOLDER' => yarn_cache_dir + }) end def yarn_cache_dir Rails.root.join('_yarn_cache').to_s end + def pnpm_cache_dir + Rails.root.join('_pnpm_cache').to_s + end + def yarn_lock File.join(site.path, 'yarn.lock') end @@ -81,23 +104,38 @@ class DeployLocal < Deploy File.exist? yarn_lock end - def gem - run %(gem install bundler --no-document) + def pnpm_lock + File.join(site.path, 'pnpm-lock.yaml') + end + + def pnpm_lock? + File.exist? pnpm_lock + end + + def gem(output: false) + run %(gem install bundler --no-document), output: output end # Corre yarn dentro del repositorio - def yarn + def yarn(output: false) return true unless yarn_lock? - run 'yarn install --production' + run 'yarn install --production', output: output end - def bundle - run %(bundle install --no-cache --path="#{gems_dir}") + def pnpm(output: false) + return true unless pnpm_lock? + + run %(pnpm config set store-dir "#{pnpm_cache_dir}"), output: output + run 'pnpm install --production', output: output end - def jekyll_build - run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") + def bundle(output: false) + run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development), output: output + end + + def jekyll_build(output: false) + run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}"), output: output end # no debería haber espacios ni caracteres especiales, pero por si @@ -110,4 +148,17 @@ class DeployLocal < Deploy def remove_destination! FileUtils.rm_rf destination end + + # Consigue todas las variables de entorno configuradas por otros + # deploys. + # + # @return [Hash] + def extra_env + @extra_env ||= + non_local_deploys.reduce({}) do |extra_env, deploy| + extra_env.tap do |e| + e.merge! deploy.local_env + end + end + end end diff --git a/app/models/deploy_localized_domain.rb b/app/models/deploy_localized_domain.rb new file mode 100644 index 00000000..59e17dcd --- /dev/null +++ b/app/models/deploy_localized_domain.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Soportar dominios localizados +class DeployLocalizedDomain < DeployAlternativeDomain + store :values, accessors: %i[hostname locale], coder: JSON + + # Generar un link simbólico del sitio principal al alternativo + def deploy(**) + File.symlink?(destination) || + File.symlink(File.join(site.hostname, locale), destination).zero? + end +end diff --git a/app/models/deploy_private.rb b/app/models/deploy_private.rb index 3a6595f9..d3bfb50d 100644 --- a/app/models/deploy_private.rb +++ b/app/models/deploy_private.rb @@ -7,8 +7,8 @@ # jekyll-private-data class DeployPrivate < DeployLocal # No es necesario volver a instalar dependencias - def deploy - jekyll_build + def deploy(output: false) + jekyll_build(output: output) end # Hacer el deploy a un directorio privado @@ -16,6 +16,10 @@ class DeployPrivate < DeployLocal File.join(Rails.root, '_private', site.name) end + def url + "#{ENV['PANEL_URL']}/sites/private/#{site.name}" + end + # No usar recursos en compresión y habilitar los datos privados def env @env ||= super.merge({ diff --git a/app/models/deploy_reindex.rb b/app/models/deploy_reindex.rb new file mode 100644 index 00000000..f3eb3d23 --- /dev/null +++ b/app/models/deploy_reindex.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Reindexa los artículos al terminar la compilación +class DeployReindex < Deploy + def deploy(**) + time_start + + site.reset + + Site.transaction do + site.indexed_posts.destroy_all + site.index_posts! + end + + time_stop + + build_stats.create action: 'reindex', + log: 'Reindex', + seconds: time_spent_in_seconds, + bytes: size, + status: true + site.touch + end + + def size + 0 + end + + def limit + 1 + end + + def hostname; end + + def url; end + + def destination; end +end diff --git a/app/models/deploy_rsync.rb b/app/models/deploy_rsync.rb index 996f8cdd..a658de6b 100644 --- a/app/models/deploy_rsync.rb +++ b/app/models/deploy_rsync.rb @@ -5,8 +5,8 @@ class DeployRsync < Deploy store :values, accessors: %i[destination host_keys], coder: JSON - def deploy - ssh? && rsync + def deploy(output: false) + ssh? && rsync(output: output) end # El espacio remoto es el mismo que el local @@ -83,8 +83,8 @@ class DeployRsync < Deploy # Sincroniza hacia el directorio remoto # # @return [Boolean] - def rsync - run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/) + def rsync(output: output) + run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output end # El origen es el destino de la compilación diff --git a/app/models/deploy_www.rb b/app/models/deploy_www.rb index 5602b0fc..dff769a6 100644 --- a/app/models/deploy_www.rb +++ b/app/models/deploy_www.rb @@ -6,7 +6,7 @@ class DeployWww < Deploy before_destroy :remove_destination! - def deploy + def deploy(**) File.symlink?(destination) || File.symlink(site.hostname, destination).zero? end @@ -27,6 +27,10 @@ class DeployWww < Deploy "www.#{site.hostname}" end + def url + "https://www.#{site.hostname}/" + end + private def remove_destination! diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb index ec8973d1..f1c94083 100644 --- a/app/models/deploy_zip.rb +++ b/app/models/deploy_zip.rb @@ -12,7 +12,7 @@ class DeployZip < Deploy # y generar un zip accesible públicamente. # # rubocop:disable Metrics/MethodLength - def deploy + def deploy(**) FileUtils.rm_f path time_start @@ -49,6 +49,10 @@ class DeployZip < Deploy "#{site.hostname}.zip" end + def url + "#{site.url}#{file}" + end + def path File.join(destination, file) end diff --git a/app/models/distributed_press_publisher.rb b/app/models/distributed_press_publisher.rb new file mode 100644 index 00000000..6139db93 --- /dev/null +++ b/app/models/distributed_press_publisher.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'distributed_press/v1' + +# Almacena el token de autenticación y la URL, por ahora solo vamos +# a tener uno, pero queda abierta la posibilidad de agregar más. +class DistributedPressPublisher < ApplicationRecord + # Cifrar la información del token en la base de datos + has_encrypted :token + + # La salida del log + # + # @return [IO] + attr_reader :logger_out + + # La instancia es única + validates_uniqueness_of :instance + + # El token es necesario + validates_presence_of :token + + # Mantener la fecha de vencimiento actualizada + before_save :update_expires_at_from_token!, :update_token_from_client! + + # Devuelve todos los tokens que vencen en una hora + scope :with_about_to_expire_tokens, lambda { + where('expires_at > ? and expires_at < ?', Time.now, Time.now + 1.hour) + } + + # Instancia un cliente de Distributed Press a partir del token. Al + # cargar un token a punto de vencer se renueva automáticamente. + # + # @return [DistributedPress::V1::Client] + def client + @client ||= DistributedPress::V1::Client.new(url: instance, token: token, logger: logger) + end + + # @return [String] + def to_s + "Distributed Press <#{instance}>" + end + + # Devuelve el hostname de la instancia + # + # @return [String] + def hostname + @hostname ||= URI.parse(instance).hostname + end + + # @return [Logger] + def logger + @logger ||= + begin + @logger_out, @logger_in = IO.pipe + ::Logger.new @logger_in, formatter: formatter + end + end + + private + + def formatter + @formatter ||= lambda do |_, _, _, msg| + "#{msg}\n" + end + end + + # Actualiza o desactiva la fecha de vencimiento a partir de la + # información del token. + # + # @return [nil] + def update_expires_at_from_token! + self.expires_at = client.token.forever? ? nil : client.token.expires_at + nil + end + + # Actualiza el token a partir del cliente, que ya actualiza el token + # automáticamente. + # + # @return [nil] + def update_token_from_client! + self.token = client.token.to_s + nil + end +end diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb index 9d3a1040..9516b907 100644 --- a/app/models/metadata_content.rb +++ b/app/models/metadata_content.rb @@ -72,6 +72,17 @@ class MetadataContent < MetadataTemplate resource['controls'] = true end + # Elimina los estilos salvo los que asigne el editor + html.css('*').each do |element| + next if elements_with_style.include? element.name.downcase + + element.remove_attribute('style') + end + html.to_s.html_safe end + + def elements_with_style + @elements_with_style ||= %w[div mark].freeze + end end diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb index 71d3f049..3ac89c9b 100644 --- a/app/models/metadata_file.rb +++ b/app/models/metadata_file.rb @@ -23,7 +23,6 @@ class MetadataFile < MetadataTemplate 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! @@ -41,14 +40,14 @@ class MetadataFile < MetadataTemplate end # Asociar la imagen subida al sitio y obtener la ruta - # - # XXX: Si evitamos guardar cambios con changed? no tenemos forma de - # saber que un archivo subido manualmente se convirtió en - # un Attachment y cada vez que lo editemos vamos a subir una imagen - # repetida. + # @return [Boolean] def save - value['description'] = sanitize value['description'] - value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil + if value['path'].blank? + self[:value] = default_value + else + value['description'] = sanitize value['description'] + value['path'] = relative_destination_path_with_filename.to_s if static_file + end true end @@ -62,9 +61,6 @@ class MetadataFile < MetadataTemplate # * El archivo es una ruta que apunta a un archivo asociado al sitio # * El archivo es una ruta a un archivo dentro del repositorio # - # XXX: La última opción provoca archivos duplicados, pero es lo mejor - # que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213 - # # @todo encontrar una forma de obtener el attachment sin tener que # recurrir al último subido. # @@ -75,13 +71,7 @@ class MetadataFile < MetadataTemplate when ActionDispatch::Http::UploadedFile site.static_files.last if site.static_files.attach(value['path']) when String - 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 + site.static_files.find_by(blob_id: blob_id) || migrate_static_file! end end @@ -98,7 +88,7 @@ class MetadataFile < MetadataTemplate # # @return [String] def key_from_path - pathname.dirname.basename.to_s + @key_from_path ||= pathname.dirname.basename.to_s end def path? @@ -127,13 +117,22 @@ class MetadataFile < MetadataTemplate # 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) + ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] }) - value['path'] + Pathname.new(File.join(site.path, value['path'])) end + # Obtener la ruta relativa al sitio. + # + # Si algo falla, devolver la ruta original para no romper el archivo. + # + # @return [String, nil] def relative_destination_path_with_filename destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath) + rescue ArgumentError => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] }) + + value['path'] end def static_file_path @@ -145,8 +144,31 @@ class MetadataFile < MetadataTemplate end end - # No hay archivo pero se lo describió - def no_file_for_description? - !path? && description? + # Obtiene el id del blob asociado + # + # @return [Integer,nil] + def blob_id + @blob_id ||= ActiveStorage::Blob.where(key: key_from_path, service_name: site.name).pluck(:id).first + end + + # Genera el blob para un archivo que ya se encuentra en el + # repositorio y lo agrega a la base de datos. + # + # @return [ActiveStorage::Attachment] + def migrate_static_file! + raise ArgumentError, 'El archivo no existe' unless path? && pathname.exist? + + Site.transaction do + blob = + ActiveStorage::Blob.create_after_unfurling!(key: key_from_path, + io: pathname.open, + filename: pathname.basename, + service_name: site.name) + + ActiveStorage::Attachment.create!(name: 'static_files', record: site, blob: blob) + end + rescue ArgumentError => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] }) + nil end end diff --git a/app/models/metadata_non_geo.rb b/app/models/metadata_non_geo.rb new file mode 100644 index 00000000..6aec8461 --- /dev/null +++ b/app/models/metadata_non_geo.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +class MetadataNonGeo < MetadataGeo; end diff --git a/app/models/metadata_password.rb b/app/models/metadata_password.rb new file mode 100644 index 00000000..1e0e2698 --- /dev/null +++ b/app/models/metadata_password.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Almacena una contraseña +class MetadataPassword < MetadataString + # Las contraseñas no son indexables + # + # @return [boolean] + def indexable? + false + end + + private + + alias_method :original_sanitize, :sanitize + + # Sanitizar la string y generar un hash Bcrypt + # + # @param :string [String] + # @return [String] + def sanitize(string) + string = original_sanitize string + + ::BCrypt::Password.create(string).to_s + end +end diff --git a/app/models/metadata_permalink.rb b/app/models/metadata_permalink.rb index 30ad32cc..895b7439 100644 --- a/app/models/metadata_permalink.rb +++ b/app/models/metadata_permalink.rb @@ -2,12 +2,6 @@ # Este metadato permite generar rutas manuales. class MetadataPermalink < MetadataString - # El valor por defecto una vez creado es la URL que le asigne Jekyll, - # de forma que nunca cambia aunque se cambie el título. - def default_value - document.url.sub(%r{\A/}, '') unless post.new? - end - # Los permalinks nunca pueden ser privados def private? false diff --git a/app/models/metadata_slug.rb b/app/models/metadata_slug.rb index 09da23f9..b0fe8cec 100644 --- a/app/models/metadata_slug.rb +++ b/app/models/metadata_slug.rb @@ -25,7 +25,7 @@ require 'jekyll/utils' class MetadataSlug < MetadataTemplate # Trae el slug desde el título si existe o una string al azar def default_value - title ? Jekyll::Utils.slugify(title) : SecureRandom.uuid + title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid end def value @@ -39,6 +39,6 @@ class MetadataSlug < MetadataTemplate return if post.title&.private? return if post.title&.value&.blank? - post.title&.value&.to_s + post.title&.value&.to_s&.unicode_normalize end end diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index c778e1b2..5de54be1 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -134,7 +134,11 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, # En caso de que algún campo necesite realizar acciones antes de ser # guardado def save - return true unless changed? + if !changed? + self[:value] = document_value if private? + + return true + end self[:value] = sanitize value self[:value] = encrypt(value) if private? diff --git a/app/models/post.rb b/app/models/post.rb index cab7665f..5cc1c5ea 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -29,7 +29,7 @@ class Post # TODO: Reemplazar cuando leamos el contenido del Document # a demanda? def find_layout(path) - IO.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym + File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym end end @@ -90,16 +90,21 @@ class Post 'page' => document.to_liquid } + # No tener errores de Liquid + site.jekyll.config['liquid']['strict_filters'] = false + site.jekyll.config['liquid']['strict_variables'] = false + # Renderizar lo estrictamente necesario y convertir a HTML para # poder reemplazar valores. html = Nokogiri::HTML document.renderer.render_document - # Las imágenes se cargan directamente desde el repositorio, porque + # Los archivos se cargan directamente desde el repositorio, porque # no son públicas hasta que se publica el artículo. - html.css('img').each do |img| - next if %r{\Ahttps?://} =~ img.attributes['src'] + html.css('img,audio,video,iframe').each do |element| + src = element.attributes['src'] - img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site, - file: img.attributes['src'].value) + next unless src&.value&.start_with? 'public/' + + src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: src.value) end # Notificar a les usuaries que están viendo una previsualización @@ -108,12 +113,16 @@ class Post # Cacofonía html.to_html.html_safe + rescue Liquid::Error => e + ExceptionNotifier.notify(e, data: { site: site.name, post: post.id }) + + '' end end # Devuelve una llave para poder guardar el post en una cache def cache_key - 'posts/' + uuid.value + "posts/#{uuid.value}" end def cache_version @@ -123,7 +132,7 @@ class Post # Agregar el timestamp para saber si cambió, siguiendo el módulo # ActiveRecord::Integration def cache_key_with_version - cache_key + '-' + cache_version + "#{cache_key}-#{cache_version}" end # TODO: Convertir a UUID? diff --git a/app/models/post/indexable.rb b/app/models/post/indexable.rb index 7757e7f7..4e46d7b2 100644 --- a/app/models/post/indexable.rb +++ b/app/models/post/indexable.rb @@ -14,9 +14,8 @@ class Post # # @return [IndexedPost] def to_index - IndexedPost.find_or_create_by(id: uuid.value).tap do |indexed_post| + IndexedPost.find_or_initialize_by(post_id: uuid.value, site_id: site.id).tap do |indexed_post| indexed_post.layout = layout.name - indexed_post.site_id = site.id indexed_post.path = path.basename indexed_post.locale = locale.value indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value) @@ -28,8 +27,6 @@ class Post end end - private - # Indexa o reindexa el Post # # @return [Boolean] @@ -41,6 +38,8 @@ class Post to_index.destroy.destroyed? end + private + # Los metadatos que se almacenan como objetos JSON. Empezamos con # las categorías porque se usan para filtrar en el listado de # artículos. diff --git a/app/models/site.rb b/app/models/site.rb index 8cab0ae0..21453370 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -17,7 +17,7 @@ class Site < ApplicationRecord # TODO: Hacer que los diferentes tipos de deploy se auto registren # @see app/services/site_service.rb - DEPLOYS = %i[local private www zip hidden_service].freeze + DEPLOYS = %i[local private www zip hidden_service distributed_press].freeze validates :name, uniqueness: true, hostname: { allow_root_label: true @@ -179,10 +179,20 @@ class Site < ApplicationRecord # Siempre tiene que tener algo porque las traducciones están # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan # sus sitios. + # + # @return [Array] def locales @locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym) end + # Modificar los locales disponibles + # + # @param :new_locales [Array] + # @return [Array] + def locales=(new_locales) + @locales = new_locales.map(&:to_sym).uniq + end + # Similar a site.i18n en jekyll-locales # # @return [Hash] @@ -250,6 +260,8 @@ class Site < ApplicationRecord layout = layouts[Post.find_layout(doc.path)] @posts[lang].build(document: doc, layout: layout, lang: lang) + rescue TypeError => e + ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path }) end @posts[lang] @@ -425,7 +437,7 @@ class Site < ApplicationRecord # El directorio donde se almacenan los sitios def self.site_path - @site_path ||= ENV.fetch('SITE_PATH', Rails.root.join('_sites')) + @site_path ||= File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites'))) end def self.default @@ -484,6 +496,7 @@ class Site < ApplicationRecord config.title = title config.url = url(slash: false) config.hostname = hostname + config.locales = locales.map(&:to_s) end # Valida si el sitio tiene al menos una forma de alojamiento asociada diff --git a/app/models/site/config.rb b/app/models/site/config.rb index 3215277e..d2e78d98 100644 --- a/app/models/site/config.rb +++ b/app/models/site/config.rb @@ -33,10 +33,10 @@ class Site def write return if persisted? - @saved = Site::Writer.new(site: site, file: path, - content: content.to_yaml).save - # Actualizar el hash para no escribir dos veces - @hash = content.hash + @saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result| + # Actualizar el hash para no escribir dos veces + @hash = content.hash + end end alias save write diff --git a/app/models/site/index.rb b/app/models/site/index.rb index e10fa523..e11095e3 100644 --- a/app/models/site/index.rb +++ b/app/models/site/index.rb @@ -14,9 +14,7 @@ class Site def index_posts! Site.transaction do - docs.each do |post| - post.to_index.save - end + docs.each(&:index!) end end end diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb index 74db2549..f63288d4 100644 --- a/app/models/site/repository.rb +++ b/app/models/site/repository.rb @@ -147,6 +147,23 @@ class Site rugged.index.remove(relativize(file)) end + # Garbage collection + # + # @return [Boolean] + def gc + env = { 'PATH' => '/usr/bin', 'LANG' => ENV['LANG'], 'HOME' => path } + cmd = 'git gc' + + r = nil + Dir.chdir(path) do + Open3.popen2e(env, cmd, unsetenv_others: true) do |_, _, t| + r = t.value + end + end + + r&.success? + end + private # Si Sutty tiene una llave privada de tipo ED25519, devuelve las diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb index c88dcc68..c87d82f9 100644 --- a/app/models/usuarie.rb +++ b/app/models/usuarie.rb @@ -9,6 +9,8 @@ class Usuarie < ApplicationRecord validates_uniqueness_of :email validates_with EmailAddress::ActiveRecordValidator, field: :email + before_create :lang_from_locale! + has_many :roles has_many :sites, through: :roles has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit' @@ -38,4 +40,10 @@ class Usuarie < ApplicationRecord increment_failed_attempts lock_access! if attempts_exceeded? && !access_locked? end + + private + + def lang_from_locale! + self.lang = I18n.locale.to_s + end end diff --git a/app/services/cleanup_service.rb b/app/services/cleanup_service.rb new file mode 100644 index 00000000..ad87cf9a --- /dev/null +++ b/app/services/cleanup_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Realiza tareas de limpieza en todos los sitios, para optimizar y +# liberar espacio. +class CleanupService + # Días de antigüedad de los sitios + attr_reader :before + + # @param :before [ActiveSupport::TimeWithZone] Cuánto tiempo lleva sin usarse un sitio. + def initialize(before: 30.days.ago) + @before = before + end + + # Limpieza general + # + # @return [nil] + def cleanup_everything! + cleanup_older_sites! + cleanup_newer_sites! + end + + # Encuentra todos los sitios sin actualizar y realiza limpieza. + # + # @return [nil] + def cleanup_older_sites! + Site.where('updated_at < ?', before).find_each do |site| + next unless File.directory? site.path + + site.deploys.find_each(&:cleanup!) + + site.repository.gc + site.touch + end + end + + # Tareas para los sitios en uso + # + # @return [nil] + def cleanup_newer_sites! + Site.where('updated_at >= ?', before).find_each do |site| + next unless File.directory? site.path + + site.repository.gc + site.touch + end + end +end diff --git a/app/services/site_service.rb b/app/services/site_service.rb index 22423bb8..f5f415e7 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -3,6 +3,11 @@ # Se encargar de guardar cambios en sitios # TODO: Implementar rollback en la configuración SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do + def deploy + site.enqueue! + DeployJob.perform_async site.id + end + # Crea un sitio, agrega un rol nuevo y guarda los cambios a la # configuración en el repositorio git def create @@ -11,7 +16,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do add_role temporal: false, rol: 'usuarie' sync_nodes - I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do + I18n.with_locale(usuarie.lang.to_sym || I18n.default_locale) do + # No se puede llamar a site.config antes de save porque el sitio + # todavía no existe. + # + # TODO: hacer que el repositorio se cree cuando es necesario, para + # que no haya estados intermedios. + site.locales = [usuarie.lang] + I18n.available_locales + site.save && site.config.write && commit_config(action: :create) @@ -19,6 +31,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do add_licencias + deploy + site end diff --git a/app/views/bootstrap/_alert.haml b/app/views/bootstrap/_alert.haml new file mode 100644 index 00000000..85bcbe84 --- /dev/null +++ b/app/views/bootstrap/_alert.haml @@ -0,0 +1,2 @@ +.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] } + = yield diff --git a/app/views/collaborations/collaborate.haml b/app/views/collaborations/collaborate.haml index 50cad809..4d43ad7e 100644 --- a/app/views/collaborations/collaborate.haml +++ b/app/views/collaborations/collaborate.haml @@ -11,7 +11,6 @@ url: site_collaborate_path(@site), method: :post) do |f| - unless current_usuarie - = render 'layouts/flash' .form-group = f.label :email = f.email_field :email, autofocus: true, autocomplete: 'email', diff --git a/app/views/deploy_mailer/deployed.html.haml b/app/views/deploy_mailer/deployed.html.haml index e8b2e7af..f5afe5de 100644 --- a/app/views/deploy_mailer/deployed.html.haml +++ b/app/views/deploy_mailer/deployed.html.haml @@ -1,17 +1,21 @@ -%h1= t('.hi') +%h1= @hi -= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname), - tags: %w[p a strong em] += sanitize_markdown @explanation, tags: %w[p a strong em] %table %thead %tr - %th= t('.th.type') - %th= t('.th.status') + - @headers.each do |header| + %th= header %tbody - - @deploys.each do |deploy, value| - %tr - %td= t(".#{deploy}.title") - %td= value ? t(".#{deploy}.success") : t(".#{deploy}.error") + - @table.each do |row| + - row[:urls].each do |url| + %tr + %td= row[:title] + %td= row[:status] + %td= link_to_if url.present?, url, url + %td + %time{ datetime: row[:seconds][:machine] }= row[:seconds][:human] + %td= row[:size] -= sanitize_markdown t('.help'), tags: %w[p a strong em] += sanitize_markdown @help, tags: %w[p a strong em] diff --git a/app/views/deploy_mailer/deployed.text.haml b/app/views/deploy_mailer/deployed.text.haml index 53a9b008..b2d0416f 100644 --- a/app/views/deploy_mailer/deployed.text.haml +++ b/app/views/deploy_mailer/deployed.text.haml @@ -1,12 +1,7 @@ -= '# ' + t('.hi') += "# #{@hi}" \ -= t('.explanation', fqdn: @deploy_local.site.hostname) += @explanation \ -= Terminal::Table.new do |table| - - table << [t('.th.type'), t('.th.status')] - - table.add_separator - - @deploys.each do |deploy, value| - - table << [t(".#{deploy}.title"), - value ? t(".#{deploy}.success") : t(".#{deploy}.error")] += @terminal_table \ -= t('.help') += @help diff --git a/app/views/deploys/_deploy_distributed_press.haml b/app/views/deploys/_deploy_distributed_press.haml new file mode 100644 index 00000000..d7d54db0 --- /dev/null +++ b/app/views/deploys/_deploy_distributed_press.haml @@ -0,0 +1,21 @@ +-# Publicar a la web distribuida + +.row + .col + = deploy.hidden_field :id + = deploy.hidden_field :type + .custom-control.custom-switch + -# + El checkbox invierte la lógica de destrucción porque queremos + crear el deploy si está activado y destruirlo si está + desactivado. + = deploy.check_box :_destroy, + { checked: deploy.object.persisted?, class: 'custom-control-input' }, + '0', '1' + = deploy.label :_destroy, class: 'custom-control-label' do + %h3= t('.title') + = sanitize_markdown t('.help', public_url: deploy.object.site.url), + tags: %w[p strong em a] + + +%hr/ diff --git a/app/views/deploys/_deploy_reindex.haml b/app/views/deploys/_deploy_reindex.haml new file mode 100644 index 00000000..af058968 --- /dev/null +++ b/app/views/deploys/_deploy_reindex.haml @@ -0,0 +1 @@ +-# NADA diff --git a/app/views/devise/confirmations/new.haml b/app/views/devise/confirmations/new.haml index 59568cb7..bc2f77bb 100644 --- a/app/views/devise/confirmations/new.haml +++ b/app/views/devise/confirmations/new.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-4.align-self-center .sr-only @@ -11,8 +13,6 @@ url: confirmation_path(resource_name), html: { method: :post }) do |f| - = render 'devise/shared/error_messages', resource: resource - :ruby value = if resource.pending_reconfirmation? resource.unconfirmed_email diff --git a/app/views/devise/invitations/edit.haml b/app/views/devise/invitations/edit.haml index 565429a8..ed4980ef 100644 --- a/app/views/devise/invitations/edit.haml +++ b/app/views/devise/invitations/edit.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center %h2= t 'devise.invitations.edit.header' @@ -8,7 +10,6 @@ as: resource_name, url: invitation_path(resource_name), html: { method: :put }) do |f| - = render 'devise/shared/error_messages', resource: resource = f.hidden_field :invitation_token, readonly: true - if f.object.class.require_password_on_accepting .form-group diff --git a/app/views/devise/invitations/new.haml b/app/views/devise/invitations/new.haml index 44ceec2e..4ebb8fa7 100644 --- a/app/views/devise/invitations/new.haml +++ b/app/views/devise/invitations/new.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center %h2= t 'devise.invitations.new.header' @@ -8,7 +10,6 @@ as: resource_name, url: invitation_path(resource_name), html: { method: :post }) do |f| - = render 'devise/shared/error_messages', resource: resource - resource.class.invite_key_fields.each do |field| .form-group = f.label field diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml index 46706c40..76b10d7f 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.haml +++ b/app/views/devise/mailer/confirmation_instructions.html.haml @@ -1,3 +1,3 @@ %p= t('.greeting', recipient: @email) %p= t('.instruction') -%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token) +%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang) diff --git a/app/views/devise/mailer/confirmation_instructions.text.haml b/app/views/devise/mailer/confirmation_instructions.text.haml index 38e4c548..7123a738 100644 --- a/app/views/devise/mailer/confirmation_instructions.text.haml +++ b/app/views/devise/mailer/confirmation_instructions.text.haml @@ -2,4 +2,4 @@ \ = t('.instruction') \ -= confirmation_url(@resource, confirmation_token: @token) += confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang) diff --git a/app/views/devise/mailer/invitation_instructions.html.haml b/app/views/devise/mailer/invitation_instructions.html.haml index 74193878..a2434abd 100644 --- a/app/views/devise/mailer/invitation_instructions.html.haml +++ b/app/views/devise/mailer/invitation_instructions.html.haml @@ -9,7 +9,7 @@ %p= site.description %p= link_to t('devise.mailer.invitation_instructions.accept'), - accept_invitation_url(@resource, invitation_token: @token) + accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang) - if @resource.invitation_due_at %p= t('devise.mailer.invitation_instructions.accept_until', diff --git a/app/views/devise/mailer/invitation_instructions.text.haml b/app/views/devise/mailer/invitation_instructions.text.haml index 16a9f0a8..27b1580c 100644 --- a/app/views/devise/mailer/invitation_instructions.text.haml +++ b/app/views/devise/mailer/invitation_instructions.text.haml @@ -9,7 +9,7 @@ \ = site.description \ -= accept_invitation_url(@resource, invitation_token: @token) += accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang) \ - if @resource.invitation_due_at = t('devise.mailer.invitation_instructions.accept_until', diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml index ccc4aa55..8d8f5919 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.haml +++ b/app/views/devise/mailer/reset_password_instructions.html.haml @@ -1,5 +1,5 @@ %p= t('.greeting', recipient: @resource.email) %p= t('.instruction') -%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token) +%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang) %p= t('.instruction_2') %p= t('.instruction_3') diff --git a/app/views/devise/mailer/reset_password_instructions.text.haml b/app/views/devise/mailer/reset_password_instructions.text.haml index 3d0fe64d..923c2a0c 100644 --- a/app/views/devise/mailer/reset_password_instructions.text.haml +++ b/app/views/devise/mailer/reset_password_instructions.text.haml @@ -2,7 +2,7 @@ \ = t('.instruction') \ -= edit_password_url(@resource, reset_password_token: @token) += edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang) \ = t('.instruction_2') \ diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml index d68bf7c7..9f8cd492 100644 --- a/app/views/devise/mailer/unlock_instructions.html.haml +++ b/app/views/devise/mailer/unlock_instructions.html.haml @@ -1,4 +1,4 @@ %p= t('.greeting', recipient: @resource.email) %p= t('.message') %p= t('.instruction') -%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token) +%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang) diff --git a/app/views/devise/mailer/unlock_instructions.text.haml b/app/views/devise/mailer/unlock_instructions.text.haml index cf06927b..950e04b7 100644 --- a/app/views/devise/mailer/unlock_instructions.text.haml +++ b/app/views/devise/mailer/unlock_instructions.text.haml @@ -4,4 +4,4 @@ \ = t('.instruction') \ -= unlock_url(@resource, unlock_token: @token) += unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang) diff --git a/app/views/devise/passwords/edit.haml b/app/views/devise/passwords/edit.haml index 7f7b16fb..3a8843c0 100644 --- a/app/views/devise/passwords/edit.haml +++ b/app/views/devise/passwords/edit.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center .sr-only @@ -10,7 +12,6 @@ = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| - = render 'devise/shared/error_messages', resource: resource = f.hidden_field :reset_password_token diff --git a/app/views/devise/passwords/new.haml b/app/views/devise/passwords/new.haml index 3c80b8a0..08dd8d2e 100644 --- a/app/views/devise/passwords/new.haml +++ b/app/views/devise/passwords/new.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center .sr-only @@ -11,7 +13,6 @@ as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| - = render 'devise/shared/error_messages', resource: resource .form-group = f.label :email, class: 'sr-only' = f.email_field :email, autofocus: true, autocomplete: 'email', diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml index 6a25da65..92699ab8 100644 --- a/app/views/devise/registrations/edit.haml +++ b/app/views/devise/registrations/edit.haml @@ -3,6 +3,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-6.align-self-center %h2= t('.title') @@ -11,8 +13,6 @@ url: registration_path(resource_name), html: { method: :put }) do |f| - = render 'devise/shared/error_messages', resource: resource - .form-group = f.label :email = f.email_field :email, autofocus: true, autocomplete: 'email', diff --git a/app/views/devise/registrations/new.haml b/app/views/devise/registrations/new.haml index cb6ff0d1..e6bda964 100644 --- a/app/views/devise/registrations/new.haml +++ b/app/views/devise/registrations/new.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center %h2= t('.sign_up') @@ -8,9 +10,7 @@ = form_for(resource, as: resource_name, - url: registration_path(resource_name)) do |f| - - = render 'devise/shared/error_messages', resource: resource + url: registration_path(resource_name, params: { locale: params[:locale] })) do |f| .form-group = f.label :email, class: 'sr-only' diff --git a/app/views/devise/sessions/new.haml b/app/views/devise/sessions/new.haml index b5223e5f..9b396187 100644 --- a/app/views/devise/sessions/new.haml +++ b/app/views/devise/sessions/new.haml @@ -3,8 +3,6 @@ .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center - = render 'layouts/flash' - .sr-only %h2= t('.sign_in') %p= t('.help') diff --git a/app/views/devise/shared/_error_messages.haml b/app/views/devise/shared/_error_messages.haml index a921fd61..64340e4f 100644 --- a/app/views/devise/shared/_error_messages.haml +++ b/app/views/devise/shared/_error_messages.haml @@ -1,9 +1,4 @@ - if resource.errors.any? - #error_explanation - %h2 - = I18n.t("errors.messages.not_saved", | - count: resource.errors.count, | - resource: resource.class.model_name.human.downcase) | - %ul - - resource.errors.full_messages.each do |message| - %li= message + = render 'bootstrap/alert' do + - resource.errors.full_messages.each do |message| + %p= message diff --git a/app/views/devise/shared/_links.haml b/app/views/devise/shared/_links.haml index c182d323..b4b89175 100644 --- a/app/views/devise/shared/_links.haml +++ b/app/views/devise/shared/_links.haml @@ -1,35 +1,38 @@ %hr/ +- locale = params.permit(:locale) + - if controller_name != 'sessions' - = link_to t('.sign_in'), new_session_path(resource_name) + = link_to t('.sign_in'), new_session_path(resource_name, params: locale), + class: 'btn btn-lg btn-block btn-success' %br/ - if devise_mapping.registerable? && controller_name != 'registrations' - = link_to t('.sign_up'), new_registration_path(resource_name), + = link_to t('.sign_up'), new_registration_path(resource_name, params: locale), class: 'btn btn-lg btn-block btn-success' %br/ - if devise_mapping.recoverable? - unless %w[passwords registrations].include?(controller_name) = link_to t('.forgot_your_password'), - new_password_path(resource_name) + new_password_path(resource_name, params: locale) %br/ - if devise_mapping.confirmable? && controller_name != 'confirmations' = link_to t('.didn_t_receive_confirmation_instructions'), - new_confirmation_path(resource_name) + new_confirmation_path(resource_name, params: locale) %br/ - if devise_mapping.lockable? - if resource_class.unlock_strategy_enabled?(:email) - if controller_name != 'unlocks' = link_to t('.didn_t_receive_unlock_instructions'), - new_unlock_path(resource_name) + new_unlock_path(resource_name, params: locale) %br/ - if devise_mapping.omniauthable? - resource_class.omniauth_providers.each do |provider| = link_to t('.sign_in_with_provider', provider: OmniAuth::Utils.camelize(provider)), - omniauth_authorize_path(resource_name, provider) + omniauth_authorize_path(resource_name, provider, params: locale) %br/ diff --git a/app/views/devise/unlocks/new.haml b/app/views/devise/unlocks/new.haml index ac511115..09468a52 100644 --- a/app/views/devise/unlocks/new.haml +++ b/app/views/devise/unlocks/new.haml @@ -1,6 +1,8 @@ = content_for :body do - 'black-bg' += render 'devise/shared/error_messages', resource: resource + .row.align-items-center.justify-content-center.full-height .col-md-5.align-self-center .sr-only @@ -11,7 +13,6 @@ as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| - = render 'devise/shared/error_messages', resource: resource .form-group = f.label :email, class: 'sr-only' = f.email_field :email, autofocus: true, autocomplete: 'email', diff --git a/app/views/invitadxs/show.haml b/app/views/invitadxs/show.haml index e1d47288..0c23522b 100644 --- a/app/views/invitadxs/show.haml +++ b/app/views/invitadxs/show.haml @@ -1,4 +1,4 @@ .row.align-items-center.justify-content-center.full-height .col-md-6.align-self-center - .alert{role: 'alert', class: "alert-success"} + = render 'bootstrap/alert' do = t('.confirmation_sent') diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml index dc0e3158..099ddde4 100644 --- a/app/views/layouts/_breadcrumb.haml +++ b/app/views/layouts/_breadcrumb.haml @@ -22,3 +22,7 @@ %li.nav-item = link_to t('.logout'), main_app.destroy_usuarie_session_path, method: :delete, role: 'button', class: 'btn' + - else + - I18n.available_locales.each do |locale| + - next if locale == I18n.locale + = link_to t(locale), "?change_locale_to=#{locale}" diff --git a/app/views/layouts/_flash.haml b/app/views/layouts/_flash.haml index 149f946f..7bd7ec0b 100644 --- a/app/views/layouts/_flash.haml +++ b/app/views/layouts/_flash.haml @@ -1,3 +1,4 @@ - flash.each do |type, message| - unless type == 'js' - .alert{ role: 'alert', class: "alert-#{type}" }= message + = render 'bootstrap/alert' do + = message diff --git a/app/views/layouts/_help.haml b/app/views/layouts/_help.haml index 7a821e2d..6800b524 100644 --- a/app/views/layouts/_help.haml +++ b/app/views/layouts/_help.haml @@ -1,3 +1,4 @@ +-# DEPRECADO .alert.alert-info.alert-dismissible.fade.show{role: 'alert'} - if help.respond_to? :each %ul diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 85d5ab22..965a856f 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -21,6 +21,9 @@ %body{ class: yield(:body) } .container-fluid#sutty = render 'layouts/breadcrumb' + = render 'layouts/flash' + = yield + - if flash[:js] .js-flash.d-none{ data: flash[:js] } diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml index e46b2eda..7de0ea79 100644 --- a/app/views/posts/_form.haml +++ b/app/views/posts/_form.haml @@ -1,7 +1,9 @@ - unless post.errors.empty? - .alert.alert-danger - %h4= t('.errors.title') - %p= t('.errors.help') + - title = t('.errors.title') + - help = t('.errors.help') + = render 'bootstrap/alert' do + %h4= title + %p= help %ul - post.errors.each do |attribute, errors| diff --git a/app/views/posts/_submit.haml b/app/views/posts/_submit.haml index b21b5ff2..cad43320 100644 --- a/app/views/posts/_submit.haml +++ b/app/views/posts/_submit.haml @@ -1,6 +1,6 @@ .form-group = submit_tag t('.save'), class: 'btn submit-post' - .invalid-help.alert.alert-danger.d-none + = render 'bootstrap/alert', class: 'invalid-help d-none' do = site.config.fetch('invalid_help', t('.invalid_help')) - .sending-help.alert.alert-success.d-none + = render 'bootstrap/alert', class: 'sending-help d-none' do = site.config.fetch('sending_help', t('.sending_help')) diff --git a/app/views/posts/attribute_ro/_non_geo.haml b/app/views/posts/attribute_ro/_non_geo.haml new file mode 100644 index 00000000..75f8d2ef --- /dev/null +++ b/app/views/posts/attribute_ro/_non_geo.haml @@ -0,0 +1,6 @@ +- lat = metadata.value['lat'] +- lng = metadata.value['lng'] +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td + = "#{lat},#{lng}" diff --git a/app/views/posts/attribute_ro/_password.haml b/app/views/posts/attribute_ro/_password.haml new file mode 100644 index 00000000..e55b021f --- /dev/null +++ b/app/views/posts/attribute_ro/_password.haml @@ -0,0 +1,6 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td{ dir: dir, lang: locale } + = metadata.value + %br/ + %small= t('.safety') diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 36b88872..5db023fb 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -6,7 +6,7 @@ .editor{ id: attribute, data: { editor: '' } } -# Esto es para luego decirle al navegador que se olvide estas cosas. = hidden_field_tag 'storage_keys[]', "#{request.original_url}##{attribute}", data: { target: 'storage-key' } - .alert.alert-info + = render 'bootstrap/alert' do :markdown #{t('editor.alert')} = text_area_tag "#{base}[#{attribute}]", '', @@ -123,7 +123,7 @@ %label{ for: 'link-url' }= t('editor.url') %input.form-control{ type: 'url', id: 'link-url', name: 'link-url' }/ - .editor-aviso-word.alert.alert-info + = render 'bootstrap/alert', class: 'editor-aviso-word' do %p= t('editor.word') .editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' } diff --git a/app/views/posts/attributes/_geo.haml b/app/views/posts/attributes/_geo.haml index d048565e..dee4707e 100644 --- a/app/views/posts/attributes/_geo.haml +++ b/app/views/posts/attributes/_geo.haml @@ -1,4 +1,8 @@ .row{ data: { controller: 'geo' } } + .col-12.mb-3 + %p.mb-0= post_label_t(attribute, post: post) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata .col .form-group = label_tag "#{base}_#{attribute}_lat", diff --git a/app/views/posts/attributes/_non_geo.haml b/app/views/posts/attributes/_non_geo.haml new file mode 100644 index 00000000..3f6a75a6 --- /dev/null +++ b/app/views/posts/attributes/_non_geo.haml @@ -0,0 +1,29 @@ +.row{ data: { controller: 'non-geo', site: site.url } } + .d-none{ hidden: true, data: { target: 'non-geo.overlay' }} + .col-12.mb-3 + %p.mb-0= post_label_t(attribute, post: post) + %p= post_label_t(attribute, post: post) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata + .col + .form-group + = label_tag "#{base}_#{attribute}_lat", + post_label_t(attribute, :lat, post: post) + = text_field(*field_name_for(base, attribute, :lat), + value: metadata.value['lat'], + **field_options(attribute, metadata), + data: { target: 'non-geo.lat' }) + = render 'posts/attribute_feedback', + post: post, attribute: [attribute, :lat], metadata: metadata + .col + .form-group + = label_tag "#{base}_#{attribute}_lng", + post_label_t(attribute, :lng, post: post) + = text_field(*field_name_for(base, attribute, :lng), + value: metadata.value['lng'], + **field_options(attribute, metadata), + data: { target: 'non-geo.lng' }) + = render 'posts/attribute_feedback', + post: post, attribute: [attribute, :lng], metadata: metadata + .col-12.mb-3 + %div{ data: { target: 'non-geo.map' }, style: 'height: 250px' } diff --git a/app/views/posts/attributes/_password.haml b/app/views/posts/attributes/_password.haml new file mode 100644 index 00000000..0aace30f --- /dev/null +++ b/app/views/posts/attributes/_password.haml @@ -0,0 +1,7 @@ +.form-group + = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post) + = password_field base, attribute, value: metadata.value, + dir: dir, lang: locale, + **field_options(attribute, metadata) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index bc5c826c..dbeddaad 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -34,14 +34,13 @@ = render 'sites/build', site: @site - if @site.design.credits - .alert.alert-primary{ role: 'alert' } + = render 'bootstrap/alert' do = sanitize_markdown @site.design.credits = link_to t('sites.donations.text'), t('sites.donations.url'), class: 'btn' - if @site.design.designer_url = link_to t('sites.designer_url'), @site.design.designer_url, class: 'btn' %section.col - = render 'layouts/flash' .d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2 %form{ action: site_posts_path } - @filter_params.each do |param, value| @@ -89,22 +88,22 @@ %div %tbody - - dir = t("locales.#{@locale}.dir") + - dir = @site.data.dig(params[:locale], 'dir') - size = @posts.size - @posts.each_with_index do |post, i| -# TODO: Solo les usuaries cachean porque tenemos que separar les botones por permisos. - cache_if @usuarie, [post, I18n.locale] do - - checkbox_id = "checkbox-#{post.id}" - %tr{ id: post.id, data: { target: 'reorder.row' } } + - checkbox_id = "checkbox-#{post.post_id}" + %tr{ id: post.post_id, data: { target: 'reorder.row' } } %td .custom-control.custom-checkbox %input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } } %label.custom-control-label{ for: checkbox_id } %span.sr-only= t('posts.reorder.select') -# Orden más alto es mayor prioridad - = hidden_field 'post[reorder]', post.id, + = hidden_field 'post[reorder]', post.post_id, value: size - i, data: { reorder: true } %td.w-100{ class: dir } diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index e46114af..9ef91888 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -1,4 +1,4 @@ -- dir = t("locales.#{@locale}.dir") +- dir = @site.data.dig(params[:locale], 'dir') .row.justify-content-center .col-md-8 %article.content.table-responsive-md @@ -6,13 +6,6 @@ edit_site_post_path(@site, @post.id), class: 'btn btn-block' - - unless @post.layout.ignored? - = link_to t('posts.preview.btn'), - site_post_preview_path(@site, @post.id), - class: 'btn btn-block', - target: '_blank', - rel: 'noopener' - %table.table.table-condensed %thead %tr diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index 6f15d570..757b8b48 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -1,7 +1,9 @@ - unless site.errors.empty? - .alert.alert-info - %h4= t('.errors.title') - %p.lead= t('.errors.help') + - title = t('.errors.title') + - help = t('.errors.help') + = render 'bootstrap/alert' do + %h4= title + %p.lead= help %ul - site.errors.messages.each_pair do |attr, error| - attr = attr.to_s @@ -48,7 +50,7 @@ %h2= t('.design.title') %p.lead= t('.help.design') - if invalid? site, :design_id - .alert.alert-info + = render 'bootstrap/alert' do = t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help', layouts: site.incompatible_layouts.to_sentence) .row.designs @@ -104,27 +106,27 @@ %hr/ - .form-group#tienda - %h2= t('.tienda.title') - %p.lead - - if site.tienda? - = t('.tienda.help') - - else - = t('.tienda.first_time_html') - - .row - .col - .form-group - = f.label :tienda_url - = f.url_field :tienda_url, class: 'form-control' - .col - .form-group - = f.label :tienda_api_key - = f.text_field :tienda_api_key, class: 'form-control' - - %hr/ - - if site.persisted? + .form-group#tienda + %h2= t('.tienda.title') + %p.lead + - if site.tienda? + = t('.tienda.help') + - else + = t('.tienda.first_time_html') + + .row + .col + .form-group + = f.label :tienda_url + = f.url_field :tienda_url, class: 'form-control' + .col + .form-group + = f.label :tienda_api_key + = f.text_field :tienda_api_key, class: 'form-control' + + %hr/ + .form-group#contact %h2= t('.contact.title') %p.lead= t('.contact.help') diff --git a/config/credentials.yml.enc.ci b/config/credentials.yml.enc.ci new file mode 100644 index 00000000..4add450d --- /dev/null +++ b/config/credentials.yml.enc.ci @@ -0,0 +1 @@ +1jEfzfldP9tT4+HWfhP48I9hw31gYCnnxHWpYjPrcTm/pgkFdiG+mDa6y31EOxzs50w6FEw2GO127BnyBSUIPIxuWY0cR96xL5pVrS3vjyzM84QN4lJF9ER0Tz1AQ9S7NJ54CelSkMfFt/rf+O4YM8cLtdSVsVC/HlGbp16p3D1pm4MFo5cQb0hEmlyyYlzEn4oJtsp/MCIwI4+z8oFhxKdMIxdbiw+KS/7PBRfMm1h5rdGORCnD69iVmnXseMvVtZn9A7N7uR6+gFlhxlD5yyEW0pwTj3tbu9NeIOVbtmYOL5ZhLW9REXtGTqR5Op/LN+ukIXbDNEScKltJXUdWfa9Pd/QjVT8IMURZ04POEMDgs1cw363yz4f+WQForhSco9oYLDOd5hTGRXoZ9fnjnfJSTjINM62hkfDY3w3+s844nNbjbj+lPTJHU/QjRhcuNqBDDxWUfwTmRIqm5zrelnHnZnuFmFwCNet6NChC6EFUAFjrals6kTSQllyMt4xImqA+HL7DnjWj6VURSH+nGQTA4tQvDdfbDwTzg/PvRkJcsy2dRd135RQdmRZ+8KXBviLabwdR256vaCqSO1j+jyeUPGLll35ghyLxncyBkkAKt1zaDRPDWgVafg0gJ3v7hVV5TYgToPzlv4w88KPCY7cBhkb1qGoXAhtO6iAuZYK9eyZd1gNQJKyqbcLqA5aTTX/ylfdbptWhaZ8ibB8KBgVyn2RmrOHEhB38rDSMHHNfK3Xs4/hhqMFIGHGTGCUYVmjCzhVFd15yRurU32d3YtP8W4L77H7qkFsF1gnvsZx+R084LcJqknwY94dmjtUE4x2u+Qh3ElFj--lr8JoUq1WH9xXNsB--mE8hxHADL7SbDWabAPY1+Q== \ No newline at end of file diff --git a/config/locales/devise.views.en.yml b/config/locales/devise.views.en.yml index fd041b33..793f3a0a 100644 --- a/config/locales/devise.views.en.yml +++ b/config/locales/devise.views.en.yml @@ -104,7 +104,7 @@ en: new: sign_up: Sign up help: We only ask for an e-mail address and a password. The password is safely stored, no one else besides you knows it! You'll also receive an e-mail to confirm your account. - signed_up: Welcome! You have signed up successfully. + signed_up: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated. signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked. signed_up_but_unconfirmed: A message with a confirmation link has been sent to your email address. Please follow the link to activate your account. @@ -138,7 +138,7 @@ en: errors: messages: already_confirmed: was already confirmed, please try signing in - confirmation_period_expired: needs to be confirmed within %{period}, please request a new one + confirmation_period_expired: "wasn't confirmed within %{period}. Please request a new confirmation link by using the \"Resend confirmation instructions\" button below and find it in your inbox." expired: has expired, please request a new one not_found: not found not_locked: was not locked diff --git a/config/locales/devise.views.es.yml b/config/locales/devise.views.es.yml index 73166afc..b745fc5f 100644 --- a/config/locales/devise.views.es.yml +++ b/config/locales/devise.views.es.yml @@ -104,7 +104,7 @@ es: new: sign_up: Registrarme help: Para registrarte solo pedimos una dirección de correo y una contraseña. La contraseña se almacena de forma segura, ¡nadie más que vos la sabe! Recibirás un correo de confirmación de cuenta. - signed_up: Bienvenide. Tu cuenta fue creada. + signed_up: "Hemos enviado un mensaje con un enlace de confirmación a tu correo electrónico. Por favor, abrí el enlace para terminar de activar tu cuenta." signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque tu cuenta aún no está activada. signed_up_but_locked: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque que tu cuenta está bloqueada. signed_up_but_unconfirmed: Para recibir actualizaciones, se ha enviado un mensaje con un enlace de confirmación a tu correo electrónico. Abre el enlace para activar tu cuenta. @@ -138,7 +138,7 @@ es: errors: messages: already_confirmed: ya ha sido confirmada, por favor intenta iniciar sesión - confirmation_period_expired: necesita confirmarse dentro de %{period}, por favor solicita una nueva + confirmation_period_expired: "quedó sin confirmar luego de %{period}. Por favor, usa el botón \"Reenviar instrucciones de confirmación\" y busca el nuevo link en tu casilla." expired: ha expirado, por favor solicita una nueva not_found: no se ha encontrado not_locked: no estaba bloqueada diff --git a/config/locales/en.yml b/config/locales/en.yml index eb7ef1ec..e6cc77be 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -16,6 +16,15 @@ en: ur: name: Urdu dir: rtl + zh: + name: Chinese + dir: ltr + de: + name: German + dir: ltr + fr: + name: French + dir: ltr login: email: E-mail address password: Password @@ -81,6 +90,9 @@ en: th: type: Type status: Status + seconds: Duration + size: Space used + url: Address deploy_local: title: Build the site success: Success! @@ -105,6 +117,18 @@ en: title: Alternative domain name success: Success! error: Error + deploy_distributed_press: + title: Distributed Web + success: Success! + error: Error + deploy_reindex: + title: Reindex + success: Success! + error: Error + deploy_localized_domain: + title: Domain name by language + success: Success! + error: Error deploy_rsync: title: Synchronize to backup server success: Success! @@ -255,9 +279,26 @@ en: Only accessible through [Tor Browser](https://www.torproject.org/download/) + deploy_distributed_press: + title: 'Publish to the distributed Web' + help: | + Make your site available through peer-to-peer protocols, + Inter-Planetary File System (IPFS), Hypercore, and via + BitTorrent, so your site is more resilient and can be available + offline, including in community mesh networks. + + **Important:** Only use this option if you would like your data + to be permanently available. If you decide to undo this + selection, a cleared version of the site will be shared in its + place. However, it is possible that nodes on the distributed + storage network may continue retaining copies of the data + indefinitely. + + [Learn more](https://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/) stats: index: title: Statistics + filter: "Filter" help: | These statistics show information about how your site is generated and how many resources it uses. @@ -433,6 +474,8 @@ en: attribute_ro: file: download: Download file + password: + safety: Passwords are stored safely show: front_matter: Post metadata submit: @@ -494,7 +537,7 @@ en: preview: btn: 'Preliminary version' alert: 'Not every article type has a preliminary version' - message: 'This is a preliminary version, use the Publish changes button back on the panel to publish the article onto your site.' + message: 'This is a preview of your post with some contextual elements from your site.' open: 'Tip: You can add new options by typing them and pressing Enter' private: '🔒 The values of this field will remain private' select: diff --git a/config/locales/es.yml b/config/locales/es.yml index 665fb350..2f423e3f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -16,6 +16,15 @@ es: ur: name: Urdu dir: rtl + zh: + name: Chino + dir: ltr + de: + name: Alemán + dir: ltr + fr: + name: Francés + dir: ltr login: email: Correo electrónico password: Contraseña @@ -81,6 +90,9 @@ es: th: type: Tipo status: Estado + seconds: Duración + size: Espacio ocupado + url: Dirección deploy_local: title: Generar el sitio success: ¡Éxito! @@ -105,6 +117,18 @@ es: title: Dominio alternativo success: ¡Éxito! error: Hubo un error + deploy_distributed_press: + title: Web distribuida + success: ¡Éxito! + error: Hubo un error + deploy_reindex: + title: Reindexación + success: ¡Éxito! + error: Hubo un error + deploy_localized_domain: + title: Dominio según idioma + success: ¡Éxito! + error: Hubo un error deploy_rsync: title: Sincronizar al servidor alternativo success: ¡Éxito! @@ -260,9 +284,26 @@ es: Sólo será accesible a través del [Navegador Tor](https://www.torproject.org/es/download/). + deploy_distributed_press: + title: 'Publicar a la Web distribuida' + help: | + Utiliza protocolos de pares, Inter-Planetary File System (IPFS), + Hypercore y torrents, para que tu sitio sea más resiliente y + esté disponible _offline_, inclusive en redes _mesh_ + comunitarias. + + **Importante:** Sólo usa esta opción si te parece correcto que + tu contenido esté disponible permanentemente. Cuando elijas + des-hacer esta acción, una versión "vacía" del sitio será + compartida en su lugar. Sin embargo, es posible que algunos + nodos en la red de almacenamiento distribuida puedan retener + copias de tu contenido indefinidamente. + + [Saber más (en inglés)](https://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/) stats: index: title: Estadísticas + filter: "Filtrar" help: | Las estadísticas visibilizan información sobre cómo se genera y cuántos recursos utiliza tu sitio. @@ -441,6 +482,8 @@ es: attribute_ro: file: download: Descargar archivo + password: + safety: Las contraseñas se almacenan de forma segura show: front_matter: Metadatos del artículo submit: @@ -502,7 +545,7 @@ es: preview: btn: 'Versión preliminar' alert: 'No todos los tipos de artículos poseen vista preliminar :)' - message: 'Esta es una versión preliminar, para que el artículo aparezca en tu sitio utiliza el botón Publicar cambios en el panel' + message: 'Esta es la vista previa de tu artículo, con algunos elementos contextuales del sitio' open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar' private: '🔒 Los valores de este campo serán privados' select: diff --git a/config/routes.rb b/config/routes.rb index 8bab18af..a132135a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,7 @@ Rails.application.routes.draw do # Gestionar artículos según idioma nested do - scope '/(:locale)', constraint: /[a-z]{2}/ do + scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do post :'posts/reorder', to: 'posts#reorder' resources :posts do get 'p/:page', action: :index, on: :collection diff --git a/db/migrate/20220428135113_add_slugify_mode_to_sites.rb b/db/migrate/20220428135113_add_slugify_mode_to_sites.rb new file mode 100644 index 00000000..fd887886 --- /dev/null +++ b/db/migrate/20220428135113_add_slugify_mode_to_sites.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Permite a los sitios elegir el método de slugificación +class AddSlugifyModeToSites < ActiveRecord::Migration[6.1] + def change + add_column :sites, :slugify_mode, :string, default: 'default' + end +end diff --git a/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb b/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb new file mode 100644 index 00000000..00bae7ea --- /dev/null +++ b/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Cambia el índice único para incluir el nombre del servicio, de forma +# que podamos tener varias copias del mismo sitio (por ejemplo para +# test) sin que falle la creación de archivos. +class ChangeBlobKeyUniquenessToIncludeServiceName < ActiveRecord::Migration[6.1] + def change + remove_index :active_storage_blobs, %i[key], unique: true + add_index :active_storage_blobs, %i[key service_name], unique: true + end +end diff --git a/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb b/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb new file mode 100644 index 00000000..e6572ffb --- /dev/null +++ b/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# No podemos compartir el uuid entre indexed_posts y posts porque +# podemos tener sitios duplicados. Al menos hasta que los sitios de +# testeo estén integrados en el panel vamos a tener que generar otros +# UUID. +class IndexedPostsByUuidAndSiteId < ActiveRecord::Migration[6.1] + def up + add_column :indexed_posts, :post_id, :uuid, index: true + + IndexedPost.transaction do + ActiveRecord::Base.connection.execute('update indexed_posts set post_id = id where post_id is null') + end + end + + def down + remove_column :indexed_posts, :post_id + end +end diff --git a/db/migrate/20230119165420_create_distributed_press_publisher.rb b/db/migrate/20230119165420_create_distributed_press_publisher.rb new file mode 100644 index 00000000..8d8de37a --- /dev/null +++ b/db/migrate/20230119165420_create_distributed_press_publisher.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Crea la tabla de publishers de Distributed Press que contiene las +# instancias y tokens +class CreateDistributedPressPublisher < ActiveRecord::Migration[6.1] + def change + create_table :distributed_press_publishers do |t| + t.timestamps + t.string :instance, unique: true + t.text :token_ciphertext, null: false + t.datetime :expires_at, null: true + end + end +end diff --git a/lib/tasks/cleanup.rake b/lib/tasks/cleanup.rake new file mode 100644 index 00000000..e14693bc --- /dev/null +++ b/lib/tasks/cleanup.rake @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +namespace :cleanup do + desc 'Cleanup sites' + task everything: :environment do + before = ENV.fetch('BEFORE', '30').to_i.days.ago + service = CleanupService.new(before: before) + + service.cleanup_everything! + end +end diff --git a/lib/tasks/distributed_press.rake b/lib/tasks/distributed_press.rake new file mode 100644 index 00000000..8ba270ec --- /dev/null +++ b/lib/tasks/distributed_press.rake @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +namespace :distributed_press do + namespace :tokens do + desc 'Renew tokens' + task renew: :environment do + RenewDistributedPressTokensJob.perform_now + end + end +end diff --git a/monit.conf b/monit.conf index 83d17449..dc866517 100644 --- a/monit.conf +++ b/monit.conf @@ -1,29 +1,12 @@ -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 start" - stop program = "/usr/local/bin/sutty prometheus start" - -check program blazer_5m - with path "/usr/local/bin/sutty blazer 5m" - every 5 cycles +# Limpiar mensualmente +check program cleanup + with path "/usr/bin/foreman run -f /srv/Procfile -d /srv cleanup" as uid "rails" gid "www-data" + every "0 3 1 * *" if status != 0 then alert -check program blazer_1h - with path "/usr/local/bin/sutty blazer 1h" - every 60 cycles - if status != 0 then alert - -check program blazer_1d - with path "/usr/local/bin/sutty blazer 1d" - every 1440 cycles - if status != 0 then alert - -check program blazer - with path "/usr/local/bin/sutty blazer" - every 61 cycles +check program distributed_press_tokens_renew + with path "/usr/bin/foreman run -f /srv/Procfile -d /srv distributed_press_tokens_renew" as uid "rails" gid "www-data" + every "0 3 * * *" if status != 0 then alert check program access_logs diff --git a/package.json b/package.json index d520c8f5..6a5f159f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "sutty", + "author": "Sutty ", "private": true, "dependencies": { "@airbrake/browser": "^1.4.1",