5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-07-01 03:26:09 +00:00

Merge branch 'rails' into deploy-reindex

This commit is contained in:
f 2023-03-23 19:05:41 -03:00
commit 539fc7bded
87 changed files with 1509 additions and 824 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

9
.profile Normal file
View file

@ -0,0 +1,9 @@
Color_Off='\e[0m'
BPurple='\e[1;35m'
BBlue='\e[1;34m'
is_git() {
git rev-parse --abbrev-ref HEAD 2>/dev/null
}
PS1="\[${BPurple}\]\$(is_git) \[${BBlue}\]\W\[${Color_Off}\] >_ "

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

20
Gemfile
View file

@ -48,9 +48,9 @@ gem 'image_processing'
gem 'icalendar' gem 'icalendar'
gem 'inline_svg' gem 'inline_svg'
gem 'httparty' gem 'httparty'
gem 'safe_yaml', source: 'https://gems.sutty.nl' gem 'safe_yaml'
gem 'jekyll', '~> 4.2' gem 'jekyll', '~> 4.2'
gem 'jekyll-data', source: 'https://gems.sutty.nl' gem 'jekyll-data'
gem 'jekyll-commonmark' gem 'jekyll-commonmark'
gem 'jekyll-images' gem 'jekyll-images'
gem 'jekyll-include-cache' gem 'jekyll-include-cache'
@ -64,7 +64,7 @@ gem 'rails-i18n'
gem 'rails_warden' gem 'rails_warden'
gem 'redis', require: %w[redis redis/connection/hiredis] gem 'redis', require: %w[redis redis/connection/hiredis]
gem 'redis-rails' gem 'redis-rails'
gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master' gem 'rollups', git: 'https://github.com/fauno/rollup.git', branch: 'update'
gem 'rubyzip' gem 'rubyzip'
gem 'rugged' gem 'rugged'
gem 'concurrent-ruby-ext' gem 'concurrent-ruby-ext'
@ -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'
@ -99,18 +99,6 @@ gem 'net-ssh'
gem 'ed25519' gem 'ed25519'
gem 'bcrypt_pbkdf' gem 'bcrypt_pbkdf'
group :themes do
gem 'adhesiones-jekyll-theme', require: false
gem 'editorial-autogestiva-jekyll-theme', require: false
gem 'minima', require: false
gem 'sutty-minima', require: false
gem 'radios-comunitarias-jekyll-theme', require: false
gem 'share-to-fediverse-jekyll-theme', require: false
gem 'sutty-donaciones-jekyll-theme', require: false
gem 'sutty-jekyll-theme', require: false
gem 'recursero-jekyll-theme', require: false
end
group :production do group :production do
gem 'lograge' gem 'lograge'
end end

View file

@ -6,15 +6,6 @@ GIT
rails (>= 3.0) rails (>= 3.0)
rake (>= 0.8.7) rake (>= 0.8.7)
GIT
remote: https://github.com/ankane/rollup.git
revision: 0ab6c603450175eb1004f7793e86486943cb9f72
branch: master
specs:
rollups (0.1.3)
activesupport (>= 5.1)
groupdate (>= 5.2)
GIT GIT
remote: https://github.com/fauno/email_address remote: https://github.com/fauno/email_address
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
@ -24,6 +15,15 @@ GIT
netaddr (>= 2.0.4, < 3) netaddr (>= 2.0.4, < 3)
simpleidn simpleidn
GIT
remote: https://github.com/fauno/rollup.git
revision: ddbb345aa57e63b4cfdf7557267efa89ba60caac
branch: update
specs:
rollups (0.1.3)
activesupport (>= 5.1)
groupdate (>= 5.2)
GEM GEM
remote: https://gems.sutty.nl/ remote: https://gems.sutty.nl/
specs: specs:
@ -88,15 +88,6 @@ GEM
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.0) addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
adhesiones-jekyll-theme (0.2.1)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
ast (2.4.2) ast (2.4.2)
autoprefixer-rails (10.3.3.0) autoprefixer-rails (10.3.3.0)
execjs (~> 2) execjs (~> 2)
@ -169,25 +160,6 @@ GEM
down (5.2.4) down (5.2.4)
addressable (~> 2.8) addressable (~> 2.8)
ed25519 (1.2.4-x86_64-linux-musl) ed25519 (1.2.4-x86_64-linux-musl)
editorial-autogestiva-jekyll-theme (0.3.4)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
jekyll-dotenv (>= 0.2)
jekyll-feed (~> 0.15)
jekyll-hardlinks (~> 0)
jekyll-ignore-layouts (~> 0)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-order (~> 0)
jekyll-relative-urls (~> 0)
jekyll-seo-tag (~> 2)
jekyll-spree-client (~> 0)
jekyll-unique-urls (~> 0)
jekyll-write-and-commit-changes (~> 0)
sutty-liquid (~> 0)
em-websocket (0.5.3) em-websocket (0.5.3)
eventmachine (>= 0.12.9) eventmachine (>= 0.12.9)
http_parser.rb (~> 0) http_parser.rb (~> 0)
@ -214,8 +186,8 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
globalid (0.6.0) globalid (0.6.0)
activesupport (>= 5.0) activesupport (>= 5.0)
groupdate (5.2.2) groupdate (6.1.0)
activesupport (>= 5) activesupport (>= 5.2)
hairtrigger (0.2.24) hairtrigger (0.2.24)
activerecord (>= 5.0, < 7) activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4) ruby2ruby (~> 2.4)
@ -369,10 +341,6 @@ GEM
mini_magick (4.11.0) mini_magick (4.11.0)
mini_mime (1.1.2) mini_mime (1.1.2)
mini_portile2 (2.6.1) mini_portile2 (2.6.1)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.4) minitest (5.14.4)
mobility (1.2.4) mobility (1.2.4)
i18n (>= 0.6.10, < 2) i18n (>= 0.6.10, < 2)
@ -415,17 +383,6 @@ GEM
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
radios-comunitarias-jekyll-theme (0.1.5)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
rails (6.1.4.1) rails (6.1.4.1)
actioncable (= 6.1.4.1) actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1) actionmailbox (= 6.1.4.1)
@ -462,24 +419,6 @@ GEM
rb-fsevent (0.11.0) rb-fsevent (0.11.0)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
recursero-jekyll-theme (0.2.0)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
jekyll-dotenv (>= 0.2)
jekyll-feed (~> 0.15)
jekyll-ignore-layouts (~> 0)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-lunr (~> 0.1)
jekyll-order (~> 0)
jekyll-relative-urls (~> 0)
jekyll-seo-tag (~> 2)
jekyll-unique-urls (~> 0.1)
sutty-archives (~> 2.2)
sutty-liquid (~> 0)
redis (4.5.1) redis (4.5.1)
redis-actionpack (5.2.0) redis-actionpack (5.2.0)
actionpack (>= 5, < 7) actionpack (>= 5, < 7)
@ -552,14 +491,6 @@ GEM
rubyzip (>= 1.2.2) rubyzip (>= 1.2.2)
semantic_range (3.0.0) semantic_range (3.0.0)
sexp_processor (4.16.0) sexp_processor (4.16.0)
share-to-fediverse-jekyll-theme (0.1.4)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
simpleidn (0.2.1) simpleidn (0.2.1)
unf (~> 0.1.4) unf (~> 0.1.4)
sourcemap (0.1.1) sourcemap (0.1.1)
@ -583,30 +514,9 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
sutty-archives (2.5.4) sutty-archives (2.5.4)
jekyll (>= 3.6, < 5.0) jekyll (>= 3.6, < 5.0)
sutty-donaciones-jekyll-theme (0.1.2)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
sutty-archives (~> 2.2)
sutty-jekyll-theme (0.1.2)
jekyll (~> 4.0)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
sutty-liquid (0.7.4) sutty-liquid (0.7.4)
fast_blank (~> 1.0) fast_blank (~> 1.0)
jekyll (~> 4) jekyll (~> 4)
sutty-minima (2.5.0)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
symbol-fstring (1.0.2-x86_64-linux-musl) symbol-fstring (1.0.2-x86_64-linux-musl)
sysexits (1.2.0) sysexits (1.2.0)
temple (0.8.2) temple (0.8.2)
@ -654,7 +564,6 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
adhesiones-jekyll-theme
bcrypt (~> 3.1.7) bcrypt (~> 3.1.7)
bcrypt_pbkdf bcrypt_pbkdf
blazer blazer
@ -672,7 +581,6 @@ DEPENDENCIES
dotenv-rails dotenv-rails
down down
ed25519 ed25519
editorial-autogestiva-jekyll-theme
email_address! email_address!
exception_notification exception_notification
factory_bot_rails factory_bot_rails
@ -691,7 +599,7 @@ DEPENDENCIES
jbuilder (~> 2.5) jbuilder (~> 2.5)
jekyll (~> 4.2) jekyll (~> 4.2)
jekyll-commonmark jekyll-commonmark
jekyll-data! jekyll-data
jekyll-images jekyll-images
jekyll-include-cache jekyll-include-cache
kaminari kaminari
@ -702,7 +610,6 @@ DEPENDENCIES
lograge lograge
memory_profiler memory_profiler
mini_magick mini_magick
minima
mobility mobility
net-ssh net-ssh
nokogiri nokogiri
@ -714,31 +621,25 @@ DEPENDENCIES
pundit pundit
rack-cors rack-cors
rack-mini-profiler rack-mini-profiler
radios-comunitarias-jekyll-theme
rails (~> 6) rails (~> 6)
rails-i18n rails-i18n
rails_warden rails_warden
recursero-jekyll-theme
redis redis
redis-rails redis-rails
rollups! rollups!
rubocop-rails rubocop-rails
rubyzip rubyzip
rugged rugged
safe_yaml! safe_yaml
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
share-to-fediverse-jekyll-theme
sourcemap sourcemap
spring spring
spring-watcher-listen (~> 2.0.0) spring-watcher-listen (~> 2.0.0)
sqlite3 sqlite3
stackprof stackprof
sucker_punch sucker_punch
sutty-donaciones-jekyll-theme
sutty-jekyll-theme
sutty-liquid (>= 0.7.3) sutty-liquid (>= 0.7.3)
sutty-minima
symbol-fstring symbol-fstring
terminal-table terminal-table
timecop timecop

