5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-06-30 19:16:08 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into issue-2123

This commit is contained in:
f 2023-03-27 13:40:52 -03:00
commit dab670b895
71 changed files with 838 additions and 231 deletions

View file

@ -2,3 +2,4 @@
* *
# Solo agregar lo que usamos en COPY # Solo agregar lo que usamos en COPY
# !./archivo # !./archivo
!./monit.conf

View file

@ -15,6 +15,8 @@ RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
RUN gem install --no-document --no-user-install foreman 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 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
COPY ./monit.conf /etc/monit.d/sutty.conf
VOLUME "/srv" VOLUME "/srv"
EXPOSE 3000 EXPOSE 3000

View file

@ -89,7 +89,7 @@ gem 'stackprof'
gem 'prometheus_exporter' gem 'prometheus_exporter'
# debug # debug
gem 'fast_jsonparser' gem 'fast_jsonparser', '~> 0.5.0'
gem 'down' gem 'down'
gem 'sourcemap' gem 'sourcemap'
gem 'rack-cors' gem 'rack-cors'

View file

@ -261,7 +261,7 @@ GEM
jekyll (~> 4) jekyll (~> 4)
jekyll-ignore-layouts (0.1.2) jekyll-ignore-layouts (0.1.2)
jekyll (~> 4) jekyll (~> 4)
jekyll-images (0.3.0) jekyll-images (0.3.2)
jekyll (~> 4) jekyll (~> 4)
ruby-filemagic (~> 0.7) ruby-filemagic (~> 0.7)
ruby-vips (~> 2) ruby-vips (~> 2)

View file

@ -1,8 +1,2 @@
migrate: bundle exec rake db:prepare db:seed cleanup: bundle exec rake cleanup:everything
sutty: bundle exec puma config.ru
blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes"
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_"
stats: bundle exec rake stats:process_all stats: bundle exec rake stats:process_all

View file

@ -15,7 +15,7 @@ module Api
params: airbrake_params.to_h params: airbrake_params.to_h
end end
render status: 201, json: { id: 1, url: root_url } render status: 201, json: { id: 1, url: '' }
end end
private private

View file

