5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-16 16:41:42 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into bundler

This commit is contained in:
f 2023-03-23 19:21:34 -03:00
commit 22abe846f4
121 changed files with 2184 additions and 1197 deletions

View file

@ -1,10 +1,5 @@
# Excluir todo # Excluir todo
* *
# Solo agregar lo que usamos en COPY # Solo agregar lo que usamos en COPY
!./.git/ # !./archivo
!./rubygems-platform-musl.patch !./monit.conf
!./Gemfile
!./Gemfile.lock
!./config/credentials.yml.enc
!./public/assets/
!./public/packs/

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

@ -1,125 +1,23 @@
# Este Dockerfile está armado pensando en una compilación lanzada desde FROM registry.nulo.in/sutty/rails:3.13.6-2.7.5
# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas ARG PANDOC_VERSION=2.17.1.1
# como el tarball van a tener que cambiar porque ya vamos a haber hecho
# un clone/pull limpio.
FROM alpine:3.13.6 AS build
MAINTAINER "f <f@sutty.nl>"
ARG RAILS_MASTER_KEY
ARG BRANCH
# Un entorno base
ENV BRANCH=$BRANCH
ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
ENV RAILS_ENV production ENV RAILS_ENV production
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake
RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
RUN apk add --no-cache patch
COPY ./rubygems-platform-musl.patch /tmp/
RUN cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch
# Agregar el usuario
RUN addgroup -g 82 -S www-data
RUN adduser -s /bin/sh -G www-data -h /home/app -D app
RUN install -dm750 -o app -g www-data /home/app/sutty
RUN gem install --no-document bundler:2.1.4
# Empezamos con la usuaria app
USER app
# Vamos a trabajar dentro de este directorio
WORKDIR /home/app/sutty
# Copiamos solo el Gemfile para poder instalar las gemas necesarias
COPY --chown=app:www-data ./Gemfile .
COPY --chown=app:www-data ./Gemfile.lock .
RUN bundle config set no-cache true
RUN bundle config set specific_platform true
RUN bundle install --path=./vendor --without='test development'
# Vaciar la caché
RUN rm vendor/ruby/2.7.0/cache/*.gem
# Copiar el repositorio git
COPY --chown=app:www-data ./.git/ ./.git/
# Hacer un clon limpio del repositorio en lugar de copiar todos los
# archivos
RUN cd .. && git clone sutty checkout
RUN cd ../checkout && git checkout $BRANCH
WORKDIR /home/app/checkout
# Traer las gemas:
RUN rm -rf ./vendor
RUN mv ../sutty/vendor ./vendor
RUN mv ../sutty/.bundle ./.bundle
# Instalar secretos
COPY --chown=app:root ./config/credentials.yml.enc ./config/
RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc
# Eliminar archivos innecesarios
USER root
RUN apk add --no-cache findutils
RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf
# Contenedor final
FROM registry.nulo.in/sutty/monit:3.13.6
ENV RAILS_ENV production
# Pandoc
RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/repositories
# Instalar las dependencias, separamos la librería de base de datos para # Instalar las dependencias, separamos la librería de base de datos para
# poder reutilizar este primer paso desde otros contenedores # poder reutilizar este primer paso desde otros contenedores
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake ruby-irb ruby-io-console ruby-etc #
RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips
RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc
RUN apk add --no-cache git-lfs openssh-client patch
# Chequear que la versión de ruby sea la correcta
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
COPY ./rubygems-platform-musl.patch /tmp/
RUN apk add --no-cache patch && cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch && apk del patch
# Necesitamos yarn para que Jekyll pueda generar los sitios # Necesitamos yarn para que Jekyll pueda generar los sitios
# XXX: Eliminarlo cuando extraigamos la generación de sitios del proceso # XXX: Eliminarlo cuando extraigamos la generación de sitios del proceso
# principal # principal
RUN apk add --no-cache yarn RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
# Instalar foreman para poder correr los servicios rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \
RUN gem install --no-document --no-user-install bundler:2.1.4 foreman yarn daemonize ruby-webrick
# Agregar el grupo del servidor web y la usuaria RUN gem install --no-document --no-user-install foreman
RUN addgroup -g 82 -S www-data 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 adduser -s /bin/sh -G www-data -h /srv/http -D app
# Convertirse en app para instalar COPY ./monit.conf /etc/monit.d/sutty.conf
USER app
COPY --from=build --chown=app:www-data /home/app/checkout /srv/http
COPY --chown=app:www-data ./.git/ ./.git/
RUN rm -rf /srv/http/_sites /srv/http/_deploy
RUN ln -s data/_storage /srv/http/_storage
RUN ln -s data/_sites /srv/http/_sites
RUN ln -s data/_deploy /srv/http/_deploy
RUN ln -s data/_private /srv/http/_private
# Volver a root para cerrar la compilación VOLUME "/srv"
USER root
# Instalar la configuración de monit
RUN install -m 640 -o root -g root /srv/http/monit.conf /etc/monit.d/sutty.conf
RUN apk add --no-cache daemonize ruby-webrick
RUN install -m 755 /srv/http/entrypoint.sh /usr/local/bin/sutty
# Mantener estos directorios!
VOLUME "/srv/http/data"
# El puerto de puma
EXPOSE 3000 EXPOSE 3000
EXPOSE 9394 EXPOSE 9394

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

@ -26,7 +26,7 @@ hain ?= ENV_FILE=.env $(HAINISH)## Ubicación de Hainish
# #
# Production es el entorno de panel.sutty.nl # Production es el entorno de panel.sutty.nl
ifeq ($(env),production) ifeq ($(env),production)
container ?= sutty container ?= panel
## TODO: Cambiar a otra cosa ## TODO: Cambiar a otra cosa
branch ?= rails branch ?= rails
public ?= public public ?= public
@ -71,6 +71,15 @@ rake: ## Corre rake dentro del entorno de desarrollo (pasar argumentos con args=
bundle: ## Corre bundle dentro del entorno de desarrollo (pasar argumentos con args=). bundle: ## Corre bundle dentro del entorno de desarrollo (pasar argumentos con args=).
$(hain) 'bundle $(args)' $(hain) 'bundle $(args)'
psql := psql -h $(PG_HOST) -U $(PG_USER) -p $(PG_PORT) -d sutty
copy-table:
test -n "$(table)"
echo "truncate $(table) $(cascade);" | $(psql)
ssh $(delegate) docker exec postgresql pg_dump -U sutty -d sutty -t $(table) | $(psql)
psql:
$(psql)
rubocop: ## Yutea el código que está por ser commiteado rubocop: ## Yutea el código que está por ser commiteado
git status --porcelain \ git status --porcelain \
| grep -E "^(A|M)" \ | grep -E "^(A|M)" \
@ -102,20 +111,13 @@ save: ## Subir la imagen Docker al nodo delegado
@echo -e "\a" @echo -e "\a"
ota-js: assets ## Actualizar Javascript en el nodo delegado ota-js: assets ## Actualizar Javascript en el nodo delegado
sudo chgrp -R 82 public/ rsync -avi --delete-after --chown 1000:82 public/ root@$(delegate):/srv/sutty/srv/http/data/_$(public)/
rsync -avi --delete-after public/ root@$(delegate):/srv/sutty/srv/http/data/_$(public)/
ssh root@$(delegate) docker exec $(container) sh -c "cat /srv/http/tmp/puma.pid | xargs -r kill -USR2" ssh root@$(delegate) docker exec $(container) sh -c "cat /srv/http/tmp/puma.pid | xargs -r kill -USR2"
ota: ## Actualizar Rails en el nodo delegado ota: ## Actualizar Rails en el nodo delegado
umask 022; git format-patch $(commit) ssh $(delegate) git -C /srv/sutty/srv/http/panel.sutty.nl pull ; true
ssh $(delegate) mkdir -p /tmp/patches-$(commit)/ ssh $(delegate) chown -R 1000:82 /srv/sutty/srv/http/panel.sutty.nl
scp ./0*.patch $(delegate):/tmp/patches-$(commit)/ ssh $(delegate) docker exec $(container) rails reload
scp ./ota.sh $(delegate):/tmp/
ssh $(delegate) docker cp /tmp/patches-$(shell echo $(commit) | cut -d / -f 1) $(container):/tmp/
ssh $(delegate) docker cp /tmp/ota.sh $(container):/usr/local/bin/ota
ssh $(delegate) docker exec $(container) apk add --no-cache patch
ssh $(delegate) docker exec $(container) ota $(commit)
rm ./0*.patch
# Todos los archivos de assets. Si alguno cambia, se van a recompilar # Todos los archivos de assets. Si alguno cambia, se van a recompilar
# los assets que luego se suben al nodo delegado. # los assets que luego se suben al nodo delegado.

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

@ -67,7 +67,11 @@
.editor-content { .editor-content {
min-height: 480px; min-height: 480px;
p, h1, h2, h3, h4, h5, h6, ul, li, figcaption { outline: #ccc solid thin; } p, h1, h2, h3, h4, h5, h6, ul, li, blockquote, figcaption { outline: #ccc solid thin; }
blockquote {
border-left: #555 solid .25em;
padding: .75em;
}
strong, em, del, u, sub, sup, small { background: #0002; } strong, em, del, u, sub, sup, small { background: #0002; }
a { background: #13fefe50; } a { background: #13fefe50; }
[data-editor-selected] { outline: #f206f9 solid thick; } [data-editor-selected] { outline: #f206f9 solid thick; }

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
module ActiveStorage
# Modifica la creación de un blob antes de subir el archivo para que
# incluya el JekyllService adecuado.
module DirectUploadsControllerDecorator
extend ActiveSupport::Concern
included do
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(service_name: session[:service_name], **blob_args)
render json: direct_upload_json(blob)
end
private
# Normalizar los caracteres unicode en los nombres de archivos
# para que puedan propagarse correctamente a través de todo el
# stack.
def blob_args
params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys.tap do |ba|
ba[:filename] = ba[:filename].unicode_normalize
end
end
end
end
end
ActiveStorage::DirectUploadsController.include ActiveStorage::DirectUploadsControllerDecorator

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module ActiveStorage
# Modificar {DiskController} para poder asociar el blob a un sitio
module DiskControllerDecorator
extend ActiveSupport::Concern
included do
# Asociar el archivo subido al sitio correspondiente. Cada sitio
# tiene su propio servicio de subida de archivos.
def update
if (token = decode_verified_token)
if acceptable_content?(token)
named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
blob = ActiveStorage::Blob.find_by_key token[:key]
site = Site.find_by_name token[:service_name]
site.static_files.attach(blob)
else
head :unprocessable_entity
end
else
head :not_found
end
rescue ActiveStorage::IntegrityError
head :unprocessable_entity
end
end
end
end
ActiveStorage::DiskController.include ActiveStorage::DiskControllerDecorator

View file

@ -6,19 +6,22 @@ module Api
class CspReportsController < BaseController class CspReportsController < BaseController
skip_forgery_protection skip_forgery_protection
# No queremos indicar que algo salió mal
rescue_from ActionController::ParameterMissing, with: :csp_report_created
# Crea un reporte de CSP intercambiando los guiones medios por # Crea un reporte de CSP intercambiando los guiones medios por
# bajos # bajos
# #
# TODO: Aplicar rate_limit # TODO: Aplicar rate_limit
def create def create
csp = CspReport.new(csp_report_params.to_h.map do |k, v| csp = CspReport.new(csp_report_params.to_h.transform_keys do |k|
[k.tr('-', '_'), v] k.tr('-', '_')
end.to_h) end)
csp.id = SecureRandom.uuid csp.id = SecureRandom.uuid
csp.save csp.save
render json: {}, status: :created csp_report_created
end end
private private
@ -39,6 +42,10 @@ module Api
:'column-number', :'column-number',
:'source-file') :'source-file')
end end
def csp_report_created
render json: {}, status: :created
end
end end
end end
end end

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,10 +2,8 @@
# 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]
# TODO: Traer los comunes desde ApplicationController # TODO: Traer los comunes desde ApplicationController
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
@ -14,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
@ -37,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
@ -166,4 +166,9 @@ class PostsController < ApplicationController
def post def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id]) @post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end end
# Recuerda el nombre del servicio de subida de archivos
def service_for_direct_upload
session[:service_name] = site.name.to_sym
end
end end

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
urls = params[:urls].is_a?(Array) ? params[:urls] : params[:urls]&.split("\n")
urls = urls&.map(&:strip)&.select(&:present?)&.select do |uri|
uri.start_with? 'https://' uri.start_with? 'https://'
end&.map do |u| end
# XXX: Eliminar
urls ||= [site.url]
urls.map do |u|
# XXX: Eliminar al deployear
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1} # @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1}
next u unless u.end_with? '/' next u unless u.end_with? '/'
"#{u}index.html" "#{u}index.html"
end&.uniq || [@site.url, @site.urls].flatten.uniq 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

@ -21,11 +21,12 @@ function makeBlock(tag: string): EditorBlock {
} }
export const li: EditorBlock = makeBlock("li"); export const li: EditorBlock = makeBlock("li");
const paragraph: EditorBlock = makeBlock("p");
// XXX: si agregás algo acá, agregalo a blockNames // XXX: si agregás algo acá, agregalo a blockNames
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml) // (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
export const blocks: { [propName: string]: EditorBlock } = { export const blocks: { [propName: string]: EditorBlock } = {
paragraph: makeBlock("p"), paragraph,
h1: makeBlock("h1"), h1: makeBlock("h1"),
h2: makeBlock("h2"), h2: makeBlock("h2"),
h3: makeBlock("h3"), h3: makeBlock("h3"),
@ -42,6 +43,11 @@ export const blocks: { [propName: string]: EditorBlock } = {
allowedChildren: ["li"], allowedChildren: ["li"],
handleEmpty: li, handleEmpty: li,
}, },
blockquote: {
...makeBlock("blockquote"),
allowedChildren: blockNames,
handleEmpty: paragraph,
},
}; };
export function setupButtons(editor: Editor): void { export function setupButtons(editor: Editor): void {

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

@ -10,6 +10,7 @@ export const blockNames = [
"h6", "h6",
"unordered_list", "unordered_list",
"ordered_list", "ordered_list",
"blockquote",
]; ];
export const markNames = [ export const markNames = [
"bold", "bold",

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

@ -3,9 +3,12 @@
# Realiza el deploy de un sitio # Realiza el deploy de un sitio
class DeployJob < ApplicationJob class DeployJob < ApplicationJob
class DeployException < StandardError; end class DeployException < StandardError; end
class DeployTimedOutException < DeployException; end
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def perform(site, notify = true, time = Time.now) def perform(site, notify: true, time: Time.now, output: false)
@output = output
ActiveRecord::Base.connection_pool.with_connection do ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site) @site = Site.find(site)
@ -16,32 +19,39 @@ class DeployJob < ApplicationJob
# hora original para poder ir haciendo timeouts. # hora original para poder ir haciendo timeouts.
if @site.building? if @site.building?
if 10.minutes.ago >= time if 10.minutes.ago >= time
@site.update status: 'waiting' notify = false
raise DeployException, raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" "#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
end end
DeployJob.perform_in(60, site, notify, time) DeployJob.perform_in(60, site, notify: notify, time: time, output: output)
return return
end end
@site.update status: 'building' @site.update status: 'building'
# Asegurarse que DeployLocal sea el primero! # Asegurarse que DeployLocal sea el primero!
@deployed = { deploy_local: deploy_locally } @deployed = {
deploy_local: {
status: deploy_locally,
seconds: deploy_local.build_stats.last.seconds,
size: deploy_local.size,
urls: [deploy_local.url]
}
}
# No es opcional # No es opcional
unless @deployed[:deploy_local] unless @deployed[:deploy_local][:status]
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea # Hacer fallar la tarea
raise DeployException, deploy_local.build_stats.last.log raise DeployException, "#{@site.name}: Falló la compilación"
end end
deploy_others deploy_others
rescue DeployTimedOutException => e
# Volver a la espera notify_exception e
@site.update status: 'waiting' rescue DeployException => e
notify_exception e, deploy_local
ensure
@site&.update status: 'waiting'
notify_usuaries if notify notify_usuaries if notify
end end
@ -50,17 +60,44 @@ class DeployJob < ApplicationJob
private private
# @param :exception [StandardError]
# @param :deploy [Deploy]
def notify_exception(exception, deploy = nil)
data = {
site: @site.id,
deploy: deploy&.type,
log: deploy&.build_stats&.last&.log
}
ExceptionNotifier.notify_exception(exception, data: data)
end
def deploy_local def deploy_local
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal') @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end end
def deploy_locally def deploy_locally
deploy_local.deploy deploy_local.deploy(output: @output)
end end
def deploy_others def deploy_others
@site.deploys.where.not(type: 'DeployLocal').find_each do |d| @site.deploys.where.not(type: 'DeployLocal').find_each do |d|
@deployed[d.type.underscore.to_sym] = d.deploy begin
status = d.deploy(output: @output)
seconds = d.build_stats.last.try(:seconds)
rescue StandardError => e
status = false
seconds = 0
notify_exception e, d
end
@deployed[d.type.underscore.to_sym] = {
status: status,
seconds: seconds || 0,
size: d.size,
urls: d.respond_to?(:urls) ? d.urls : [d.url].compact
}
end end
end end

View file

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

View file

@ -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')
AccessLog.where(host: hostname, uri: uri)
.where('created_at >= ?', beginning_of_interval)
.completed_requests
.non_robots
.group(:host, :uri)
.rollup('host|uri', interval: starting_interval, update: true)
# Reducir las estadísticas calculadas aplicando un rollup sobre el
# intervalo más amplio.
Stat::INTERVALS.reduce do |previous, current|
Rollup.where(name: 'host|uri', interval: previous)
.where_dimensions(host: hostname, uri: uri)
.group("dimensions->'host'", "dimensions->'uri'")
.rollup('host|uri', interval: current, update: true) do |rollup|
rollup.sum(:value)
end
# Devolver el intervalo actual
current
end
end
end
# Recordar la última vez que se corrió la tarea # Recordar la última vez que se corrió la tarea
site.stats.create! name: STAT_NAME stat = site.stats.create! name: STAT_NAME
# Columnas a agrupar
columns = Stat::COLUMNS.zip([nil]).to_h
# Las URIs son la fuente de verdad de las visitas, porque son las
# que indican las páginas y recursos descargables, el resto son
# imágenes, CSS, JS y tipografías que no nos aportan números
# significativos.
uri_dimensions = { host: site.hostnames, uri: uris }
host_dimensions = { host: site.hostnames }
# Recorremos todos los hostnames y uris posibles y luego agrupamos
# recursivamente para no tener que recalcular, asumiendo que es más
# rápido buscar en los rollups indexados que en la tabla en bruto.
#
# Los referers solo se agrupan por host.
columns.each_key do |column|
columns[column] = AccessLog.where(**host_dimensions).distinct(column).pluck(column)
end
# Cantidad de visitas por host
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).map do |uri|
"/#{uri}"
end end
end.flatten(3).compact
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

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActionDispatch
module Http
# Normaliza los nombres de archivo para que se propaguen
# correctamente a través de todo el stack.
module UploadedFileDecorator
extend ActiveSupport::Concern
included do
# Devolver el nombre de archivo con caracteres unicode
# normalizados
def original_filename
@original_filename.unicode_normalize
end
end
end
end
end
ActionDispatch::Http::UploadedFile.include ActionDispatch::Http::UploadedFileDecorator

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
module ActiveStorage
module Attached::Changes::CreateOneDecorator
extend ActiveSupport::Concern
included do
private
# A partir de ahora todos los archivos se suben al servicio de
# cada sitio.
def attachment_service_name
record.name.to_sym
end
end
end
end
ActiveStorage::Attached::Changes::CreateOne.include ActiveStorage::Attached::Changes::CreateOneDecorator

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
module ActiveStorage
class Service
# Sube los archivos a cada repositorio y los agrega al LFS de su
# repositorio git.
#
# @todo: Implementar LFS. No nos gusta mucho la idea porque duplica
# el espacio en disco, pero es la única forma que tenemos (hasta que
# implementemos IPFS) para poder transferir los archivos junto con el
# sitio.
class JekyllService < Service::DiskService
# Genera un servicio para un sitio determinado
#
# @param :site [Site]
# @return [ActiveStorage::Service::JekyllService]
def self.build_for_site(site:)
new(root: File.join(site.path, 'public'), public: true).tap do |js|
js.name = site.name.to_sym
end
end
# Solo copiamos el archivo si no existe
#
# @param :key [String]
# @param :io [IO]
# @param :checksum [String]
def upload(key, io, checksum: nil, **)
instrument :upload, key: key, checksum: checksum do
IO.copy_stream(io, make_path_for(key)) unless exist?(key)
ensure_integrity_of(key, checksum) if checksum
end
end
# Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde
# corresponde.
#
# @param :key [String]
# @param :expires_in [Integer]
# @param :content_type [String]
# @param :content_length [Integer]
# @param :checksum [String]
# @return [String]
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
instrument :url, key: key do |payload|
verified_token_with_expiration = ActiveStorage.verifier.generate(
{
key: key,
content_type: content_type,
content_length: content_length,
checksum: checksum,
service_name: name,
filename: filename_for(key)
},
expires_in: expires_in,
purpose: :blob_token
)
generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
payload[:url] = generated_url
generated_url
end
end
# Mantener retrocompatibilidad con cómo gestionamos los archivos
# subidos hasta ahora.
#
# @param :key [String]
# @return [String]
def folder_for(key)
key
end
# Obtiene el nombre de archivo para esta key
#
# @param :key [String]
# @return [String]
def filename_for(key)
ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first.tap do |filename|
raise ArgumentError, "Filename for key #{key} is blank" if filename.blank?
end
end
# Crea una ruta para la llave con un nombre conocido.
#
# @param :key [String]
# @return [String]
def path_for(key)
File.join root, folder_for(key), filename_for(key)
end
end
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module ActiveStorage
class Service
# Modificaciones a ActiveStorage::Service::Registry
module RegistryDecorator
extend ActiveSupport::Concern
included do
# El mismo comportamiento que #fetch con el agregado de generar
# un {JekyllService} para cada sitio.
def fetch(name)
services.fetch(name.to_sym) do |key|
if configurations.include?(key)
services[key] = configurator.build(key)
elsif (site = Site.find_by_name(key))
services[key] = ActiveStorage::Service::JekyllService.build_for_site(site: site)
elsif block_given?
yield key
else
raise KeyError, "Missing configuration for the #{key} Active Storage service. " \
"Configurations available for the #{configurations.keys.to_sentence} services."
end
end
end
end
end
end
end
ActiveStorage::Service::Registry.include ActiveStorage::Service::RegistryDecorator

View file

@ -8,21 +8,66 @@
# TODO: Agregar firma GPG y header Autocrypt # TODO: Agregar firma GPG y header Autocrypt
# TODO: Cifrar con GPG si le usuarie nos dio su llave # TODO: Cifrar con GPG si le usuarie nos dio su llave
class DeployMailer < ApplicationMailer class DeployMailer < ApplicationMailer
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
# rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/AbcSize
def deployed(which_ones) def deployed(deploys = {})
@usuarie = Usuarie.find(params[:usuarie]) usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site]) site = usuarie.sites.find(params[:site])
@deploys = which_ones hostname = site.hostname
@deploy_local = @site.deploys.find_by(type: 'DeployLocal') deploys ||= {}
# Informamos a cada quien en su idioma y damos una dirección de # Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben # respuesta porque a veces les usuaries nos escriben
I18n.with_locale(@usuarie.lang) do I18n.with_locale(usuarie.lang) do
mail(to: @usuarie.email, subject = t('.subject', site: site.name)
reply_to: "sutty@#{Site.domain}",
subject: I18n.t('deploy_mailer.deployed.subject', @hi = t('.hi')
site: @site.name)) @explanation = t('.explanation', fqdn: hostname)
@help = t('.help')
@headers = %w[type status url seconds size].map do |header|
t(".th.#{header}")
end
@table = deploys.each_pair.map do |deploy, value|
{
title: t(".#{deploy}.title"),
status: t(".#{deploy}.#{value[:status] ? 'success' : 'error'}"),
urls: value[:urls],
seconds: {
human: distance_of_time_in_words(value[:seconds].seconds),
machine: "PT#{value[:seconds]}S"
},
size: number_to_human_size(value[:size], precision: 2)
}
end
@terminal_table = Terminal::Table.new do |t|
t << @headers
t.add_separator
@table.each do |row|
row[:urls].each do |url|
t << (row.map do |k, v|
case k
when :seconds then v[:human]
when :urls then url
else v
end
end)
end
end
end
mail(to: usuarie.email, reply_to: "sutty@#{Site.domain}", subject: subject)
end end
end end
# rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/AbcSize
private
def t(key, **args)
I18n.t("deploy_mailer.deployed#{key}", **args)
end
end end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module ActiveStorage
# Modificaciones a ActiveStorage::Blob
module BlobDecorator
extend ActiveSupport::Concern
included do
# Permitir que llegue el nombre de archivo al servicio de subida de
# archivos.
#
# @return [Hash]
def service_metadata
if forcibly_serve_as_binary?
{ content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
elsif !allowed_inline?
{ content_type: content_type, disposition: :attachment, filename: filename }
else
{ content_type: content_type, filename: filename }
end
end
end
end
end
ActiveStorage::Blob.include ActiveStorage::BlobDecorator

View file

@ -11,7 +11,11 @@ class Deploy < ApplicationRecord
belongs_to :site belongs_to :site
has_many :build_stats, dependent: :destroy has_many :build_stats, dependent: :destroy
def deploy def deploy(**)
raise NotImplementedError
end
def url
raise NotImplementedError raise NotImplementedError
end end
@ -23,6 +27,9 @@ class Deploy < ApplicationRecord
raise NotImplementedError raise NotImplementedError
end end
# Realizar tareas de limpieza.
def cleanup!; end
def time_start def time_start
@start = Time.now @start = Time.now
end end
@ -49,20 +56,26 @@ class Deploy < ApplicationRecord
# #
# @param [String] # @param [String]
# @return [Boolean] # @return [Boolean]
def run(cmd) def run(cmd, output: false)
r = nil r = nil
lines = [] lines = []
time_start time_start
Dir.chdir(site.path) do Dir.chdir(site.path) do
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t|
r = t.value
# XXX: Tenemos que leer línea por línea porque en salidas largas
# se cuelga la IO
# TODO: Enviar a un websocket para ver el proceso en vivo? # TODO: Enviar a un websocket para ver el proceso en vivo?
Thread.new do
o.each do |line| o.each do |line|
lines << line lines << line
puts line if output
end end
rescue IOError => e
lines << e.message
puts e.message if output
end
r = t.value
end end
end end
time_stop time_stop

View file

@ -5,7 +5,7 @@ class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON store :values, accessors: %i[hostname], coder: JSON
# Generar un link simbólico del sitio principal al alternativo # Generar un link simbólico del sitio principal al alternativo
def deploy def deploy(**)
File.symlink?(destination) || File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero? File.symlink(site.hostname, destination).zero?
end end
@ -18,6 +18,10 @@ class DeployAlternativeDomain < Deploy
end end
def destination def destination
File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, '')) @destination ||= File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
end
def url
"https://#{File.basename destination}"
end end
end end

View file

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

View file

@ -12,12 +12,12 @@ class DeployLocal < Deploy
# #
# Pasamos variables de entorno mínimas para no filtrar secretos de # Pasamos variables de entorno mínimas para no filtrar secretos de
# Sutty # Sutty
def deploy def deploy(output: false)
return false unless mkdir return false unless mkdir
return false unless yarn return false unless yarn(output: output)
return false unless bundle return false unless bundle(output: output)
jekyll_build jekyll_build(output: output)
end end
# Sólo permitimos un deploy local # Sólo permitimos un deploy local
@ -25,9 +25,14 @@ class DeployLocal < Deploy
1 1
end end
def url
site.url
end
# Obtener el tamaño de todos los archivos y directorios (los # Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :) # directorios son archivos :)
def size def size
@size ||= begin
paths = [destination, File.join(destination, '**', '**')] paths = [destination, File.join(destination, '**', '**')]
Dir.glob(paths).map do |file| Dir.glob(paths).map do |file|
@ -38,11 +43,23 @@ class DeployLocal < Deploy
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
@ -79,29 +96,23 @@ class DeployLocal < Deploy
File.exist? yarn_lock File.exist? yarn_lock
end end
def gem def gem(output: false)
run %(gem install bundler --no-document) run %(gem install bundler --no-document), output: output
end end
# Corre yarn dentro del repositorio # Corre yarn dentro del repositorio
def yarn def yarn(output: false)
return true unless yarn_lock? return true unless yarn_lock?
run 'yarn install --production' run 'yarn install --production', output: output
end end
def bundle def bundle(output: false)
if Rails.env.production? run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development), output: output
# XXX: Desde que ya no compartimos el directorio de gemas, tenemos
# que hacer limpieza después de instalar.
run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development)
else
run %(bundle install)
end
end end
def jekyll_build def jekyll_build(output: false)
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}"), output: output
end end
# no debería haber espacios ni caracteres especiales, pero por si # no debería haber espacios ni caracteres especiales, pero por si

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Soportar dominios localizados
class DeployLocalizedDomain < DeployAlternativeDomain
store :values, accessors: %i[hostname locale], coder: JSON
# Generar un link simbólico del sitio principal al alternativo
def deploy(**)
File.symlink?(destination) ||
File.symlink(File.join(site.hostname, locale), destination).zero?
end
end

View file

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

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
# Reindexa los artículos al terminar la compilación
class DeployReindex < Deploy
def deploy(**)
time_start
site.reset
Site.transaction do
site.indexed_posts.destroy_all
site.index_posts!
end
time_stop
build_stats.create action: 'reindex',
log: 'Reindex',
seconds: time_spent_in_seconds,
bytes: size,
status: true
site.touch
end
def size
0
end
def limit
1
end
def hostname; end
def url; end
def destination; end
end

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(output: false)
ssh? && rsync(output: output)
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(output: output)
run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
end
# El origen es el destino de la compilación
#
# @return [String]
def source
deploy_local.destination
end
def deploy_local
@deploy_local ||= site.deploys.find_by(type: 'DeployLocal')
end
end

View file

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

View file

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

View file

@ -13,12 +13,17 @@ class MetadataFile < MetadataTemplate
value == default_value value == default_value
end end
# No hay valores sugeridos para archivos subidos.
#
# XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end
def validate def validate
super super
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.compact! errors.compact!
errors.empty? errors.empty?
@ -34,26 +39,14 @@ class MetadataFile < MetadataTemplate
value['path'].is_a?(String) value['path'].is_a?(String)
end end
# Determina si la ruta es opcional pero deja pasar si la ruta se
# especifica
def path_optional?
!required && !path?
end
# Asociar la imagen subida al sitio y obtener la ruta # 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?
self[:value] = default_value
if path?
hardlink
value['path'] = relative_destination_path
else else
value['path'] = nil value['description'] = sanitize value['description']
value['path'] = relative_destination_path_with_filename.to_s if static_file
end end
true true
@ -68,104 +61,114 @@ 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 # @todo encontrar una forma de obtener el attachment sin tener que
# que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213 # recurrir al último subido.
# #
# @return [ActiveStorage::Attachment] # @return [ActiveStorage::Attachment,nil]
def static_file def static_file
return unless path?
@static_file ||= @static_file ||=
case value['path'] case value['path']
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 = 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)
elsif site.static_files.attach(io: path.open, filename: path.basename)
site.static_files.last
end
end end
end end
# Obtiene la ruta absoluta al archivo
#
# @return [Pathname]
def pathname
raise NoMethodError unless uploaded?
@pathname ||= Pathname.new(File.join(site.path, value['path']))
end
# Obtiene la key del attachment a partir de la ruta
#
# @return [String]
def key_from_path def key_from_path
path.dirname.basename.to_s @key_from_path ||= pathname.dirname.basename.to_s
end end
def path? def path?
value['path'].present? value['path'].present?
end end
def description?
value['description'].present?
end
private private
def filemagic # Obtener la ruta al archivo relativa al sitio
@filemagic ||= FileMagic.new(FileMagic::MAGIC_MIME) #
end
# @return [Pathname] # @return [Pathname]
def path
@path ||= Pathname.new(File.join(site.path, value['path']))
end
def file
return unless path?
@file ||=
case value['path']
when ActionDispatch::Http::UploadedFile then value['path'].tempfile.path
when String then File.join(site.path, value['path'])
end
end
# Hacemos un link duro para colocar el archivo dentro del repositorio
# y no duplicar el espacio que ocupan. Esto requiere que ambos
# directorios estén dentro del mismo punto de montaje.
#
# XXX: Asumimos que el archivo destino no existe porque siempre
# contiene una key única.
#
# @return [Boolean]
def hardlink
return if hardlink?
return if File.exist? destination_path
FileUtils.mkdir_p(File.dirname(destination_path))
FileUtils.ln(uploaded_path, destination_path).zero?
end
def hardlink?
File.stat(uploaded_path).ino == File.stat(destination_path).ino
rescue Errno::ENOENT
false
end
# Obtener la ruta al archivo
# https://stackoverflow.com/a/53908358
def uploaded_relative_path
ActiveStorage::Blob.service.path_for(static_file.key)
end
# @return [String]
def uploaded_path
Rails.root.join uploaded_relative_path
end
# La ruta del archivo mantiene el nombre original pero contiene el
# nombre interno y único del archivo para poder relacionarlo con el
# archivo subido en Sutty.
#
# @return [String]
def relative_destination_path
@relative_destination_path ||= File.join('public', static_file.key, static_file.filename.to_s)
end
# @return [String]
def destination_path def destination_path
@destination_path ||= File.join(site.path, relative_destination_path) Pathname.new(static_file_path)
end end
# No hay archivo pero se lo describió # Agrega el nombre de archivo a la ruta para tener retrocompatibilidad
def no_file_for_description? #
value['description'].present? && value['path'].blank? # @return [Pathname]
def destination_path_with_filename
destination_path.realpath
# Si el archivo no llegara a existir, en lugar de hacer fallar todo,
# devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno.
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
Pathname.new(File.join(site.path, value['path']))
end
# Obtener la ruta relativa al sitio.
#
# Si algo falla, devolver la ruta original para no romper el archivo.
#
# @return [String, nil]
def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath)
rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
value['path']
end
def static_file_path
case static_file.blob.service.name
when :local
File.join(site.path, 'public', static_file.key, static_file.filename.to_s)
else
static_file.blob.service.path_for(static_file.key)
end
end
# Obtiene el id del blob asociado
#
# @return [Integer,nil]
def blob_id
@blob_id ||= ActiveStorage::Blob.where(key: key_from_path, service_name: site.name).pluck(:id).first
end
# Genera el blob para un archivo que ya se encuentra en el
# repositorio y lo agrega a la base de datos.
#
# @return [ActiveStorage::Attachment]
def migrate_static_file!
raise ArgumentError, 'El archivo no existe' unless path? && pathname.exist?
Site.transaction do
blob =
ActiveStorage::Blob.create_after_unfurling!(key: key_from_path,
io: pathname.open,
filename: pathname.basename,
service_name: site.name)
ActiveStorage::Attachment.create!(name: 'static_files', record: site, blob: blob)
end
rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
nil
end end
end end

View file

@ -5,7 +5,7 @@ class MetadataImage < MetadataFile
def validate def validate
super super
errors << I18n.t('metadata.image.not_an_image') unless image? errors << I18n.t('metadata.image.not_an_image') if path? && !image?
errors.compact! errors.compact!
errors.empty? errors.empty?
@ -13,8 +13,6 @@ class MetadataImage < MetadataFile
# Determina si es una imagen # Determina si es una imagen
def image? def image?
return true unless file static_file&.blob&.send(:web_image?)
filemagic.file(file).starts_with? 'image/'
end end
end end

View file

@ -12,6 +12,6 @@ class MetadataMarkdown < MetadataText
# markdown y se eliminan autolinks. Mejor es habilitar la generación # markdown y se eliminan autolinks. Mejor es habilitar la generación
# SAFE de CommonMark en la configuración del sitio. # SAFE de CommonMark en la configuración del sitio.
def sanitize(string) def sanitize(string)
string string.unicode_normalize
end end
end end

View file

@ -25,6 +25,6 @@ class MetadataMarkdownContent < MetadataText
# markdown y se eliminan autolinks. Mejor es deshabilitar la # markdown y se eliminan autolinks. Mejor es deshabilitar la
# generación SAFE de CommonMark en la configuración del sitio. # generación SAFE de CommonMark en la configuración del sitio.
def sanitize(string) def sanitize(string)
string.tr("\r", '') string.tr("\r", '').unicode_normalize
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

@ -2,12 +2,6 @@
# Este metadato permite generar rutas manuales. # Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString class MetadataPermalink < MetadataString
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
# de forma que nunca cambia aunque se cambie el título.
def default_value
document.url.sub(%r{\A/}, '') unless post.new?
end
# Los permalinks nunca pueden ser privados # Los permalinks nunca pueden ser privados
def private? def private?
false false
@ -19,7 +13,7 @@ class MetadataPermalink < MetadataString
# puntos suspensivos, la primera / para que siempre sea relativa y # puntos suspensivos, la primera / para que siempre sea relativa y
# agregamos una / al final si la ruta no tiene extensión. # agregamos una / al final si la ruta no tiene extensión.
def sanitize(value) def sanitize(value)
value = value.strip.gsub('..', '/').gsub('./', '').squeeze('/') value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
value = value[1..-1] if value.start_with? '/' value = value[1..-1] if value.start_with? '/'
value += '/' if File.extname(value).blank? value += '/' if File.extname(value).blank?

View file

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

View file

@ -17,7 +17,7 @@ class MetadataString < MetadataTemplate
def sanitize(string) def sanitize(string)
return '' if string.blank? return '' if string.blank?
sanitizer.sanitize(string.strip, sanitizer.sanitize(string.strip.unicode_normalize,
tags: [], tags: [],
attributes: []).strip.html_safe attributes: []).strip.html_safe
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?
@ -184,9 +188,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
return if string.nil? return if string.nil?
return string unless string.is_a? String return string unless string.is_a? String
sanitizer.sanitize(string.tr("\r", ''), sanitizer
.sanitize(string.tr("\r", '').unicode_normalize,
tags: allowed_tags, tags: allowed_tags,
attributes: allowed_attributes).strip.html_safe attributes: allowed_attributes)
.strip
.html_safe
end end
def sanitizer def sanitizer
@ -199,7 +206,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
end end
def allowed_tags def allowed_tags
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure @allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote
figcaption a sub sup small].freeze figcaption a sub sup small].freeze
end end

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, :static_file_migration!
# 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.
@ -230,6 +260,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)] layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang) @posts[lang].build(document: doc, layout: layout, lang: lang)
rescue TypeError => e
ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
end end
@posts[lang] @posts[lang]
@ -331,9 +363,18 @@ 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
install_gems
Jekyll::Site.new(configuration).tap do |site|
site.reader = JekyllData::Reader.new(site) if site.theme
end
end end
end end
@ -380,9 +421,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 +430,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 +468,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,13 +494,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
end config.locales = locales.map(&:to_s)
# Migra los archivos a Sutty
def static_file_migration!
Site::StaticFileMigration.new(site: self).migrate!
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
@ -532,4 +552,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,11 +33,11 @@ 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
# Detecta si la configuración cambió comparando con el valor inicial # Detecta si la configuración cambió comparando con el valor inicial

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

@ -1,52 +0,0 @@
# frozen_string_literal: true
class Site
# Obtiene todos los archivos relacionados en artículos del sitio y los
# sube a Sutty.
class StaticFileMigration
# Tipos de metadatos que contienen archivos
STATIC_TYPES = %i[file image].freeze
attr_reader :site
def initialize(site:)
@site = site
end
def migrate!
modified = site.docs.map do |doc|
next unless STATIC_TYPES.map do |field|
next unless doc.attribute? field
next unless doc[field].path?
next unless doc[field].static_file
true
end.any?
log.write "#{doc.path.relative};no se pudo guardar\n" unless doc.save(validate: false)
doc.path.absolute
end.compact
log.close
return if modified.empty?
# TODO: Hacer la migración desde el servicio de creación de sitios?
site.repository.commit(file: modified,
message: I18n.t('sites.static_file_migration'),
usuarie: author)
end
private
def author
@author ||= GitAuthor.new email: "sutty@#{Site.domain}",
name: 'Sutty'
end
def log
@log ||= File.open(File.join(site.path, 'migration.csv'), 'w')
end
end
end

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

@ -1,17 +1,21 @@
%h1= t('.hi') %h1= @hi
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname), = sanitize_markdown @explanation, tags: %w[p a strong em]
tags: %w[p a strong em]
%table %table
%thead %thead
%tr %tr
%th= t('.th.type') - @headers.each do |header|
%th= t('.th.status') %th= header
%tbody %tbody
- @deploys.each do |deploy, value| - @table.each do |row|
- row[:urls].each do |url|
%tr %tr
%td= t(".#{deploy}.title") %td= row[:title]
%td= value ? t(".#{deploy}.success") : t(".#{deploy}.error") %td= row[:status]
%td= link_to_if url.present?, url, url
%td
%time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
%td= row[:size]
= sanitize_markdown t('.help'), tags: %w[p a strong em] = sanitize_markdown @help, tags: %w[p a strong em]

View file

@ -1,12 +1,7 @@
= '# ' + t('.hi') = "# #{@hi}"
\ \
= t('.explanation', fqdn: @deploy_local.site.hostname) = @explanation
\ \
= Terminal::Table.new do |table| = @terminal_table
- table << [t('.th.type'), t('.th.status')]
- table.add_separator
- @deploys.each do |deploy, value|
- table << [t(".#{deploy}.title"),
value ? t(".#{deploy}.success") : t(".#{deploy}.error")]
\ \
= t('.help') = @help

View file

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

View file

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

@ -95,6 +95,9 @@
%button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }> %button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }>
%i.fa.fa-fw.fa-align-right> %i.fa.fa-fw.fa-align-right>
%span.sr-only>= t('editor.right') %span.sr-only>= t('editor.right')
%button.btn{ type: 'button', title: t('editor.blockquote'), data: { editor_button: 'block-blockquote' } }>
%i.fa.fa-fw.fa-quote-left>
%span.sr-only>= t('editor.blockquote')
-# HAML cringe -# HAML cringe
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } } .editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } }

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)
-# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
-# Los archivos requeridos solo se pueden reemplazar
- unless metadata.required
.custom-control.custom-switch .custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input' = 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' = 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
@ -38,4 +31,4 @@
- cache [metadata, I18n.locale] do - cache [metadata, I18n.locale] do
%section.editor{ id: attr, dir: dir } %section.editor{ id: attr, dir: dir }
= @post.public_send(attr).to_s.html_safe = @post.public_send(attr).value.html_safe

View file

@ -104,6 +104,7 @@
%hr/ %hr/
- if site.persisted?
.form-group#tienda .form-group#tienda
%h2= t('.tienda.title') %h2= t('.tienda.title')
%p.lead %p.lead
@ -124,7 +125,6 @@
%hr/ %hr/
- if site.persisted?
.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')

Some files were not shown because too many files have changed in this diff Show more