View file

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

View file

@ -126,6 +126,7 @@ ol.breadcrumb {
color: var(--foreground); color: var(--foreground);
} }
.table tr.sticky-top,
.form-control, .form-control,
.custom-file-label { .custom-file-label {
background-color: var(--background); background-color: var(--background);

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

@ -3,6 +3,7 @@
# Forma de ingreso a Sutty # Forma de ingreso a Sutty
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include ExceptionHandler include ExceptionHandler
include Pundit
protect_from_forgery with: :null_session, prepend: true protect_from_forgery with: :null_session, prepend: true
@ -10,6 +11,7 @@ class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
around_action :set_locale around_action :set_locale
rescue_from Pundit::NilPolicyError, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found rescue_from ActionController::RoutingError, with: :page_not_found
rescue_from ActionController::ParameterMissing, with: :page_not_found rescue_from ActionController::ParameterMissing, with: :page_not_found
@ -33,7 +35,7 @@ class ApplicationController < ActionController::Base
def find_site def find_site
id = params[:site_id] || params[:id] id = params[:site_id] || params[:id]
unless (site = current_usuarie.sites.find_by_name(id)) unless (site = current_usuarie&.sites&.find_by_name(id))
raise SiteNotFound raise SiteNotFound
end end
@ -44,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
@ -62,6 +66,21 @@ class ApplicationController < ActionController::Base
render 'application/page_not_found', status: :not_found render 'application/page_not_found', status: :not_found
end end
# Necesario para poder acceder a Blazer. Solo les usuaries de este
# sitio pueden acceder al panel.
def require_usuarie
site = find_site
authorize SiteBlazer.new(site)
# Necesario para los breadcrumbs.
ActionView::Base.include Loaf::ViewExtensions unless ActionView::Base.included_modules.include? Loaf::ViewExtensions
breadcrumb current_usuarie.email, main_app.edit_usuarie_registration_path
breadcrumb 'sites.index', main_app.sites_path, match: :exact
breadcrumb site.title, main_app.site_path(site), match: :exact
breadcrumb 'stats.index', root_path, match: :exact
end
protected protected
def configure_permitted_parameters def configure_permitted_parameters
@ -71,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

@ -0,0 +1,194 @@
# frozen_string_literal: true
# Modificaciones para Blazer
module BlazerDecorator
# No poder obtener información de la base de datos.
module DisableDatabaseInfo
extend ActiveSupport::Concern
included do
def docs; end
def tables; end
def schema; end
end
end
# Deshabilitar edición de consultas y chequeos.
module DisableEdits
extend ActiveSupport::Concern
included do
def create; end
def update; end
def destroy; end
def run; end
def refresh; end
def cancel; end
end
end
# Blazer hace un gran esfuerzo para ejecutar consultas de forma
# asincrónica pero termina enviándolas por JS.
module RunSync
extend ActiveSupport::Concern
included do
alias_method :original_show, :show
include Blazer::BaseHelper
def show
original_show
options = { user: blazer_user, query: @query, run_id: SecureRandom.uuid, async: false }
@data_source = Blazer.data_sources[@query.data_source]
@result = Blazer::RunStatement.new.perform(@data_source, @statement, options)
chart_data
end
private
# Solo mostrar las consultas de le usuarie
def set_queries(_ = nil)
@queries = (@current_usuarie || current_usuarie).blazer_queries
end
# blazer-2.4.2/app/views/blazer/queries/run.html.erb
def chart_type
case @result.chart_type
when /\Aline(2)?\z/
chart_options.merge! min: nil
when /\Abar(2)?\z/
chart_options.merge! library: { tooltips: { intersect: false, axis: 'x' } }
when 'pie'
chart_options
when 'scatter'
chart_options.merge! library: { tooltips: { intersect: false } }, xtitle: @result.columns[0],
ytitle: @result.columns[1]
when nil
else
if @result.column_types.size == 2
chart_options.merge! library: { tooltips: { intersect: false, axis: 'x' } }
else
chart_options.merge! library: { tooltips: { intersect: false } }
end
end
@result.chart_type
end
def chart_data
@chart_data ||=
case chart_type
when 'line'
@result.columns[1..-1].each_with_index.map do |k, i|
{
name: blazer_series_name(k),
data: @result.rows.map do |r|
[r[0], r[i + 1]]
end,
library: series_library[i]
}
end
when 'line2'
@result.rows.group_by do |r|
v = r[1]
(@result.boom[@result.columns[1]] || {})[v.to_s] || v
end.each_with_index.map do |(name, v), i|
{
name: blazer_series_name(name),
data: v.map do |v2|
[v2[0], v2[2]]
end,
library: series_library[i]
}
end
when 'pie'
@result.rows.map do |r|
[(@result.boom[@result.columns[0]] || {})[r[0].to_s] || r[0], r[1]]
end
when 'bar'
(@result.rows.first.size - 1).times.map do |i|
name = @result.columns[i + 1]
{
name: blazer_series_name(name),
data: @result.rows.first(20).map do |r|
[(@result.boom[@result.columns[0]] || {})[r[0].to_s] || r[0], r[i + 1]]
end
}
end
when 'bar2'
first_20 = @result.rows.group_by { |r| r[0] }.values.first(20).flatten(1)
labels = first_20.map { |r| r[0] }.uniq
series = first_20.map { |r| r[1] }.uniq
labels.each do |l|
series.each do |s|
first_20 << [l, s, 0] unless first_20.find { |r| r[0] == l && r[1] == s }
end
end
first_20.group_by do |r|
v = r[1]
(@result.boom[@result.columns[1]] || {})[v.to_s] || v
end.each_with_index.map do |(name, v), _i|
{
name: blazer_series_name(name),
data: v.sort_by do |r2|
labels.index(r2[0])
end.map do |v2|
v3 = v2[0]
[(@result.boom[@result.columns[0]] || {})[v3.to_s] || v3, v2[2]]
end
}
end
when 'scatter'
@result.rows
end
end
def target_index
@target_index ||= @result.columns.index do |k|
k.downcase == 'target'
end
end
def series_library
@series_library ||= {}.tap do |sl|
if target_index
color = '#109618'
sl[target_index - 1] = {
pointStyle: 'line',
hitRadius: 5,
borderColor: color,
pointBackgroundColor: color,
backgroundColor: color,
pointHoverBackgroundColor: color
}
end
end
end
def chart_options
@chart_options ||= { id: SecureRandom.hex }
end
end
end
end
classes = [Blazer::QueriesController, Blazer::ChecksController, Blazer::DashboardsController]
modules = [BlazerDecorator::DisableDatabaseInfo, BlazerDecorator::DisableEdits]
classes.each do |klass|
modules.each do |modul|
klass.include modul unless klass.included_modules.include? modul
end
end
Blazer::QueriesController.include BlazerDecorator::RunSync

View file

@ -2,9 +2,6 @@
# Controlador para artículos # Controlador para artículos
class PostsController < ApplicationController class PostsController < ApplicationController
include Pundit
rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
before_action :service_for_direct_upload, only: %i[new edit] before_action :service_for_direct_upload, only: %i[new edit]
@ -15,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
@ -38,6 +35,8 @@ class PostsController < ApplicationController
# Filtrar los posts que les invitades no pueden ver # Filtrar los posts que les invitades no pueden ver
@usuarie = site.usuarie? current_usuarie @usuarie = site.usuarie? current_usuarie
@site_stat = SiteStat.new(site)
end end
def show def show

View file

@ -6,8 +6,6 @@ class PrivateController < ApplicationController
# XXX: Permite ejecutar JS # XXX: Permite ejecutar JS
skip_forgery_protection skip_forgery_protection
include Pundit
# Enviar el archivo si existe, agregar una / al final siempre para no # Enviar el archivo si existe, agregar una / al final siempre para no
# romper las direcciones relativas. # romper las direcciones relativas.
def show def show

View file

@ -2,9 +2,6 @@
# Controlador de sitios # Controlador de sitios
class SitesController < ApplicationController class SitesController < ApplicationController
include Pundit
rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
@ -71,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

@ -8,6 +8,10 @@ class StatsController < ApplicationController
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
before_action :authorize_stats before_action :authorize_stats
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
breadcrumb 'sites.index', :sites_path, match: :exact
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
EXTRA_OPTIONS = { EXTRA_OPTIONS = {
builds: {}, builds: {},
space_used: { bytes: true }, space_used: { bytes: true },
@ -20,19 +24,53 @@ class StatsController < ApplicationController
policy.script_src :self, :unsafe_inline policy.script_src :self, :unsafe_inline
end end
# Parámetros por defecto
#
# @return [Hash]
def default_url_options
{ interval: 'day', period_start: Date.today.beginning_of_year, period_end: Date.today }
end
def index def index
@chart_params = { interval: interval } breadcrumb I18n.t('stats.index.title'), ''
params.with_defaults! default_url_options
@chart_params = {
interval: interval,
period_start: params[:period_start],
period_end: params[:period_end]
}
hostnames hostnames
last_stat last_stat
chart_options chart_options
normalized_urls normalized_urls
expires_in = Time.now.try(:"end_of_#{Stat.default_interval}") - Time.now
@columns = {}
Stat::COLUMNS.each do |column|
@columns[column] =
Rails.cache.fetch("stats/#{column}/#{site.id}", expires_in: expires_in) do
rollup_scope.where(interval: interval, name: "host|#{column}")
.where_dimensions(host: hostnames)
.group("dimensions->>'#{column}'")
.order('sum(value) desc')
.sum(:value)
.transform_values(&:to_i)
.transform_values do |v|
v * nodes
end
end
end
end end
# Genera un gráfico de visitas por dominio asociado a este sitio # Genera un gráfico de visitas por dominio asociado a este sitio
def host def host
return unless stale? [last_stat, hostnames, interval] return unless stale? [last_stat, hostnames, interval, period]
stats = Rollup.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series| stats = rollup_scope.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series|
series.each do |serie| series.each do |serie|
serie[:name] = serie.dig(:dimensions, 'host') serie[:name] = serie.dig(:dimensions, 'host')
serie[:data].transform_values! do |value| serie[:data].transform_values! do |value|
@ -45,23 +83,20 @@ class StatsController < ApplicationController
end end
def resources def resources
return unless stale? [last_stat, interval, resource] return unless stale? [last_stat, interval, resource, period]
options = { options = { interval: interval, dimensions: { site_id: site.id } }
interval: interval,
dimensions: {
deploy_id: @site.deploys.where(type: 'DeployLocal').pluck(:id).first
}
}
render json: Rollup.series(resource, **options) render json: rollup_scope.series(resource, **options)
end end
def uris def uris
return unless stale? [last_stat, hostnames, interval, normalized_urls] return unless stale? [last_stat, hostnames, interval, normalized_urls, period]
options = { host: hostnames, uri: normalized_paths } options = { host: hostnames, uri: normalized_paths }
stats = Rollup.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series| # XXX: where_dimensions es más corto pero no aprovecha los índices
# de Rollup
stats = rollup_scope.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series|
series.each do |serie| series.each do |serie|
serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/') serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/')
serie[:data].transform_values! do |value| serie[:data].transform_values! do |value|
@ -75,34 +110,44 @@ class StatsController < ApplicationController
private private
def rollup_scope
Rollup.where(time: period)
end
def last_stat def last_stat
@last_stat ||= Stat.last @last_stat ||= site.stats.last
end end
def authorize_stats def authorize_stats
@site = find_site authorize SiteStat.new(site)
authorize SiteStat.new(@site)
end end
# TODO: Eliminar cuando mergeemos referer-origin # TODO: Eliminar cuando mergeemos referer-origin
def hostnames def hostnames
@hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten @hostnames ||= site.hostnames
end end
# Normalizar las URLs # Normalizar las URLs
# #
# @return [Array] # @return [Array]
def normalized_urls def normalized_urls
@normalized_urls ||= params.permit(:urls).try(:[], @normalized_urls ||=
:urls)&.split("\n")&.map(&:strip)&.select(&:present?)&.select do |uri| begin
uri.start_with? 'https://' urls = params[:urls].is_a?(Array) ? params[:urls] : params[:urls]&.split("\n")
end&.map do |u| urls = urls&.map(&:strip)&.select(&:present?)&.select do |uri|
# XXX: Eliminar uri.start_with? 'https://'
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1} end
next u unless u.end_with? '/'
"#{u}index.html" urls ||= [site.url]
end&.uniq || [@site.url, @site.urls].flatten.uniq
urls.map do |u|
# XXX: Eliminar al deployear
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1}
next u unless u.end_with? '/'
"#{u}index.html"
end.uniq
end
end end
def normalized_paths def normalized_paths
@ -140,14 +185,15 @@ class StatsController < ApplicationController
def interval def interval
@interval ||= begin @interval ||= begin
i = params[:interval]&.to_sym i = params[:interval]&.to_sym
Stat::INTERVALS.include?(i) ? i : :day Stat::INTERVALS.include?(i) ? i : Stat::INTERVALS.first
end end
end end
# @return [Symbol]
def resource def resource
@resource ||= begin @resource ||= begin
r = params[:resource].to_sym r = params[:resource].to_sym
Stat::RESOURCES.include?(r) ? r : :builds Stat::RESOURCES.include?(r) ? r : Stat::RESOURCES.first
end end
end end
@ -165,4 +211,15 @@ class StatsController < ApplicationController
def nodes def nodes
@nodes ||= ENV.fetch('NODES', 1).to_i @nodes ||= ENV.fetch('NODES', 1).to_i
end end
def period
@period ||= begin
p = params.permit(:period_start, :period_end)
p[:period_start]..p[:period_end]
end
end
def site
@site ||= find_site
end
end end

View file

@ -103,11 +103,7 @@ export default class extends Controller {
this.reorder() this.reorder()
// Mantenemos el primero a la vista // Mantenemos el primero a la vista
if ("scrollIntoViewIfNeeded" in rows[0].row) { rows[0].row.scrollIntoView({ block: "center" });
rows[0].row.scrollIntoViewIfNeeded()
} else {
rows[0].row.scrollIntoView()
}
} }
counter () { counter () {
@ -146,7 +142,7 @@ export default class extends Controller {
this.reorder() this.reorder()
// Mantenemos el primero a la vista // Mantenemos el primero a la vista
rows[0].row.scrollIntoViewIfNeeded() rows[0].row.scrollIntoView({ block: "center" });
} }
bottom (event) { bottom (event) {
@ -167,7 +163,7 @@ export default class extends Controller {
this.reorder() this.reorder()
// Mantenemos el primero a la vista // Mantenemos el primero a la vista
rows[0].row.scrollIntoViewIfNeeded() rows[0].row.scrollIntoView({ block: "center" });
} }
/* /*

View file

@ -137,8 +137,10 @@ export function setupAuxiliaryToolbar(editor: Editor): void {
"click", "click",
(event) => { (event) => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files; const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
if (!files || !files.length) if (!files || !files.length) {
throw new Error("no hay archivos para subir"); console.info("no hay archivos para subir");
return;
}
const file = files[0]; const file = files[0];
const selectedEl = editor.contentEl.querySelector<HTMLElement>( const selectedEl = editor.contentEl.querySelector<HTMLElement>(

View file

@ -20,7 +20,6 @@ function makeParentBlock(
}; };
} }
// TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón // XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml // en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = { export const parentBlocks: { [propName: string]: EditorNode } = {

View file

@ -0,0 +1,60 @@
# frozen_string_literal: true
# Implementa rollups recursivos
module RecursiveRollup
extend ActiveSupport::Concern
included do
private
# Genera un rollup recursivo en base al período anterior y aplica una
# operación.
#
# @param :name [String]
# @param :interval_previous [String]
# @param :interval [String]
# @param :operation [Symbol]
# @param :dimensions [Hash]
# @param :beginning [Time]
# @return [Rollup]
def recursive_rollup(name:, interval_previous:, interval:, dimensions:, beginning:, operation: :sum)
Rollup.where(name: name, interval: interval_previous, dimensions: dimensions)
.where('time >= ?', beginning.try(:"beginning_of_#{interval}"))
.group(*dimensions_to_jsonb_query(dimensions))
.rollup(name, interval: interval, update: true) do |rollup|
rollup.try(operation, :value)
end
end
# Reducir las estadísticas calculadas aplicando un rollup sobre el
# intervalo más amplio.
#
# @param :name [String]
# @param :operation [Symbol]
# @param :dimensions [Hash]
# @return [nil]
def reduce_rollup(name:, dimensions:, operation: :sum)
Stat::INTERVALS.reduce do |previous, current|
recursive_rollup(name: name,
interval_previous: previous,
interval: current,
dimensions: dimensions,
beginning: beginning_of_interval,
operation: operation)
# Devolver el intervalo actual
current
end
nil
end
# @param :dimensions [Hash]
# @return [Array]
def dimensions_to_jsonb_query(dimensions)
dimensions.keys.map do |key|
"dimensions->'#{key}'"
end
end
end
end

View file

@ -52,4 +52,12 @@ class PeriodicJob < ApplicationJob
def beginning_of_interval def beginning_of_interval
@beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}") @beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}")
end end
def stop_file
@stop_file ||= Rails.root.join('tmp', self.class.to_s.tableize)
end
def stop?
File.exist? stop_file
end
end end

View file

@ -2,11 +2,15 @@
# Genera resúmenes de información para poder mostrar estadísticas y se # Genera resúmenes de información para poder mostrar estadísticas y se
# corre regularmente a sí misma. # corre regularmente a sí misma.
class StatCollectionJob < ApplicationJob class StatCollectionJob < PeriodicJob
include RecursiveRollup
STAT_NAME = 'stat_collection_job' STAT_NAME = 'stat_collection_job'
def perform(site_id:, once: true) def perform(site_id:, once: true)
@site = Site.find site_id @site = Site.find site_id
beginning = beginning_of_interval
stat = site.stats.create! name: STAT_NAME
scope.rollup('builds', **options) scope.rollup('builds', **options)
@ -18,44 +22,23 @@ class StatCollectionJob < ApplicationJob
rollup.average(:seconds) rollup.average(:seconds)
end end
# XXX: Es correcto promediar promedios? dimensions = { site_id: site_id }
Stat::INTERVALS.reduce do |previous, current|
rollup(name: 'builds', interval_previous: previous, interval: current)
rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average)
rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average)
current reduce_rollup(name: 'builds', operation: :sum, dimensions: dimensions)
end reduce_rollup(name: 'space_used', operation: :average, dimensions: dimensions)
reduce_rollup(name: 'build_time', operation: :average, dimensions: dimensions)
# Registrar que se hicieron todas las recolecciones
site.stats.create! name: STAT_NAME
stat.touch
run_again! unless once run_again! unless once
end end
private private
# Genera un rollup recursivo en base al período anterior y aplica una
# operación.
#
# @return [NilClass]
def rollup(name:, interval_previous:, interval:, operation: :sum)
Rollup.where(name: name, interval: interval_previous)
.where_dimensions(site_id: site.id)
.group("dimensions->'site_id'")
.rollup(name, interval: interval, update: true) do |rollup|
rollup.try(:operation, :value)
end
end
# Los registros a procesar # Los registros a procesar
# #
# @return [ActiveRecord::Relation] # @return [ActiveRecord::Relation]
def scope def scope
@scope ||= site.build_stats @scope ||= site.build_stats.jekyll.where('build_stats.created_at >= ?', beginning_of_interval).group(:site_id)
.jekyll
.where('created_at => ?', beginning_of_interval)
.group(:site_id)
end end
# Las opciones por defecto # Las opciones por defecto
@ -64,4 +47,8 @@ class StatCollectionJob < ApplicationJob
def options def options
@options ||= { interval: starting_interval, update: true } @options ||= { interval: starting_interval, update: true }
end end
def stat_name
STAT_NAME
end
end end

View file

@ -13,94 +13,160 @@
class UriCollectionJob < PeriodicJob class UriCollectionJob < PeriodicJob
# Ignoramos imágenes porque suelen ser demasiadas y no aportan a las # Ignoramos imágenes porque suelen ser demasiadas y no aportan a las
# estadísticas. # estadísticas.
IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze IMAGES = %w[.png .jpg .jpeg .gif .webp .jfif].freeze
STAT_NAME = 'uri_collection_job' STAT_NAME = 'uri_collection_job'
def perform(site_id:, once: true) def perform(site_id:, once: true)
@site = Site.find site_id @site = Site.find site_id
hostnames.each do |hostname| # Obtener el principio del intervalo anterior
uris.each do |uri| beginning_of_interval
return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop') # Recordar la última vez que se corrió la tarea
stat = site.stats.create! name: STAT_NAME
# Columnas a agrupar
columns = Stat::COLUMNS.zip([nil]).to_h
AccessLog.where(host: hostname, uri: uri) # Las URIs son la fuente de verdad de las visitas, porque son las
.where('created_at >= ?', beginning_of_interval) # que indican las páginas y recursos descargables, el resto son
.completed_requests # imágenes, CSS, JS y tipografías que no nos aportan números
.non_robots # significativos.
.group(:host, :uri) uri_dimensions = { host: site.hostnames, uri: uris }
.rollup('host|uri', interval: starting_interval, update: true) host_dimensions = { host: site.hostnames }
# Reducir las estadísticas calculadas aplicando un rollup sobre el # Recorremos todos los hostnames y uris posibles y luego agrupamos
# intervalo más amplio. # recursivamente para no tener que recalcular, asumiendo que es más
Stat::INTERVALS.reduce do |previous, current| # rápido buscar en los rollups indexados que en la tabla en bruto.
Rollup.where(name: 'host|uri', interval: previous) #
.where_dimensions(host: hostname, uri: uri) # Los referers solo se agrupan por host.
.group("dimensions->'host'", "dimensions->'uri'") columns.each_key do |column|
.rollup('host|uri', interval: current, update: true) do |rollup| columns[column] = AccessLog.where(**host_dimensions).distinct(column).pluck(column)
rollup.sum(:value)
end
# Devolver el intervalo actual
current
end
end
end end
# Recordar la última vez que se corrió la tarea # Cantidad de visitas por host
site.stats.create! name: STAT_NAME rollup(name: 'host', dimensions: host_dimensions, filter: uri_dimensions)
reduce_rollup(name: 'host', dimensions: host_dimensions, filter: uri_dimensions)
# Cantidad de visitas por página/recurso
rollup(name: 'host|uri', dimensions: uri_dimensions)
reduce_rollup(name: 'host|uri', dimensions: uri_dimensions)
# Cantidad de visitas host y parámetro
columns.each_pair do |column, values|
column_name = "host|#{column}"
column_dimensions = { host: site.hostnames }
column_dimensions[column] = values
rollup(name: column_name, dimensions: column_dimensions, filter: uri_dimensions)
reduce_rollup(name: column_name, dimensions: column_dimensions)
end
stat.touch
run_again! unless once run_again! unless once
end end
private private
# Generar un rollup de access logs
#
# @param :name [String]
# @param :beginning [Time]
# @param :dimensions [Hash]
# @param :filter [Hash]
# @return [nil]
def rollup(name:, dimensions:, interval: starting_interval, filter: nil)
AccessLog.where(**(filter || dimensions))
.where('created_at >= ?', beginning_of_interval)
.completed_requests
.non_robots
.group(*dimensions.keys)
.rollup(name, interval: interval, update: true)
end
# Generar rollups con el resto de la información
#
# @param :name [String]
# @param :dimensions [Hash]
# @param :filter [Hash]
# @return [nil]
def reduce_rollup(name:, dimensions:, filter: nil)
Stat::INTERVALS.reduce do |_previous, current|
rollup(name: name, dimensions: dimensions, filter: filter, interval: current)
current
end
nil
end
def stat_name def stat_name
STAT_NAME STAT_NAME
end end
# Obtiene todas las ubicaciones de archivos
#
# @return [String] # @return [String]
# #
# TODO: Cambiar al mergear origin-referer # TODO: Cambiar al mergear origin-referer
def destination def destinations
@destination ||= site.deploys.find_by(type: 'DeployLocal').destination @destinations ||= site.deploys.map(&:destination).compact.select do |d|
end File.directory?(d)
end.map do |d|
# TODO: Cambiar al mergear origin-referer File.realpath(d)
# end.uniq
# @return [Array]
def hostnames
@hostnames ||= site.deploys.map do |deploy|
case deploy
when DeployLocal
site.hostname
when DeployWww
deploy.fqdn
when DeployAlternativeDomain
deploy.hostname.dup.tap do |h|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
end
when DeployHiddenService
deploy.onion
end
end.compact
end end
# Recolecta todas las URIs menos imágenes # Recolecta todas las URIs menos imágenes
# #
# TODO: Para los sitios con DeployLocalizedDomain estamos buscando
# URIs de más.
#
# @return [Array] # @return [Array]
def uris def uris
@uris ||= Dir.chdir destination do @uris ||=
(Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p| destinations.map do |destination|
File.directory? p locales.map do |locale|
end.reject do |p| uri = "/#{locale}/".squeeze('/')
p = p.downcase dir = File.join(destination, locale)
IMAGES.any? do |i| next unless File.directory? dir
p.end_with? i
files(dir).map do |f|
uri + f
end
end end
end).map do |uri| end.flatten(3).compact
"/#{uri}" end
end
# @return [Array]
def locales
@locales ||= ['', site.locales.map(&:to_s)].flatten(1)
end
# @param :dir [String]
# @return [Array]
def files(dir)
Dir.chdir(dir) do
pages = Dir.glob('**/*.html')
files = Dir.glob('public/**/*')
files = remove_directories files
files = remove_images files
[pages, files].flatten(1)
end
end
# @param :files [Array]
# @return [Array]
def remove_directories(files)
files.reject do |f|
File.directory? f
end
end
def remove_images(files)
files.reject do |f|
IMAGES.include? File.extname(f).downcase
end end
end end
end end

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

@ -23,6 +23,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

View file

@ -28,21 +28,34 @@ class DeployLocal < Deploy
# 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
paths = [destination, File.join(destination, '**', '**')] @size ||= begin
paths = [destination, File.join(destination, '**', '**')]
Dir.glob(paths).map do |file| Dir.glob(paths).map do |file|
if File.symlink? file if File.symlink? file
0 0
else else
File.size(file) File.size(file)
end end
end.inject(:+) end.inject(:+)
end
end end
def destination def destination
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
@ -91,11 +104,7 @@ class DeployLocal < Deploy
end end
def bundle def bundle
if Rails.env.production? run %(bundle install --no-cache --path="#{gems_dir}")
run %(bundle install --no-cache --path="#{gems_dir}")
else
run %(bundle install)
end
end end
def jekyll_build def jekyll_build

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

100
app/models/deploy_rsync.rb Normal file
View file

@ -0,0 +1,100 @@
# frozen_string_literal: true
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
# remoto tiene que tener rsync instalado.
class DeployRsync < Deploy
store :values, accessors: %i[destination host_keys], coder: JSON
def deploy
ssh? && rsync
end
# El espacio remoto es el mismo que el local
#
# @return [Integer]
def size
deploy_local.size
end
# Devolver el destino o lanzar un error si no está configurado
def destination
values[:destination].tap do |d|
raise(ArgumentError, 'destination no está configurado') if d.blank?
end
end
private
# Verificar la conexión SSH implementando Trust On First Use
#
# TODO: Medir el tiempo que tarda en iniciarse la conexión
#
# @return [Boolean]
def ssh?
user, host = user_host
ssh_available = false
Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh|
if values[:host_keys].blank?
# Guardar las llaves que se encontraron en la primera conexión
values[:host_keys] = ssh.transport.host_keys.map do |host_key|
"#{host_key.ssh_type} #{host_key.fingerprint}"
end
ssh_available = save
else
ssh_available = true
end
end
ssh_available
rescue Exception => e
ExceptionNotifier.notify_exception(e, data: { site: site.id, hostname: host, user: user })
false
end
def env
{
'HOME' => home_dir,
'PATH' => '/usr/bin',
'LANG' => ENV['LANG']
}
end
# Confiar en la primera llave que encontremos, fallar si cambian
#
# @return [Symbol]
def tofu
values[:host_keys].present? ? :always : :accept_new
end
# Devuelve el par user host
#
# @return [Array]
def user_host
destination.split(':', 2).first.split('@', 2).tap do |d|
next unless d.size == 1
d.insert(0, nil)
end
end
# Sincroniza hacia el directorio remoto
#
# @return [Boolean]
def rsync
run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/)
end
# El origen es el destino de la compilación
#
# @return [String]
def source
deploy_local.destination
end
def deploy_local
@deploy_local ||= site.deploys.find_by(type: 'DeployLocal')
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,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

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

@ -90,6 +90,10 @@ 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
@ -108,6 +112,10 @@ 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

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

@ -1,360 +0,0 @@
# frozen_string_literal: true
# Representa los distintos tipos de campos que pueden venir de una
# plantilla compleja
class Post
class TemplateField
attr_reader :post, :contents, :key
STRING_VALUES = %w[string text url number email password date year
image video audio document].freeze
# Tipo de valores que son archivos
FILE_TYPES = %w[image video audio document].freeze
def initialize(post, key, contents)
@post = post
@key = key
@contents = contents
end
def title
contents.dig('title') if complex?
end
def subtitle
contents.dig('subtitle') if complex?
end
# Obtiene el valor
def value
complex? ? contents.dig('value') : contents
end
def max
return 0 if simple?
contents.fetch('max', 0)
end
def min
return 0 if simple?
contents.fetch('min', 0)
end
# TODO: volver elegante!
def type
return @type if @type
if image?
@type = 'image'
elsif email?
@type = 'email'
elsif url?
@type = 'url'
elsif number?
@type = 'number'
elsif password?
@type = 'password'
elsif date?
@type = 'date'
elsif year?
@type = 'year'
elsif text_area?
@type = 'text_area'
elsif check_box_group?
@type = 'check_box_group'
elsif radio_group?
@type = 'radio_group'
elsif string?
@type = 'text'
# TODO: volver a hacer funcionar esto y ahorranos los multiple:
# false
elsif string? && contents.split('/', 2).count == 2
@type = 'select'
elsif nested?
@type = 'table'
elsif array?
@type = 'select'
elsif boolean?
@type = 'check_box'
end
@type
end
# Devuelve los valores vacíos según el tipo
def empty_value
if string?
''
elsif nested?
# TODO: devolver las keys también
{}
elsif array?
[]
elsif boolean?
false
end
end
def cols
complex? && contents.dig('cols')
end
def align
complex? && contents.dig('align')
end
# El campo es requerido si es complejo y se especifica que lo sea
def required?
complex? && contents.dig('required')
end
def boolean?
value.is_a?(FalseClass) || value.is_a?(TrueClass)
end
def string?
value.is_a? String
end
def text_area?
value == 'text'
end
def url?
value == 'url'
end
def email?
value == 'email' || value == 'mail'
end
alias mail? email?
def date?
value == 'date'
end
def password?
value == 'password'
end
def number?
value == 'number'
end
def year?
value == 'year'
end
def file?
string? && FILE_TYPES.include?(value)
end
def image?
array? ? value.first == 'image' : value == 'image'
end
# Si la plantilla es simple no está admitiendo Hashes como valores
def simple?
!complex?
end
def complex?
contents.is_a? Hash
end
# XXX Retrocompatibilidad
def to_s
key
end
# Convierte el campo en un parámetro
def to_param
if nested?
{ key.to_sym => {} }
elsif array? && multiple?
{ key.to_sym => [] }
else
key.to_sym
end
end
# Convierte la plantilla en el formato de front_matter
def to_front_matter
{ key => empty_value }
end
def check_box_group?
array? && (complex? && contents.fetch('checkbox', false))
end
def radio_group?
array? && (complex? && contents.fetch('radio', false))
end
def array?
value.is_a? Array
end
# TODO: detectar cuando es complejo y tomar el valor de :multiple
def multiple?
# si la plantilla es simple, es multiple cuando tenemos un array
return array? if simple?
array? && contents.fetch('multiple', true)
end
# Detecta si el valor es una tabla de campos
def nested?
value.is_a?(Hash) || (array? && value.first.is_a?(Hash))
end
# Un campo acepta valores abiertos si no es un array con múltiples
# elementos
def open?
# Todos los valores simples son abiertos
return true unless complex?
return false unless array?
# La cosa se complejiza cuando tenemos valores complejos
#
# Si tenemos una lista cerrada de valores, necesitamos saber si el
# campo es abierto o cerrado. Si la lista tiene varios elementos,
# es una lista cerrada, opcionalmente abierta. Si la lista tiene
# un elemento, quiere decir que estamos autocompletando desde otro
# lado.
contents.fetch('open', value.count < 2)
end
def closed?
!open?
end
# Determina si los valores del campo serán públicos después
#
# XXX Esto es solo una indicación, el theme Jekyll tiene que
# respetarlos por su lado luego
def public?
# Todos los campos son públicos a menos que se indique lo
# contrario
simple? || contents.fetch('public', true)
end
def private?
!public?
end
def human
h = key.humanize
h
end
def label
h = (complex? && contents.dig('label')) || human
h += ' *' if required?
h
end
def help
complex? && contents.dig('help')
end
def nested_fields
return unless nested?
v = value
v = value.first if array?
@nested_fields ||= v.map do |k, sv|
Post::TemplateField.new post, k, sv
end
end
# Obtiene los valores posibles para el campo de la plantilla
def values
return 'false' if value == false
return 'true' if value == true
# XXX por alguna razón `value` no refiere a value() :/
return '' if STRING_VALUES.include? value
# Las listas cerradas no necesitan mayor procesamiento
return value if array? && closed? && value.count > 1
# Y las vacías tampoco
return value if array? && value.empty?
# Ahorrarnos el trabajo
return @values if @values
# Duplicar el valor para no tener efectos secundarios luego (?)
value = self.value.dup
# Para obtener los valores posibles, hay que procesar la string y
# convertirla a parametros
# Si es una array de un solo elemento, es un indicador de que
# tenemos que rellenarla con los valores que indica.
#
# El primer valor es el que trae la string de autocompletado
values = array? ? value.shift : value
# Si el valor es un array con más de un elemento, queremos usar
# esas opciones. Pero si además es abierto, queremos traer los
# valores cargados anteriormente.
# Procesamos el valor, buscando : como separador de campos que
# queremos encontrar y luego los unimos
_value = (values&.split(':', 2) || []).map do |v|
# Tenemos hasta tres niveles de búsqueda
collection, attr, subattr = v.split('/', 3)
if collection == 'site'
# TODO: puede ser peligroso permitir acceder a cualquier
# atributo de site? No estamos trayendo nada fuera de
# lo normal
post.site.send(attr.to_sym)
# Si hay un subatributo, tenemos que averiguar todos los
# valores dentro de el
# TODO volver elegante!
# TODO volver recursivo!
elsif subattr
post.site.everything_of(attr, lang: collection)
.compact
.map { |sv| sv[subattr] }
.flatten
.compact
.uniq
else
post.site.everything_of(attr, lang: collection).compact
end
end
# Si el valor es abierto, sumar los valores auto-completados a
# lo pre-cargados.
#
# En este punto _value es un array de 1 o 2 arrays, si es de uno,
# value tambien tiene que serlo. Si es de 2, hay que unir cada
# una
if open?
if _value.count == 1
_value = [(_value.first + value).uniq]
elsif _value.count == 2
_value = _value.each_with_index.map do |v, i|
v + value.fetch(i, [])
end
end
end
# Crea un array de arrays, útil para los select
# [ [ 1, a ], [ 2, b ] ]
# aunque si no hay un : en el autocompletado, el array queda
# [ [ 1, 1 ], [ 2, 2 ] ]
values = _value.empty? ? [] : _value.last.zip(_value.first)
# En última instancia, traer el valor por defecto y ahorrarnos
# volver a procesar
@values = values
end
end
end

View file

@ -54,10 +54,6 @@ class Site < ApplicationRecord
before_create :clone_skel! before_create :clone_skel!
# Elimina el directorio al destruir un sitio # Elimina el directorio al destruir un sitio
before_destroy :remove_directories! before_destroy :remove_directories!
# Carga el sitio Jekyll una vez que se inicializa el modelo o después
# de crearlo
after_initialize :load_jekyll
after_create :load_jekyll
# Cambiar el nombre del directorio # Cambiar el nombre del directorio
before_update :update_name! before_update :update_name!
before_save :add_private_key_if_missing! before_save :add_private_key_if_missing!
@ -101,6 +97,26 @@ class Site < ApplicationRecord
"https://#{hostname}#{slash ? '/' : ''}" "https://#{hostname}#{slash ? '/' : ''}"
end end
# TODO: Cambiar al mergear origin-referer
#
# @return [Array]
def hostnames
@hostnames ||= deploys.map do |deploy|
case deploy
when DeployLocal
hostname
when DeployWww
deploy.fqdn
when DeployAlternativeDomain
deploy.hostname.dup.tap do |h|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
end
when DeployHiddenService
deploy.onion
end
end.compact
end
# Obtiene los dominios alternativos # Obtiene los dominios alternativos
# #
# @return Array # @return Array
@ -123,7 +139,9 @@ class Site < ApplicationRecord
# #
# @return Array # @return Array
def urls(slash: true) def urls(slash: true)
alternative_urls(slash: slash) << url(slash: slash) @urls ||= hostnames.map do |h|
"https://#{h}#{slash ? '/' : ''}"
end
end end
def invitade?(usuarie) def invitade?(usuarie)
@ -161,13 +179,25 @@ 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]
def i18n def i18n
data[I18n.locale.to_s] data[I18n.locale.to_s] || {}
end end
# Devuelve el idioma por defecto del sitio, el primero de la lista. # Devuelve el idioma por defecto del sitio, el primero de la lista.
@ -331,10 +361,19 @@ class Site < ApplicationRecord
status == 'building' status == 'building'
end end
def jekyll?
File.directory? path
end
def jekyll def jekyll
run_in_path do @jekyll ||=
@jekyll ||= Jekyll::Site.new(configuration) begin
end install_gems
Jekyll::Site.new(configuration).tap do |site|
site.reader = JekyllData::Reader.new(site) if site.theme
end
end
end end
# Cargar el sitio Jekyll # Cargar el sitio Jekyll
@ -380,9 +419,6 @@ class Site < ApplicationRecord
@configuration[unneeded] = [] if @configuration.key? unneeded @configuration[unneeded] = [] if @configuration.key? unneeded
end end
# Eliminar el theme si no es una gema válida
@configuration.delete('theme') unless theme_available?
# Si estamos usando nuestro propio plugin de i18n, los posts están # Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones" # en "colecciones"
locales.map(&:to_s).each do |i| locales.map(&:to_s).each do |i|
@ -392,20 +428,6 @@ class Site < ApplicationRecord
@configuration @configuration
end end
# Lista los nombres de las plantillas disponibles como gemas,
# tomándolas dinámicamente de las que agreguemos en el grupo :themes
# del Gemfile.
def available_themes
@available_themes ||= Bundler.load.current_dependencies.select do |gem|
gem.groups.include? :themes
end.map(&:name)
end
# Detecta si el tema actual es una gema
def theme_available?
available_themes.include? design&.gem
end
# Devuelve el dominio actual # Devuelve el dominio actual
def self.domain def self.domain
ENV.fetch('SUTTY', 'sutty.nl') ENV.fetch('SUTTY', 'sutty.nl')
@ -444,7 +466,7 @@ class Site < ApplicationRecord
# Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada # Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
# si el sitio ya existe # si el sitio ya existe
def clone_skel! def clone_skel!
return if File.directory? path return if jekyll?
Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
end end
@ -470,8 +492,9 @@ class Site < ApplicationRecord
config.theme = design.gem unless design.no_theme? config.theme = design.gem unless design.no_theme?
config.description = description config.description = description
config.title = title config.title = title
config.url = url 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
@ -527,4 +550,11 @@ class Site < ApplicationRecord
def run_in_path(&block) def run_in_path(&block)
Dir.chdir path, &block Dir.chdir path, &block
end end
def install_gems
return unless persisted?
return if Rails.root.join('_storage', 'gems', name).directory?
deploys.find_by_type('DeployLocal').send(:bundle)
end
end end

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