@ -9,7 +9,7 @@ module Api
# Lista de nombres de dominios a emitir certificados # Lista de nombres de dominios a emitir certificados
def index def index
render json: sites_names + alternative_names + api_names render json: sites_names + alternative_names + api_names + www_names
end end
# Sitios con hidden service de Tor # Sitios con hidden service de Tor
@ -28,7 +28,7 @@ module Api
site = Site.find_by(name: params[:name]) site = Site.find_by(name: params[:name])
if site 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, service = SiteService.new site: site, usuarie: usuarie,
params: params params: params
service.add_onion service.add_onion
@ -39,14 +39,22 @@ module Api
private private
def canonicalize(name)
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
end
# Nombres de los sitios # Nombres de los sitios
def sites_names def sites_names
Site.all.order(:name).pluck(:name) Site.all.order(:name).pluck(:name).map do |name|
canonicalize name
end
end end
# Dominios alternativos # Dominios alternativos
def alternative_names def alternative_names
DeployAlternativeDomain.all.map(&:hostname) (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
canonicalize name
end
end end
# Obtener todos los sitios con API habilitada, es decir formulario # Obtener todos los sitios con API habilitada, es decir formulario
@ -56,7 +64,16 @@ module Api
def api_names def api_names
Site.where(contact: true) Site.where(contact: true)
.or(Site.where(colaboracion_anonima: 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 end
end end

View file

@ -46,17 +46,19 @@ class ApplicationController < ActionController::Base
# defecto. # defecto.
# #
# Esto se refiere al idioma de la interfaz, no de los artículos. # 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 end
# El idioma es el preferido por le usuarie, pero no necesariamente se # El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer # corresponde con el idioma de los artículos, porque puede querer
# traducirlos. # traducirlos.
def set_locale(&action) def set_locale(&action)
I18n.with_locale(current_locale(include_params: false), &action) I18n.with_locale(current_locale, &action)
end end
# Muestra una página 404 # Muestra una página 404
@ -88,4 +90,12 @@ class ApplicationController < ActionController::Base
def prepare_exception_notifier def prepare_exception_notifier
request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie } request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie }
end 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 end

View file

@ -12,7 +12,7 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie # Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options def default_url_options
{ locale: current_locale } { locale: locale }
end end
def index def index

View file

@ -68,9 +68,7 @@ class SitesController < ApplicationController
def enqueue def enqueue
authorize site authorize site
# XXX: Convertir en una máquina de estados? SiteService.new(site: site).deploy
site.enqueue!
DeployJob.perform_async site.id
redirect_to site_posts_path(site, locale: site.default_locale) redirect_to site_posts_path(site, locale: site.default_locale)
end end

View file

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

View file

@ -3,9 +3,14 @@
# Realiza el deploy de un sitio # Realiza el deploy de un sitio
class DeployJob < ApplicationJob class DeployJob < ApplicationJob
class DeployException < StandardError; end class DeployException < StandardError; end
class DeployTimedOutException < DeployException; end
discard_on ActiveRecord::RecordNotFound
# rubocop:disable Metrics/MethodLength # 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 ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site) @site = Site.find(site)
@ -16,32 +21,39 @@ class DeployJob < ApplicationJob
# hora original para poder ir haciendo timeouts. # hora original para poder ir haciendo timeouts.
if @site.building? if @site.building?
if 10.minutes.ago >= time if 10.minutes.ago >= time
@site.update status: 'waiting' notify = false
raise DeployException, raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" "#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
end end
DeployJob.perform_in(60, site, notify, time) DeployJob.perform_in(60, site, notify: notify, time: time, output: output)
return return
end end
@site.update status: 'building' @site.update status: 'building'
# Asegurarse que DeployLocal sea el primero! # 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 # No es opcional
unless @deployed[:deploy_local] unless @deployed[:deploy_local][:status]
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea # Hacer fallar la tarea
raise DeployException, deploy_local.build_stats.last.log raise DeployException, "#{@site.name}: Falló la compilación"
end end
deploy_others deploy_others
rescue DeployTimedOutException => e
# Volver a la espera notify_exception e
@site.update status: 'waiting' rescue DeployException => e
notify_exception e, deploy_local
ensure
@site&.update status: 'waiting'
notify_usuaries if notify notify_usuaries if notify
end end
@ -50,17 +62,44 @@ class DeployJob < ApplicationJob
private 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 def deploy_local
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal') @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end end
def deploy_locally def deploy_locally
deploy_local.deploy deploy_local.deploy(output: @output)
end end
def deploy_others def deploy_others
@site.deploys.where.not(type: 'DeployLocal').find_each do |d| @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
end end

View file

@ -3,6 +3,8 @@
# Notifica excepciones a una instancia de Gitlab, como incidencias # Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes. # nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob class GitlabNotifierJob < ApplicationJob
class GitlabNotifierError < StandardError; end
include ExceptionNotifier::BacktraceCleaner include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego # Variables que vamos a acceder luego
@ -18,22 +20,28 @@ class GitlabNotifierJob < ApplicationJob
@issue_data = { count: 1 } @issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía # Necesitamos saber si el issue ya existía
@cached = false @cached = false
@issue = {}
# Traemos los datos desde la caché si existen, sino generamos un # Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché # issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do @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 @cached = true
{ {
count: 1, count: 1,
issue: issue['iid'], issue: @issue['iid'],
user_agents: [user_agent].compact, user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact, params: [request&.filtered_parameters].compact,
urls: [url].compact urls: [url].compact
} }
end 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 # No seguimos actualizando si acabamos de generar el issue
return if cached return if cached
@ -104,6 +112,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String] # @return [String]
def description def description
@description ||= ''.dup.tap do |d| @description ||= ''.dup.tap do |d|
d << log_section
d << request_section d << request_section
d << javascript_section d << javascript_section
d << javascript_footer d << javascript_footer
@ -151,6 +160,19 @@ class GitlabNotifierJob < ApplicationJob
@client ||= GitlabApiClient.new @client ||= GitlabApiClient.new
end end
# @return [String]
def log_section
return '' unless options[:log]
<<~LOG
# Log
```
#{options[:log]}
```
LOG
end
# Muestra información de la petición # Muestra información de la petición
# #
# @return [String] # @return [String]

View file

@ -20,6 +20,18 @@ module ActiveStorage
end end
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 # Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde # firma. Esto permite que luego podamos guardar el archivo donde
# corresponde. # corresponde.
@ -67,7 +79,9 @@ module ActiveStorage
# @param :key [String] # @param :key [String]
# @return [String] # @return [String]
def filename_for(key) 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 end
# Crea una ruta para la llave con un nombre conocido. # Crea una ruta para la llave con un nombre conocido.

View file

@ -8,21 +8,66 @@
# TODO: Agregar firma GPG y header Autocrypt # TODO: Agregar firma GPG y header Autocrypt
# TODO: Cifrar con GPG si le usuarie nos dio su llave # TODO: Cifrar con GPG si le usuarie nos dio su llave
class DeployMailer < ApplicationMailer class DeployMailer < ApplicationMailer
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
# rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/AbcSize
def deployed(which_ones) def deployed(deploys = {})
@usuarie = Usuarie.find(params[:usuarie]) usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site]) site = usuarie.sites.find(params[:site])
@deploys = which_ones hostname = site.hostname
@deploy_local = @site.deploys.find_by(type: 'DeployLocal') deploys ||= {}
# Informamos a cada quien en su idioma y damos una dirección de # Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben # respuesta porque a veces les usuaries nos escriben
I18n.with_locale(@usuarie.lang) do I18n.with_locale(usuarie.lang) do
mail(to: @usuarie.email, subject = t('.subject', site: site.name)
reply_to: "sutty@#{Site.domain}",
subject: I18n.t('deploy_mailer.deployed.subject', @hi = t('.hi')
site: @site.name)) @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
end end
# rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/AbcSize
private
def t(key, **args)
I18n.t("deploy_mailer.deployed#{key}", **args)
end
end end

