5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-06-02 06:04:17 +00:00

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

This commit is contained in:
f 2023-03-30 13:50:55 -03:00
commit 6e100d8954
71 changed files with 838 additions and 231 deletions

View file

@ -2,3 +2,4 @@
*
# Solo agregar lo que usamos en COPY
# !./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 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"
EXPOSE 3000

View file

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

View file

@ -261,7 +261,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)

View file

@ -1,8 +1,2 @@
migrate: bundle exec rake db:prepare db:seed
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_"
cleanup: bundle exec rake cleanup:everything
stats: bundle exec rake stats:process_all

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,12 +12,12 @@ 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 bundle(output: output)
jekyll_build
jekyll_build(output: output)
end
# Sólo permitimos un deploy local
@ -25,6 +25,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 +49,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
@ -81,23 +96,23 @@ class DeployLocal < Deploy
File.exist? yarn_lock
end
def gem
run %(gem install bundler --no-document)
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 bundle(output: false)
run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development), output: output
end
def jekyll_build
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}")
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

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
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({

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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
# 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?

View file

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

View file

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

View file

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

View file

@ -33,10 +33,10 @@ class Site
def write
return true 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

View file

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

View file

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

View file

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

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
# 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) &&
@ -21,6 +33,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_privacy_policy
end
deploy
site
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,4 +4,4 @@
\
= 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,
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

View file

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

View file

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

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' } }
.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",

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

View file

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

View file

@ -104,27 +104,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')

View file

@ -13,6 +13,15 @@ en:
ar:
name: Arabic
dir: rtl
zh:
name: Chinese
dir: ltr
de:
name: German
dir: ltr
fr:
name: French
dir: ltr
login:
email: E-mail address
password: Password
@ -78,6 +87,9 @@ en:
th:
type: Type
status: Status
seconds: Duration
size: Space used
url: Address
deploy_local:
title: Build the site
success: Success!
@ -102,6 +114,14 @@ en:
title: Alternative domain name
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,6 +275,7 @@ en:
stats:
index:
title: Statistics
filter: "Filter"
help: |
These statistics show information about how your site is generated and
how many resources it uses.
@ -430,6 +451,8 @@ en:
attribute_ro:
file:
download: Download file
password:
safety: Passwords are stored safely
show:
front_matter: Post metadata
submit:
@ -491,7 +514,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: '&#128274; The values of this field will remain private'
select:

View file

@ -13,6 +13,15 @@ es:
ar:
name: Árabe
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
@ -78,6 +87,9 @@ es:
th:
type: Tipo
status: Estado
seconds: Duración
size: Espacio ocupado
url: Dirección
deploy_local:
title: Generar el sitio
success: ¡Éxito!
@ -102,6 +114,14 @@ es:
title: Dominio alternativo
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,6 +280,7 @@ es:
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.
@ -438,6 +459,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:
@ -499,7 +522,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: '&#128274; Los valores de este campo serán privados'
select:

View file

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

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
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
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
# 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 access_logs