@ -0,0 +1,3 @@
# frozen_string_literal: true
SiteBlazer = Struct.new(:site)

View file

@ -3,8 +3,16 @@
# Registran cuándo fue la última recolección de datos. # Registran cuándo fue la última recolección de datos.
class Stat < ApplicationRecord class Stat < ApplicationRecord
# XXX: Los intervalos van en orden de mayor especificidad a menor # XXX: Los intervalos van en orden de mayor especificidad a menor
INTERVALS = %i[day month year].freeze INTERVALS = %i[day].freeze
RESOURCES = %i[builds space_used build_time].freeze RESOURCES = %i[builds space_used build_time].freeze
COLUMNS = %i[http_referer geoip2_data_country_name].freeze
belongs_to :site belongs_to :site
# El intervalo por defecto
#
# @return [Symbol]
def self.default_interval
INTERVALS.first
end
end end

View file

@ -9,8 +9,12 @@ 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_queries, foreign_key: 'creator_id', class_name: 'Blazer::Query'
def name def name
email.split('@', 2).first email.split('@', 2).first
@ -36,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,10 @@
# frozen_string_literal: true
# Les invitades no pueden ver las estadísticas (aun)
SiteBlazerPolicy = Struct.new(:usuarie, :site_blazer) do
def home?
site_blazer&.site&.usuarie? usuarie
end
alias_method :show?, :home?
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,14 +3,27 @@
# 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
self.site = Site.new params self.site = Site.new params
add_role temporal: false, rol: 'usuarie' add_role temporal: false, rol: 'usuarie'
sync_nodes
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
I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.save && site.save &&
site.config.write && site.config.write &&
commit_config(action: :create) commit_config(action: :create)
@ -18,6 +31,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias add_licencias
deploy
site site
end end
@ -144,4 +159,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
PostService.new(site: site, usuarie: usuarie, post: post, PostService.new(site: site, usuarie: usuarie, post: post,
params: params).update params: params).update
end end
# Crea los deploys necesarios para sincronizar a otros nodos de Sutty
def sync_nodes
Rails.application.nodes.each do |node|
site.deploys.build(type: 'DeployRsync', destination: "sutty@#{node}:#{site.hostname}")
end
end
end end