View file

@ -11,7 +11,11 @@ class Deploy < ApplicationRecord
belongs_to :site belongs_to :site
has_many :build_stats, dependent: :destroy has_many :build_stats, dependent: :destroy
def deploy def deploy(**)
raise NotImplementedError
end
def url
raise NotImplementedError raise NotImplementedError
end end
@ -23,6 +27,9 @@ class Deploy < ApplicationRecord
raise NotImplementedError raise NotImplementedError
end end
# Realizar tareas de limpieza.
def cleanup!; end
def time_start def time_start
@start = Time.now @start = Time.now
end end
@ -39,6 +46,7 @@ class Deploy < ApplicationRecord
site.path site.path
end end
# XXX: Ver DeployLocal#bundle
def gems_dir def gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name) @gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end end
@ -48,20 +56,26 @@ class Deploy < ApplicationRecord
# #
# @param [String] # @param [String]
# @return [Boolean] # @return [Boolean]
def run(cmd) def run(cmd, output: false)
r = nil r = nil
lines = [] lines = []
time_start time_start
Dir.chdir(site.path) do Dir.chdir(site.path) do
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| 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? # TODO: Enviar a un websocket para ver el proceso en vivo?
o.each do |line| Thread.new do
lines << line o.each do |line|
lines << line
puts line if output
end
rescue IOError => e
lines << e.message
puts e.message if output
end end
r = t.value
end end
end end
time_stop time_stop

View file

@ -5,7 +5,7 @@ class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON store :values, accessors: %i[hostname], coder: JSON
# Generar un link simbólico del sitio principal al alternativo # Generar un link simbólico del sitio principal al alternativo
def deploy def deploy(**)
File.symlink?(destination) || File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero? File.symlink(site.hostname, destination).zero?
end end
@ -18,6 +18,10 @@ class DeployAlternativeDomain < Deploy
end end
def destination 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
end end

View file

@ -2,7 +2,7 @@
# Genera una versión onion # Genera una versión onion
class DeployHiddenService < DeployWww class DeployHiddenService < DeployWww
def deploy def deploy(**)
return true if fqdn.blank? return true if fqdn.blank?
super super
@ -13,6 +13,6 @@ class DeployHiddenService < DeployWww
end end
def url def url
'http://' + fqdn "http://#{fqdn}"
end end
end end

View file

@ -12,12 +12,12 @@ class DeployLocal < Deploy
# #
# Pasamos variables de entorno mínimas para no filtrar secretos de # Pasamos variables de entorno mínimas para no filtrar secretos de
# Sutty # Sutty
def deploy def deploy(output: false)
return false unless mkdir return false unless mkdir
return false unless yarn return false unless yarn(output: output)
return false unless bundle return false unless bundle(output: output)
jekyll_build jekyll_build(output: output)
end end
# Sólo permitimos un deploy local # Sólo permitimos un deploy local
@ -25,6 +25,10 @@ class DeployLocal < Deploy
1 1
end end
def url
site.url
end
# Obtener el tamaño de todos los archivos y directorios (los # Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :) # directorios son archivos :)
def size def size
@ -45,6 +49,17 @@ class DeployLocal < Deploy
File.join(Rails.root, '_deploy', site.hostname) File.join(Rails.root, '_deploy', site.hostname)
end 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 private
def mkdir def mkdir
@ -81,23 +96,23 @@ class DeployLocal < Deploy
File.exist? yarn_lock File.exist? yarn_lock
end end
def gem def gem(output: false)
run %(gem install bundler --no-document) run %(gem install bundler --no-document), output: output
end end
# Corre yarn dentro del repositorio # Corre yarn dentro del repositorio
def yarn def yarn(output: false)
return true unless yarn_lock? return true unless yarn_lock?
run 'yarn install --production' run 'yarn install --production', output: output
end end
def bundle def bundle(output: false)
run %(bundle install --no-cache --path="#{gems_dir}") run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development), output: output
end end
def jekyll_build def jekyll_build(output: false)
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}"), output: output
end end
# no debería haber espacios ni caracteres especiales, pero por si # no debería haber espacios ni caracteres especiales, pero por si

View file

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

View file

@ -7,8 +7,8 @@
# jekyll-private-data # jekyll-private-data
class DeployPrivate < DeployLocal class DeployPrivate < DeployLocal
# No es necesario volver a instalar dependencias # No es necesario volver a instalar dependencias
def deploy def deploy(output: false)
jekyll_build jekyll_build(output: output)
end end
# Hacer el deploy a un directorio privado # Hacer el deploy a un directorio privado
@ -16,6 +16,10 @@ class DeployPrivate < DeployLocal
File.join(Rails.root, '_private', site.name) File.join(Rails.root, '_private', site.name)
end end
def url
"#{ENV['PANEL_URL']}/sites/private/#{site.name}"
end
# No usar recursos en compresión y habilitar los datos privados # No usar recursos en compresión y habilitar los datos privados
def env def env
@env ||= super.merge({ @env ||= super.merge({

View file

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

View file

@ -5,8 +5,8 @@
class DeployRsync < Deploy class DeployRsync < Deploy
store :values, accessors: %i[destination host_keys], coder: JSON store :values, accessors: %i[destination host_keys], coder: JSON
def deploy def deploy(output: false)
ssh? && rsync ssh? && rsync(output: output)
end end
# El espacio remoto es el mismo que el local # El espacio remoto es el mismo que el local
@ -83,8 +83,8 @@ class DeployRsync < Deploy
# Sincroniza hacia el directorio remoto # Sincroniza hacia el directorio remoto
# #
# @return [Boolean] # @return [Boolean]
def rsync def rsync(output: output)
run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/) run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
end end
# El origen es el destino de la compilación # El origen es el destino de la compilación

View file

@ -6,7 +6,7 @@ class DeployWww < Deploy
before_destroy :remove_destination! before_destroy :remove_destination!
def deploy def deploy(**)
File.symlink?(destination) || File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero? File.symlink(site.hostname, destination).zero?
end end
@ -27,6 +27,10 @@ class DeployWww < Deploy
"www.#{site.hostname}" "www.#{site.hostname}"
end end
def url
"https://www.#{site.hostname}/"
end
private private
def remove_destination! def remove_destination!

View file

@ -12,7 +12,7 @@ class DeployZip < Deploy
# y generar un zip accesible públicamente. # y generar un zip accesible públicamente.
# #
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def deploy def deploy(**)
FileUtils.rm_f path FileUtils.rm_f path
time_start time_start
@ -49,6 +49,10 @@ class DeployZip < Deploy
"#{site.hostname}.zip" "#{site.hostname}.zip"
end end
def url
"#{site.url}#{file}"
end
def path def path
File.join(destination, file) File.join(destination, file)
end end

View file

@ -72,6 +72,17 @@ class MetadataContent < MetadataTemplate
resource['controls'] = true resource['controls'] = true
end 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 html.to_s.html_safe
end end
def elements_with_style
@elements_with_style ||= %w[div mark].freeze
end
end end

View file

@ -23,7 +23,6 @@ class MetadataFile < MetadataTemplate
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid? errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
errors << I18n.t("metadata.#{type}.path_required") if path_missing? errors << I18n.t("metadata.#{type}.path_required") if path_missing?
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact! errors.compact!
@ -41,14 +40,14 @@ class MetadataFile < MetadataTemplate
end end
# Asociar la imagen subida al sitio y obtener la ruta # Asociar la imagen subida al sitio y obtener la ruta
# # @return [Boolean]
# 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.
def save def save
value['description'] = sanitize value['description'] if value['path'].blank?
value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil self[:value] = default_value
else
value['description'] = sanitize value['description']
value['path'] = relative_destination_path_with_filename.to_s if static_file
end
true true
end 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 que apunta a un archivo asociado al sitio
# * El archivo es una ruta a un archivo dentro del repositorio # * 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 # @todo encontrar una forma de obtener el attachment sin tener que
# recurrir al último subido. # recurrir al último subido.
# #
@ -75,13 +71,7 @@ class MetadataFile < MetadataTemplate
when ActionDispatch::Http::UploadedFile when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path']) site.static_files.last if site.static_files.attach(value['path'])
when String when String
if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first) site.static_files.find_by(blob_id: blob_id) || migrate_static_file!
site.static_files.find_by(blob_id: blob_id)
elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename)
site.static_files.last.tap do |s|
s.blob.update(key: key_from_path)
end
end
end end
end end
@ -98,7 +88,7 @@ class MetadataFile < MetadataTemplate
# #
# @return [String] # @return [String]
def key_from_path def key_from_path
pathname.dirname.basename.to_s @key_from_path ||= pathname.dirname.basename.to_s
end end
def path? def path?
@ -127,13 +117,22 @@ class MetadataFile < MetadataTemplate
# devolvemos la ruta original, que puede ser el archivo que no existe # devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno. # o vacía si se está subiendo uno.
rescue Errno::ENOENT => e 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 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 def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath) 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 end
def static_file_path def static_file_path
@ -145,8 +144,31 @@ class MetadataFile < MetadataTemplate
end end
end end
# No hay archivo pero se lo describió # Obtiene el id del blob asociado
def no_file_for_description? #
!path? && description? # @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
end end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
class MetadataNonGeo < MetadataGeo; end