View file

@ -0,0 +1,5 @@
%ul
- @checks.each do |check|
%li
= check.query.name
= check.state

View file

@ -0,0 +1,30 @@
!!!
%html
%head
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
%body{:style => "font-family: 'Helvetica Neue', Arial, Helvetica; font-size: 14px; color: #333;"}
- if @error
%p= @error
- elsif @rows_count > 0 && @check_type == "bad_data"
%p
- if @rows_count <= 10
= pluralize(@rows_count, "row")
- else
Showing 10 of #{@rows_count} rows
%table{:style => "width: 100%; border-spacing: 0; border-collapse: collapse;"}
%thead
%tr
- @columns.first(5).each do |column|
%th{:style => "padding: 8px; line-height: 1.4; text-align: left; vertical-align: bottom; border-bottom: 2px solid #ddd; width: #{(100 / @columns.size).round(2)}%;"}
= column
%tbody
- @rows.first(10).each do |row|
%tr
- @columns.first(5).each_with_index do |column, i|
%td{:style => "padding: 8px; line-height: 1.4; vertical-align: top; border-top: 1px solid #ddd;"}
- value = row[i]
- if @column_types[i] == "time" && value.to_s.length > 10
- value = Time.parse(value).in_time_zone(Blazer.time_zone) rescue value
= value
- if @columns.size > 5
%p{:style => "color: #999;"} Only first 5 columns shown