View file

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

View file

@ -2,12 +2,6 @@
# Este metadato permite generar rutas manuales. # Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString 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 # Los permalinks nunca pueden ser privados
def private? def private?
false false

View file

@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar # Trae el slug desde el título si existe o una string al azar
def default_value def default_value
title ? Jekyll::Utils.slugify(title) : SecureRandom.uuid title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid
end end
def value def value
@ -39,6 +39,6 @@ class MetadataSlug < MetadataTemplate
return if post.title&.private? return if post.title&.private?
return if post.title&.value&.blank? return if post.title&.value&.blank?
post.title&.value&.to_s post.title&.value&.to_s&.unicode_normalize
end end
end end

View file

@ -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 # En caso de que algún campo necesite realizar acciones antes de ser
# guardado # guardado
def save def save
return true unless changed? if !changed?
self[:value] = document_value if private?
return true
end
self[:value] = sanitize value self[:value] = sanitize value
self[:value] = encrypt(value) if private? self[:value] = encrypt(value) if private?

View file

@ -29,7 +29,7 @@ class Post
# TODO: Reemplazar cuando leamos el contenido del Document # TODO: Reemplazar cuando leamos el contenido del Document
# a demanda? # a demanda?
def find_layout(path) 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
end end
@ -90,16 +90,21 @@ class Post
'page' => document.to_liquid '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 # Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores. # poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document 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. # no son públicas hasta que se publica el artículo.
html.css('img').each do |img| html.css('img,audio,video,iframe').each do |element|
next if %r{\Ahttps?://} =~ img.attributes['src'] src = element.attributes['src']
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site, next unless src&.value&.start_with? 'public/'
file: img.attributes['src'].value)
src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: src.value)
end end
# Notificar a les usuaries que están viendo una previsualización # Notificar a les usuaries que están viendo una previsualización
@ -108,12 +113,16 @@ class Post
# Cacofonía # Cacofonía
html.to_html.html_safe html.to_html.html_safe
rescue Liquid::Error => e
ExceptionNotifier.notify(e, data: { site: site.name, post: post.id })
''
end end
end end
# Devuelve una llave para poder guardar el post en una cache # Devuelve una llave para poder guardar el post en una cache
def cache_key def cache_key
'posts/' + uuid.value "posts/#{uuid.value}"
end end
def cache_version def cache_version
@ -123,7 +132,7 @@ class Post
# Agregar el timestamp para saber si cambió, siguiendo el módulo # Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration # ActiveRecord::Integration
def cache_key_with_version def cache_key_with_version
cache_key + '-' + cache_version "#{cache_key}-#{cache_version}"
end end
# TODO: Convertir a UUID? # TODO: Convertir a UUID?

View file

@ -14,9 +14,8 @@ class Post
# #
# @return [IndexedPost] # @return [IndexedPost]
def to_index 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.layout = layout.name
indexed_post.site_id = site.id
indexed_post.path = path.basename indexed_post.path = path.basename
indexed_post.locale = locale.value indexed_post.locale = locale.value
indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value) indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value)
@ -28,8 +27,6 @@ class Post
end end
end end
private
# Indexa o reindexa el Post # Indexa o reindexa el Post
# #
# @return [Boolean] # @return [Boolean]
@ -41,6 +38,8 @@ class Post
to_index.destroy.destroyed? to_index.destroy.destroyed?
end end
private
# Los metadatos que se almacenan como objetos JSON. Empezamos con # Los metadatos que se almacenan como objetos JSON. Empezamos con
# las categorías porque se usan para filtrar en el listado de # las categorías porque se usan para filtrar en el listado de
# artículos. # artículos.

View file

@ -179,10 +179,20 @@ class Site < ApplicationRecord
# Siempre tiene que tener algo porque las traducciones están # Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios. # sus sitios.
#
# @return [Array]
def locales def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym) @locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
end 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 # Similar a site.i18n en jekyll-locales
# #
# @return [Hash] # @return [Hash]
@ -250,6 +260,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)] layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang) @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 end
@posts[lang] @posts[lang]
@ -425,7 +437,7 @@ class Site < ApplicationRecord
# El directorio donde se almacenan los sitios # El directorio donde se almacenan los sitios
def self.site_path 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 end
def self.default def self.default
@ -484,6 +496,7 @@ class Site < ApplicationRecord
config.title = title config.title = title
config.url = url(slash: false) config.url = url(slash: false)
config.hostname = hostname config.hostname = hostname
config.locales = locales.map(&:to_s)
end end
# Valida si el sitio tiene al menos una forma de alojamiento asociada # Valida si el sitio tiene al menos una forma de alojamiento asociada

View file