View file

@ -0,0 +1,9 @@
#queries
%table.table
%tbody.list
- @queries.each do |query|
%tr
-#
Por alguna razón no tenemos acceso a query_path para poder
generar la URL según Rails
%td= link_to query[:name], "/sites/#{params[:site_id]}/stats/queries/#{query.to_param}"

View file

@ -0,0 +1,51 @@
- blazer_title @query.name
.container
.row
.col-12
%h1= @query.name
- if @query.description.present?
%p.lead= @query.description
- unless @result.chart_type.blank?
.col-12
- case @result.chart_type
- when 'line'
= line_chart @chart_data, **@chart_options
- when 'line2'
= line_chart @chart_data, **@chart_options
- when 'pie'
= pie_chart @chart_data, **@chart_options
- when 'bar'
= column_chart @chart_data, **@chart_options
- when 'bar2'
= column_chart @chart_data, **@chart_options
- when 'scatter'
= scatter_chart @chart_data, **@chart_options
.col-12
%table.table
%thead
%tr
- @result.columns.each do |key|
- next if key.include? 'ciphertext'
- next if key.include? 'encrypted'
%th.position-sticky.background-white{ style: 'top: 0' }= t("blazer.columns.#{key}", default: key.titleize)
%tbody
- @result.rows.each do |row|
%tr
- row.each_with_index do |v, i|
- k = @result.columns[i]
- next if k.include? 'ciphertext'
- next if k.include? 'encrypted'
%td
- if v.is_a?(Time)
- v = blazer_time_value(@data_source, k, v)
- unless v.nil?
- if v.is_a?(String) && v.empty?
%span.text-muted= t('.empty')
- elsif @data_source.linked_columns[k]
= link_to blazer_format_value(k, v), @data_source.linked_columns[k].gsub('{value}', u(v.to_s)), target: '_blank'
- else
= blazer_format_value(k, v)
- if (v2 = (@result.boom[k] || {})[v.nil? ? v : v.to_s])
%span.text-muted= v2

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