@ -33,10 +33,10 @@ class Site
def write def write
return if persisted? return if persisted?
@saved = Site::Writer.new(site: site, file: path, @saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result|
content: content.to_yaml).save # Actualizar el hash para no escribir dos veces
# Actualizar el hash para no escribir dos veces @hash = content.hash
@hash = content.hash end
end end
alias save write alias save write

View file

@ -14,9 +14,7 @@ class Site
def index_posts! def index_posts!
Site.transaction do Site.transaction do
docs.each do |post| docs.each(&:index!)
post.to_index.save
end
end end
end end
end end

View file

@ -147,6 +147,23 @@ class Site
rugged.index.remove(relativize(file)) rugged.index.remove(relativize(file))
end 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 private
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las # Si Sutty tiene una llave privada de tipo ED25519, devuelve las

View file

@ -9,6 +9,8 @@ class Usuarie < ApplicationRecord
validates_uniqueness_of :email validates_uniqueness_of :email
validates_with EmailAddress::ActiveRecordValidator, field: :email validates_with EmailAddress::ActiveRecordValidator, field: :email
before_create :lang_from_locale!
has_many :roles has_many :roles
has_many :sites, through: :roles has_many :sites, through: :roles
has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit' has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit'
@ -38,4 +40,10 @@ class Usuarie < ApplicationRecord
increment_failed_attempts increment_failed_attempts
lock_access! if attempts_exceeded? && !access_locked? lock_access! if attempts_exceeded? && !access_locked?
end end
private
def lang_from_locale!
self.lang = I18n.locale.to_s
end
end end

View file

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

View file

@ -3,6 +3,11 @@
# Se encargar de guardar cambios en sitios # Se encargar de guardar cambios en sitios
# TODO: Implementar rollback en la configuración # TODO: Implementar rollback en la configuración
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do 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 # Crea un sitio, agrega un rol nuevo y guarda los cambios a la
# configuración en el repositorio git # configuración en el repositorio git
def create def create
@ -11,7 +16,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_role temporal: false, rol: 'usuarie' add_role temporal: false, rol: 'usuarie'
sync_nodes 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.save &&
site.config.write && site.config.write &&
commit_config(action: :create) commit_config(action: :create)
@ -19,6 +31,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias add_licencias
deploy
site site
end end

View file

@ -1,17 +1,21 @@
%h1= t('.hi') %h1= @hi
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname), = sanitize_markdown @explanation, tags: %w[p a strong em]
tags: %w[p a strong em]
%table %table
%thead %thead
%tr %tr
%th= t('.th.type') - @headers.each do |header|
%th= t('.th.status') %th= header
%tbody %tbody
- @deploys.each do |deploy, value| - @table.each do |row|
%tr - row[:urls].each do |url|
%td= t(".#{deploy}.title") %tr
%td= value ? t(".#{deploy}.success") : t(".#{deploy}.error") %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]

View file

@ -1,12 +1,7 @@
= '# ' + t('.hi') = "# #{@hi}"
\ \
= t('.explanation', fqdn: @deploy_local.site.hostname) = @explanation
\ \
= Terminal::Table.new do |table| = @terminal_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")]
\ \
= t('.help') = @help

View file

@ -0,0 +1 @@
-# NADA

View file

@ -1,3 +1,3 @@
%p= t('.greeting', recipient: @email) %p= t('.greeting', recipient: @email)
%p= t('.instruction') %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)

View file

@ -2,4 +2,4 @@
\ \
= t('.instruction') = t('.instruction')
\ \
= confirmation_url(@resource, confirmation_token: @token) = confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang)

View file