@ -9,7 +9,7 @@
%p= site.description %p= site.description
%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

@ -9,7 +9,7 @@
\ \
= site.description = 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 - 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

@ -12,7 +12,7 @@
- else - else
%span.line-clamp-1= link_to crumb.name, crumb.url %span.line-clamp-1= link_to crumb.name, crumb.url
- if current_usuarie - if @current_usuarie || current_usuarie
%ul.navbar-nav %ul.navbar-nav
- if @site&.tienda? - if @site&.tienda?
%li.nav-item %li.nav-item
@ -20,5 +20,9 @@
role: 'button', class: 'btn' role: 'button', class: 'btn'
%li.nav-item %li.nav-item
= link_to t('.logout'), 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,14 @@
!!!
%html
%head
%meta{content: 'text/html; charset=UTF-8', 'http-equiv': 'Content-Type'}/
%title= blazer_title ? blazer_title : 'Sutty'
%meta{charset: 'utf-8'}/
= favicon_link_tag 'blazer/favicon.png'
= stylesheet_link_tag 'application'
= javascript_pack_tag 'blazer', 'data-turbolinks-track': 'reload'
= csrf_meta_tags
%body{ class: yield(:body) }
.container-fluid#sutty
= render 'layouts/breadcrumb'
= yield

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,7 +1,5 @@
.form-group .form-group
- if metadata.static_file - if metadata.static_file
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
- case metadata.static_file.blob.content_type - case metadata.static_file.blob.content_type
- when %r{\Avideo/} - when %r{\Avideo/}
= video_tag url_for(metadata.static_file), = video_tag url_for(metadata.static_file),
@ -14,13 +12,17 @@
- else - else
= link_to t('posts.attribute_ro.file.download'), = link_to t('posts.attribute_ro.file.download'),
url_for(metadata.static_file) url_for(metadata.static_file)
.custom-control.custom-switch -# Mantener el valor si no enviamos ninguna imagen
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input' = hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
= label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label' -# Los archivos requeridos solo se pueden reemplazar
- unless metadata.required
.custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input'
= label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label'
.custom-file .custom-file
= file_field(*field_name_for(base, attribute, :path), = file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata), **field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}", class: "custom-file-input #{invalid(post, attribute)}",
data: { preview: "#{attribute}-preview" }) data: { preview: "#{attribute}-preview" })
= label_tag "#{base}_#{attribute}_path", = label_tag "#{base}_#{attribute}_path",
@ -30,7 +32,7 @@
.form-group .form-group
= label_tag "#{base}_#{attribute}_description", = label_tag "#{base}_#{attribute}_description",
post_label_t(attribute, :description, post: post) post_label_t(attribute, :description, post: post, required: false)
= text_field(*field_name_for(base, attribute, :description), = text_field(*field_name_for(base, attribute, :description),
value: metadata.value['description'], value: metadata.value['description'],
dir: dir, lang: locale, dir: dir, lang: locale,

View file

@ -1,5 +1,5 @@
.form-group .form-group
- if metadata.uploaded? - if metadata.static_file
= image_tag url_for(metadata.static_file), = image_tag url_for(metadata.static_file),
alt: metadata.value['description'], alt: metadata.value['description'],
class: 'img-fluid', class: 'img-fluid',
@ -37,4 +37,3 @@
**field_options(attribute, metadata, required: false)) **field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata post: post, attribute: [attribute, :description], metadata: metadata

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

@ -15,6 +15,9 @@
- else - else
%td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm' %td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
- if policy(@site_stat).index?
= link_to t('stats.index.title'), site_stats_path(@site), class: 'btn'
- if policy(@site).edit? - if policy(@site).edit?
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn' = link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn'
@ -71,8 +74,8 @@
%table.table{ data: { controller: 'reorder' } } %table.table{ data: { controller: 'reorder' } }
%caption.sr-only= t('posts.caption') %caption.sr-only= t('posts.caption')
%thead %thead
%tr %tr.sticky-top
%th.border-0.background-white.position-sticky{ style: 'top: 0; z-index: 2', colspan: '4' } %th.border-0{ colspan: '4' }
.d-flex.flex-row.justify-content-between .d-flex.flex-row.justify-content-between
%div %div
= submit_tag t('posts.reorder.submit'), class: 'btn' = submit_tag t('posts.reorder.submit'), class: 'btn'
@ -93,15 +96,15 @@
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

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

@ -14,7 +14,7 @@
%table.table.table-condensed %table.table.table-condensed
%tbody %tbody
- @sites.each do |site| - @sites.each do |site|
- next unless site.jekyll - next unless site.jekyll?
- rol = current_usuarie.rol_for_site(site) - rol = current_usuarie.rol_for_site(site)
-# -#
TODO: Solo les usuaries cachean porque tenemos que separar TODO: Solo les usuaries cachean porque tenemos que separar

View file

@ -6,32 +6,57 @@
%p %p
%small %small
= t('.last_update') = t('.last_update')
%time{ datetime: @last_stat.created_at } %time{ datetime: @last_stat.updated_at }
#{time_ago_in_words @last_stat.created_at}. #{time_ago_in_words @last_stat.updated_at}.
.mb-5 %form.mb-5.form-inline{ method: 'get' }
- Stat::INTERVALS.each do |interval| - Stat::INTERVALS.each do |interval|
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}" = link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls], period_start: params[:period_start].to_date.try(:"beginning_of_#{interval}").to_date, period_end: params[:period_end]), class: "mb-0 btn #{'btn-primary active' if @interval == interval}"
%input.form-control{ type: 'date', name: :period_start, value: params[:period_start] }
%input.form-control{ type: 'date', name: :period_end, value: params[:period_end] }
%button.btn.mb-0{ type: 'submit' }= t('.filter')
.mb-5 .mb-5
%h2= t('.host.title', count: @hostnames.size) %h2= t('.host.title', count: @hostnames.size)
%p.lead= t('.host.description') %p.lead= t('.host.description')
= line_chart site_stats_host_path(@chart_params), **@chart_options = line_chart site_stats_host_path(@chart_params), **@chart_options
.mb-5 #custom-urls.mb-5
%h2= t('.urls.title') %h2= t('.urls.title')
%p.lead= t('.urls.description') %p.lead= t('.urls.description')
%form %form{ method: 'get', action: '#custom-urls' }
%input{ type: 'hidden', name: 'interval', value: @interval } %input{ type: 'hidden', name: 'interval', value: @interval }
.form-group .form-group
%label{ for: 'urls' }= t('.urls.label') %label{ for: 'urls' }= t('.urls.label')
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size, aria_describedby: 'help-urls' }= @normalized_urls.join("\n") %textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size + 1, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
%small#help-urls.feedback.form-text.text-muted= t('.urls.help') %small#help-urls.feedback.form-text.text-muted= t('.urls.help')
.form-group .form-group
%button.btn{ type: 'submit' }= t('.urls.submit') %button.btn{ type: 'submit' }= t('.urls.submit')
- if @normalized_urls.present? - if @normalized_urls.present?
= line_chart site_stats_uris_path(urls: params[:urls], **@chart_params), **@chart_options = line_chart site_stats_uris_path(urls: @normalized_urls, **@chart_params), **@chart_options
.row.mb-5.row-cols-1.row-cols-md-2
- @columns.each_pair do |column, values|
- next if values.blank?
.col.mb-5
%h2= t(".columns.#{column}.title")
%p.lead= t(".columns.#{column}.description")
%table.table
%colgroup
%col
%col
%thead
%tr.sticky-top
%th{ scope: 'col' }= t(".columns.#{column}.column")
%th{ scope: 'col' }= t('.columns.visits')
%tfoot
%tbody
- values.each_pair do |col, val|
%tr
%th{ scope: 'row', style: 'word-break: break-all' }= col.blank? ? t(".columns.#{column}.empty") : col
%td= val
.mb-5 .mb-5
%h2= t('.resources.title') %h2= t('.resources.title')
%p.lead= t('.resources.description') %p.lead= t('.resources.description')

15
bin/access_logs Executable file
View file

@ -0,0 +1,15 @@
#!/bin/sh
set -e
# Volcar y eliminar todos los access logs de dos días atrás
date="`dateadd today -1d`"
file="/srv/http/_storage/${date}.psql.gz"
test -n "${date}"
test ! -f "${file}"
psql -h postgresql "${DATABASE:-sutty}" sutty <<SQL | gzip > "${file}"
begin;
copy (select * from access_logs where created_at < '${date}') to stdout;
delete from access_logs where created_at < '${date}';
commit;
SQL

View file

@ -39,6 +39,7 @@ module Sutty
config.active_storage.variant_processor = :vips config.active_storage.variant_processor = :vips
config.to_prepare do config.to_prepare do
# Load application's model / class decorators
Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c| Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c|
Rails.configuration.cache_classes ? require(c) : load(c) Rails.configuration.cache_classes ? require(c) : load(c)
end end
@ -55,5 +56,9 @@ module Sutty
EmailAddress::Config.error_messages translations.transform_keys(&:to_s), locale.to_s EmailAddress::Config.error_messages translations.transform_keys(&:to_s), locale.to_s
end end
end end
def nodes
@nodes ||= ENV.fetch('SUTTY_NODES', '').split(',')
end
end end
end end

View file

@ -50,7 +50,7 @@ user_method: current_usuarie
user_name: email user_name: email
# custom before_action to use for auth # custom before_action to use for auth
# before_action_method: require_admin before_action_method: require_usuarie
# email to send checks from # email to send checks from
from_email: blazer@<%= ENV.fetch('SUTTY', 'sutty.nl') %> from_email: blazer@<%= ENV.fetch('SUTTY', 'sutty.nl') %>

View file