@ -10,7 +10,7 @@
- if @resource.created_by_invite? && !@resource.invitation_accepted? - if @resource.created_by_invite? && !@resource.invitation_accepted?
%p= link_to t('devise.mailer.invitation_instructions.accept'), %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 - if @resource.invitation_due_at
%p= t('devise.mailer.invitation_instructions.accept_until', %p= t('devise.mailer.invitation_instructions.accept_until',

View file

@ -10,7 +10,7 @@
= site.description = site.description
\ \
- if @resource.created_by_invite? && !@resource.invitation_accepted? - if @resource.created_by_invite? && !@resource.invitation_accepted?
= accept_invitation_url(@resource, invitation_token: @token) = accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
\ \
- if @resource.invitation_due_at - if @resource.invitation_due_at
= t('devise.mailer.invitation_instructions.accept_until', = t('devise.mailer.invitation_instructions.accept_until',

View file

@ -1,5 +1,5 @@
%p= t('.greeting', recipient: @resource.email) %p= t('.greeting', recipient: @resource.email)
%p= t('.instruction') %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_2')
%p= t('.instruction_3') %p= t('.instruction_3')

View file

@ -2,7 +2,7 @@
\ \
= t('.instruction') = 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') = t('.instruction_2')
\ \

View file

@ -1,4 +1,4 @@
%p= t('.greeting', recipient: @resource.email) %p= t('.greeting', recipient: @resource.email)
%p= t('.message') %p= t('.message')
%p= t('.instruction') %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)

View file

@ -4,4 +4,4 @@
\ \
= t('.instruction') = t('.instruction')
\ \
= unlock_url(@resource, unlock_token: @token) = unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang)

View file

@ -8,7 +8,7 @@
= form_for(resource, = form_for(resource,
as: resource_name, as: resource_name,
url: registration_path(resource_name)) do |f| url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
= render 'devise/shared/error_messages', resource: resource = render 'devise/shared/error_messages', resource: resource

View file

@ -1,35 +1,38 @@
%hr/ %hr/
- locale = params.permit(:locale)
- if controller_name != 'sessions' - 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/ %br/
- if devise_mapping.registerable? && controller_name != 'registrations' - 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' class: 'btn btn-lg btn-block btn-success'
%br/ %br/
- if devise_mapping.recoverable? - if devise_mapping.recoverable?
- unless %w[passwords registrations].include?(controller_name) - unless %w[passwords registrations].include?(controller_name)
= link_to t('.forgot_your_password'), = link_to t('.forgot_your_password'),
new_password_path(resource_name) new_password_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.confirmable? && controller_name != 'confirmations' - if devise_mapping.confirmable? && controller_name != 'confirmations'
= link_to t('.didn_t_receive_confirmation_instructions'), = link_to t('.didn_t_receive_confirmation_instructions'),
new_confirmation_path(resource_name) new_confirmation_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.lockable? - if devise_mapping.lockable?
- if resource_class.unlock_strategy_enabled?(:email) - if resource_class.unlock_strategy_enabled?(:email)
- if controller_name != 'unlocks' - if controller_name != 'unlocks'
= link_to t('.didn_t_receive_unlock_instructions'), = link_to t('.didn_t_receive_unlock_instructions'),
new_unlock_path(resource_name) new_unlock_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.omniauthable? - if devise_mapping.omniauthable?
- resource_class.omniauth_providers.each do |provider| - resource_class.omniauth_providers.each do |provider|
= link_to t('.sign_in_with_provider', = link_to t('.sign_in_with_provider',
provider: OmniAuth::Utils.camelize(provider)), provider: OmniAuth::Utils.camelize(provider)),
omniauth_authorize_path(resource_name, provider) omniauth_authorize_path(resource_name, provider, params: locale)
%br/ %br/

View file

@ -22,3 +22,7 @@
%li.nav-item %li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path, = link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn' 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}"

View file

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

View file

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

View file

@ -1,4 +1,8 @@
.row{ data: { controller: 'geo' } } .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 .col
.form-group .form-group
= label_tag "#{base}_#{attribute}_lat", = label_tag "#{base}_#{attribute}_lat",

View file

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

View file

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

View file

@ -89,22 +89,22 @@
%div %div
%tbody %tbody
- dir = t("locales.#{@locale}.dir") - dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size - size = @posts.size
- @posts.each_with_index do |post, i| - @posts.each_with_index do |post, i|
-# -#
TODO: Solo les usuaries cachean porque tenemos que separar TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos. les botones por permisos.
- cache_if @usuarie, [post, I18n.locale] do - cache_if @usuarie, [post, I18n.locale] do
- checkbox_id = "checkbox-#{post.id}" - checkbox_id = "checkbox-#{post.post_id}"
%tr{ id: post.id, data: { target: 'reorder.row' } } %tr{ id: post.post_id, data: { target: 'reorder.row' } }
%td %td
.custom-control.custom-checkbox .custom-control.custom-checkbox
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } } %input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
%label.custom-control-label{ for: checkbox_id } %label.custom-control-label{ for: checkbox_id }
%span.sr-only= t('posts.reorder.select') %span.sr-only= t('posts.reorder.select')
-# Orden más alto es mayor prioridad -# Orden más alto es mayor prioridad
= hidden_field 'post[reorder]', post.id, = hidden_field 'post[reorder]', post.post_id,
value: size - i, value: size - i,
data: { reorder: true } data: { reorder: true }
%td.w-100{ class: dir } %td.w-100{ class: dir }

View file

@ -1,4 +1,4 @@
- dir = t("locales.#{@locale}.dir") - dir = @site.data.dig(params[:locale], 'dir')
.row.justify-content-center .row.justify-content-center
.col-md-8 .col-md-8
%article.content.table-responsive-md %article.content.table-responsive-md
@ -6,13 +6,6 @@
edit_site_post_path(@site, @post.id), edit_site_post_path(@site, @post.id),
class: 'btn btn-block' 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 %table.table.table-condensed
%thead %thead
%tr %tr

View file

@ -104,27 +104,27 @@
%hr/ %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? - 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 .form-group#contact
%h2= t('.contact.title') %h2= t('.contact.title')
%p.lead= t('.contact.help') %p.lead= t('.contact.help')

View file

@ -13,6 +13,15 @@ en:
ar: ar:
name: Arabic name: Arabic
dir: rtl dir: rtl
zh:
name: Chinese
dir: ltr
de:
name: German
dir: ltr
fr:
name: French
dir: ltr
login: login:
email: E-mail address email: E-mail address
password: Password password: Password
@ -78,6 +87,9 @@ en:
th: th:
type: Type type: Type
status: Status status: Status
seconds: Duration
size: Space used
url: Address
deploy_local: deploy_local:
title: Build the site title: Build the site
success: Success! success: Success!
@ -102,6 +114,14 @@ en:
title: Alternative domain name title: Alternative domain name
success: Success! success: Success!
error: Error error: Error
deploy_reindex:
title: Reindex
success: Success!
error: Error
deploy_localized_domain:
title: Domain name by language
success: Success!
error: Error
deploy_rsync: deploy_rsync:
title: Synchronize to backup server title: Synchronize to backup server
success: Success! success: Success!
@ -255,6 +275,7 @@ en:
stats: stats:
index: index:
title: Statistics title: Statistics
filter: "Filter"
help: | help: |
These statistics show information about how your site is generated and These statistics show information about how your site is generated and
how many resources it uses. how many resources it uses.
@ -430,6 +451,8 @@ en:
attribute_ro: attribute_ro:
file: file:
download: Download file download: Download file
password:
safety: Passwords are stored safely
show: show:
front_matter: Post metadata front_matter: Post metadata
submit: submit:
@ -491,7 +514,7 @@ en:
preview: preview:
btn: 'Preliminary version' btn: 'Preliminary version'
alert: 'Not every article type has a 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' open: 'Tip: You can add new options by typing them and pressing Enter'
private: '&#128274; The values of this field will remain private' private: '&#128274; The values of this field will remain private'
select: select:

View file

@ -13,6 +13,15 @@ es:
ar: ar:
name: Árabe name: Árabe
dir: rtl dir: rtl
zh:
name: Chino
dir: ltr
de:
name: Alemán
dir: ltr
fr:
name: Francés
dir: ltr
login: login:
email: Correo electrónico email: Correo electrónico
password: Contraseña password: Contraseña
@ -78,6 +87,9 @@ es:
th: th:
type: Tipo type: Tipo
status: Estado status: Estado
seconds: Duración
size: Espacio ocupado
url: Dirección
deploy_local: deploy_local:
title: Generar el sitio title: Generar el sitio
success: ¡Éxito! success: ¡Éxito!
@ -102,6 +114,14 @@ es:
title: Dominio alternativo title: Dominio alternativo
success: ¡Éxito! success: ¡Éxito!
error: Hubo un error 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: deploy_rsync:
title: Sincronizar al servidor alternativo title: Sincronizar al servidor alternativo
success: ¡Éxito! success: ¡Éxito!
@ -260,6 +280,7 @@ es:
stats: stats:
index: index:
title: Estadísticas title: Estadísticas
filter: "Filtrar"
help: | help: |
Las estadísticas visibilizan información sobre cómo se genera y Las estadísticas visibilizan información sobre cómo se genera y
cuántos recursos utiliza tu sitio. cuántos recursos utiliza tu sitio.
@ -438,6 +459,8 @@ es:
attribute_ro: attribute_ro:
file: file:
download: Descargar archivo download: Descargar archivo
password:
safety: Las contraseñas se almacenan de forma segura
show: show:
front_matter: Metadatos del artículo front_matter: Metadatos del artículo
submit: submit:
@ -499,7 +522,7 @@ es:
preview: preview:
btn: 'Versión preliminar' btn: 'Versión preliminar'
alert: 'No todos los tipos de artículos poseen vista 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' open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar'
private: '&#128274; Los valores de este campo serán privados' private: '&#128274; Los valores de este campo serán privados'
select: select:

View file

@ -55,7 +55,7 @@ Rails.application.routes.draw do
# Gestionar artículos según idioma # Gestionar artículos según idioma
nested do 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' post :'posts/reorder', to: 'posts#reorder'
resources :posts do resources :posts do
get 'p/:page', action: :index, on: :collection get 'p/:page', action: :index, on: :collection

View file

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

View file

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

View file

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

11
lib/tasks/cleanup.rake Normal file
View file

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

View file

@ -1,29 +1,7 @@
check process sutty with pidfile /srv/tmp/puma.pid # Limpiar mensualmente
start program = "/usr/local/bin/sutty start" check program cleanup
stop program = "/usr/local/bin/sutty stop" with path "/usr/bin/foreman run -f /srv/Procfile -d /srv cleanup" as uid "rails" gid "www-data"
every "0 3 1 * *"
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
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
if status != 0 then alert if status != 0 then alert
check program access_logs check program access_logs