@ -37,6 +37,13 @@ end
# #
# TODO: Aplicar monkey patches en otro lado... # TODO: Aplicar monkey patches en otro lado...
module Jekyll module Jekyll
Site.class_eval do
def configure_theme
self.theme = nil
self.theme = Jekyll::Theme.new(config['theme'], self) unless config['theme'].nil?
end
end
Reader.class_eval do Reader.class_eval do
# No necesitamos otros posts # No necesitamos otros posts
def retrieve_posts(_); end def retrieve_posts(_); end
@ -69,6 +76,46 @@ module Jekyll
end end
end end
Theme.class_eval do
attr_reader :site
def initialize(name, site)
@name = name.downcase.strip
@site = site
end
def root
@root ||= begin
lockfile = Bundler::LockfileParser.new(File.read(site.in_source_dir('Gemfile.lock')))
spec = lockfile.specs.find do |spec|
spec.name == name
end
ruby_version = Gem::Version.new(RUBY_VERSION)
ruby_version.canonical_segments[2] = 0
base_path = Rails.root.join('_storage', 'gems', File.basename(site.source), 'ruby',
ruby_version.canonical_segments.join('.'))
File.realpath(
case spec.source
when Bundler::Source::Git
File.join(base_path, 'bundler', 'gems', spec.source.extension_dir_name)
when Bundler::Source::Rubygems
File.join(base_path, 'gems', spec.full_name)
end
)
end
end
def runtime_dependencies
[]
end
private
def gemspec; end
end
# No necesitamos los archivos de la plantilla # No necesitamos los archivos de la plantilla
ThemeAssetsReader.class_eval do ThemeAssetsReader.class_eval do
def read; end def read; end

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
@ -106,6 +115,14 @@ en:
title: Reindex title: Reindex
success: Success! success: Success!
error: Error error: Error
deploy_localized_domain:
title: Domain name by language
success: Success!
error: Error
deploy_rsync:
title: Synchronize to backup server
success: Success!
error: Error
help: You can contact us by replying to this e-mail help: You can contact us by replying to this e-mail
maintenance_mailer: maintenance_mailer:
notice: notice:
@ -258,7 +275,7 @@ en:
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.
last_update: 'Updated every hour. Last update on ' last_update: 'Updated daily. Last update on '
empty: 'There is no enough information yet. We invite you to come back in %{please_return_at}!' empty: 'There is no enough information yet. We invite you to come back in %{please_return_at}!'
loading: 'Loading...' loading: 'Loading...'
hour: 'Hourly' hour: 'Hourly'
@ -289,7 +306,19 @@ en:
description: 'Average storage space used by your site.' description: 'Average storage space used by your site.'
build_time: build_time:
title: 'Publication time' title: 'Publication time'
description: 'Average time your site takes to build.' description: 'Average time your site takes to build, from pressing "Publish changes" to actually being available on your site.'
columns:
visits: "Visits"
http_referer:
title: "Referers"
description: "Visits by origin"
column: "Referer"
empty: "(direct visit)"
geoip2_data_country_name:
title: "Countries"
description: "Visits by country"
column: "Country"
empty: "(couldn't detect country)"
sites: sites:
donations: donations:
url: 'https://donaciones.sutty.nl/en/' url: 'https://donaciones.sutty.nl/en/'
@ -418,6 +447,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:
@ -479,7 +510,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:
@ -616,3 +647,14 @@ en:
edit: 'Editing' edit: 'Editing'
usuaries: usuaries:
index: 'Users' index: 'Users'
stats:
index: 'Statistics'
blazer:
columns:
total: 'Total'
dia: 'Date'
date: 'Date'
visitas: 'Visits'
queries:
show:
empty: '(empty)'

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
@ -106,6 +115,14 @@ es:
title: Reindexación title: Reindexación
success: ¡Éxito! success: ¡Éxito!
error: Hubo un error 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!
error: Hubo un error
help: Por cualquier duda, responde este correo para contactarte con nosotres. help: Por cualquier duda, responde este correo para contactarte con nosotres.
maintenance_mailer: maintenance_mailer:
notice: notice:
@ -263,7 +280,7 @@ es:
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.
last_update: 'Actualizadas cada hora. Última actualización hace ' last_update: 'Actualizadas diariamente. Última actualización hace '
empty: 'Todavía no hay información suficiente. Te invitamos a volver en %{please_return_at} :)' empty: 'Todavía no hay información suficiente. Te invitamos a volver en %{please_return_at} :)'
loading: 'Cargando...' loading: 'Cargando...'
hour: 'Por hora' hour: 'Por hora'
@ -294,7 +311,19 @@ es:
description: 'Espacio en disco que ocupa en promedio tu sitio.' description: 'Espacio en disco que ocupa en promedio tu sitio.'
build_time: build_time:
title: 'Tiempo de publicación' title: 'Tiempo de publicación'
description: 'Tiempo promedio que toma en publicarse tu sitio.' description: 'Tiempo que tarda el sitio en generarse, desde que usas el botón "Publicar cambios" hasta que los puedes ver en el sitio'
columns:
visits: "Visitas"
http_referer:
title: "Referencias"
description: "Orígenes de las visitas"
column: "Referencia"
empty: "(visita directa)"
geoip2_data_country_name:
title: "Países"
description: "Cantidad de visitas por país"
column: "País"
empty: "(no se pudo detectar el país)"
sites: sites:
donations: donations:
url: 'https://donaciones.sutty.nl/' url: 'https://donaciones.sutty.nl/'
@ -426,6 +455,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:
@ -487,7 +518,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:
@ -624,3 +655,14 @@ es:
edit: 'Editando' edit: 'Editando'
usuaries: usuaries:
index: 'Usuaries' index: 'Usuaries'
stats:
index: 'Estadísticas'
blazer:
columns:
total: 'Total'
dia: 'Fecha'
date: 'Fecha'
visitas: 'Visitas'
queries:
show:
empty: '(vacío)'

View file

@ -4,8 +4,6 @@ Rails.application.routes.draw do
devise_for :usuaries devise_for :usuaries
get '/.well-known/change-password', to: redirect('/usuaries/edit') get '/.well-known/change-password', to: redirect('/usuaries/edit')
mount Blazer::Engine, at: 'blazer'
root 'application#index' root 'application#index'
constraints(Constraints::ApiSubdomain.new) do constraints(Constraints::ApiSubdomain.new) do

View file

@ -3,8 +3,6 @@
# Blazer # Blazer
class InstallBlazer < ActiveRecord::Migration[6.0] class InstallBlazer < ActiveRecord::Migration[6.0]
def change def change
return unless Rails.env.production?
create_table :blazer_queries do |t| create_table :blazer_queries do |t|
t.references :creator t.references :creator
t.string :name t.string :name

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# La recolección de estadísticas podría pertenecer a un sitio
class AddSiteToStats < ActiveRecord::Migration[6.1]
def change
add_belongs_to :stats, :site, index: true, null: true
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
# Agregarle un nombre a la estadística
class AddNameToStats < ActiveRecord::Migration[6.1]
def change
add_column :stats, :name, :string, null: false
add_index :stats, :name, using: 'hash'
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Agrega un DeployRsync hacia los servidores alternativos para cada
# sitio
class AddDeployRsyncToSites < ActiveRecord::Migration[6.1]
def up
Site.find_each do |site|
SiteService.new(site: site).send :sync_nodes
site.save
end
end
def down
DeployRsync.destroy_all
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

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_05_14_165639) do ActiveRecord::Schema.define(version: 2021_10_22_225449) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
@ -64,6 +64,7 @@ ActiveRecord::Schema.define(version: 2021_05_14_165639) do
t.string "remote_user" t.string "remote_user"
t.boolean "crawler", default: false t.boolean "crawler", default: false
t.string "http_referer" t.string "http_referer"
t.datetime "created_at", precision: 6
t.index ["geoip2_data_city_name"], name: "index_access_logs_on_geoip2_data_city_name" t.index ["geoip2_data_city_name"], name: "index_access_logs_on_geoip2_data_city_name"
t.index ["geoip2_data_country_name"], name: "index_access_logs_on_geoip2_data_country_name" t.index ["geoip2_data_country_name"], name: "index_access_logs_on_geoip2_data_country_name"
t.index ["host"], name: "index_access_logs_on_host" t.index ["host"], name: "index_access_logs_on_host"
@ -303,6 +304,15 @@ ActiveRecord::Schema.define(version: 2021_05_14_165639) do
t.index ["usuarie_id"], name: "index_roles_on_usuarie_id" t.index ["usuarie_id"], name: "index_roles_on_usuarie_id"
end end
create_table "rollups", force: :cascade do |t|
t.string "name", null: false
t.string "interval", null: false
t.datetime "time", null: false
t.jsonb "dimensions", default: {}, null: false
t.float "value"
t.index ["name", "interval", "time", "dimensions"], name: "index_rollups_on_name_and_interval_and_time_and_dimensions", unique: true
end
create_table "sites", force: :cascade do |t| create_table "sites", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@ -324,6 +334,15 @@ ActiveRecord::Schema.define(version: 2021_05_14_165639) do
t.index ["name"], name: "index_sites_on_name", unique: true t.index ["name"], name: "index_sites_on_name", unique: true
end end
create_table "stats", force: :cascade do |t|
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.bigint "site_id"
t.string "name", null: false
t.index ["name"], name: "index_stats_on_name", using: :hash
t.index ["site_id"], name: "index_stats_on_site_id"
end
create_table "usuaries", force: :cascade do |t| create_table "usuaries", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@ -370,4 +389,10 @@ new.indexed_content := to_tsvector(('pg_catalog.' || new.dictionary)::regconfig,
SQL_ACTIONS SQL_ACTIONS
end end
create_trigger("access_logs_before_insert_row_tr", :compatibility => 1).
on("access_logs").
before(:insert) do
"new.created_at := to_timestamp(new.msec);"
end
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

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

@ -0,0 +1,11 @@
# frozen_string_literal: true
namespace :stats do
desc 'Process stats'
task process_all: :environment do
Site.all.pluck(:id).each do |site_id|
UriCollectionJob.perform_now site_id: site_id, once: true
StatCollectionJob.perform_now site_id: site_id, once: true
end
end
end

View file

@ -1,27 +1,15 @@
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 if status != 0 then alert
check program blazer_1h check program access_logs
with path "/usr/local/bin/sutty blazer 1h" with path "/srv/http/bin/access_logs" as uid "app" and gid "www-data"
every 60 cycles every "0 0 * * *"
if status != 0 then alert if status != 0 then alert
check program blazer_1d check program stats
with path "/usr/local/bin/sutty blazer 1d" with path "/usr/bin/foreman run -f /srv/Procfile -d /srv stats" as uid "rails" gid "www-data"
every 1440 cycles every "0 1 * * *"
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

View file

@ -2257,7 +2257,7 @@ collection-visit@^1.0.0:
map-visit "^1.0.0" map-visit "^1.0.0"
object-visit "^1.0.0" object-visit "^1.0.0"
color-convert@^1.9.0, color-convert@^1.9.1: color-convert@^1.9.0, color-convert@^1.9.1, color-convert@^1.9.3:
version "1.9.3" version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@ -5029,6 +5029,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
moment@^2.10.2:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
move-concurrently@^1.0.1: move-concurrently@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"