Compare commits

..

No commits in common. "staging-cd" and "rails" have entirely different histories.

83 changed files with 1806 additions and 1571 deletions

View file

@ -1,21 +0,0 @@
pipeline:
deploy:
image: registry.nulo.in/sutty/haini.sh@sha256:e28a80228476f5d79e5095e4725ae23c887f9f29ccaa3878b89b619b966eb26b
environment:
# ¡MOCK!
- RAILS_MASTER_KEY=5d2d51406b25ff9c3465122d0732e72c
# Workaround porque Woodpecker a veces lo setea a /root :/
- HOME=/home/suttier
commands:
- sudo chown suttier:suttier -R .
- eval $(ssh-agent -s)
- echo "$${SSH_KEY}" | tr -d '\r' | ssh-add -
- make bundle hain="sh -c"
- echo -n z8p4KI/XRbGPdxPsNux8ys1gvL4+97DrrvPyt7gugJog3o3x/UEIyedkKUq9FWHOS9ltrsUN6NpN5Dsme+iHbMC/FrRjDmDvOoHpP/pqy924l6IgU8OK3m2Y28AU7eqiYvf6kJd5s4KmPJDiH9AQRx4QRy4jG5DfMHBew6EumqedgvRRFtAc3++GPH2qPnO8SYapRM4FXXUTjP3fNdRVD1Fqm7chUra4Qng1JhnzdMlOUhCPfD1Rmeh+X2TltzYhdPMFH3U3fJV7xCkitxu5PQgWfxMhb9FVF68Uvykbt/rod4IE6ZmAmPyyGktYuQSI2t1kkpAV4MOG4ag9aC/RLmi23rt+fVoYJREHga+NQ0YjVSGbBlINIDACr1iL+abtNmHhtfY+o9unlD7xy3UP0EdqTx6WncVJn02D--pfdBRF+zxL1uqoWs--4OJ7axQaFf9git6zUtUGOA== > config/credentials.yml.enc
- make ota-js hain="sh -c"
secrets:
- ssh_key
when:
event: push
branch: staging-cd

View file

@ -17,7 +17,7 @@ ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler 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'`
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
@ -85,7 +85,7 @@ 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'`
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808

View file

@ -19,8 +19,6 @@ gem 'sassc-rails'
gem 'uglifier', '>= 1.3.0'
gem 'bootstrap', '~> 4'
gem 'nokogiri', '~>1.11.0'
# Turbolinks makes navigating your web application faster. Read more:
# https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'

View file

@ -18,60 +18,60 @@ GIT
GEM
remote: https://gems.sutty.nl/
specs:
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
actioncable (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailbox (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
mail (>= 2.7.1)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailer (6.1.4)
actionpack (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activesupport (= 6.1.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionpack (6.1.4)
actionview (= 6.1.4)
activesupport (= 6.1.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actiontext (6.1.4)
actionpack (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
nokogiri (>= 1.8.5)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
actionview (6.1.4)
activesupport (= 6.1.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
activejob (6.1.4)
activesupport (= 6.1.4)
globalid (>= 0.3.6)
activemodel (6.1.4.1)
activesupport (= 6.1.4.1)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
activemodel (6.1.4)
activesupport (= 6.1.4)
activerecord (6.1.4)
activemodel (= 6.1.4)
activesupport (= 6.1.4)
activestorage (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activesupport (= 6.1.4)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.1)
activesupport (6.1.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -89,13 +89,13 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
ast (2.4.2)
autoprefixer-rails (10.3.3.0)
execjs (~> 2)
autoprefixer-rails (10.2.5.1)
execjs (> 0)
bcrypt (3.1.16-x86_64-linux-musl)
bcrypt_pbkdf (1.1.0-x86_64-linux-musl)
benchmark-ips (2.9.1)
bindex (0.8.1-x86_64-linux-musl)
blazer (2.4.3)
blazer (2.4.2)
activerecord (>= 5)
chartkick (>= 3.2)
railties (>= 5)
@ -104,7 +104,7 @@ GEM
autoprefixer-rails (>= 9.1.0)
popper_js (>= 1.14.3, < 2)
sassc-rails (>= 2.0.0)
brakeman (5.1.1)
brakeman (5.0.4)
builder (3.2.4)
capybara (2.18.0)
addressable
@ -157,8 +157,8 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.3)
addressable (~> 2.8)
down (5.2.2)
addressable (~> 2.5)
ed25519 (1.2.4-x86_64-linux-musl)
editorial-autogestiva-jekyll-theme (0.3.4)
jekyll (~> 4)
@ -194,22 +194,22 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
fast_blank (1.0.1-x86_64-linux-musl)
fast_blank (1.0.0-x86_64-linux-musl)
fast_jsonparser (0.5.0-x86_64-linux-musl)
ffi (1.15.4-x86_64-linux-musl)
ffi (1.15.3-x86_64-linux-musl)
flamegraph (0.9.5)
forwardable-extended (2.6.0)
friendly_id (5.4.2)
activerecord (>= 4.0.0)
get_process_mem (0.2.7)
ffi (~> 1.0)
globalid (0.5.2)
activesupport (>= 5.0)
globalid (0.4.2)
activesupport (>= 4.2.0)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
haml (5.2.2)
haml (5.2.1)
temple (>= 0.8.0)
tilt
haml-lint (0.999.999)
@ -220,7 +220,7 @@ GEM
rainbow
rubocop (>= 0.50.0)
sysexits (~> 1.1)
hamlit (2.15.1-x86_64-linux-musl)
hamlit (2.15.0-x86_64-linux-musl)
temple (>= 0.8.2)
thor
tilt
@ -276,7 +276,7 @@ GEM
jekyll (>= 3.7, < 5.0)
jekyll-hardlinks (0.1.2)
jekyll (~> 4)
jekyll-ignore-layouts (0.1.2)
jekyll-ignore-layouts (0.1.0)
jekyll (~> 4)
jekyll-images (0.2.7)
jekyll (~> 4)
@ -284,7 +284,7 @@ GEM
ruby-vips (~> 2)
jekyll-include-cache (0.2.1)
jekyll (>= 3.7, < 5.0)
jekyll-linked-posts (0.4.2)
jekyll-linked-posts (0.4.0)
jekyll (~> 4)
jekyll-locales (0.1.12)
jekyll-lunr (0.3.0)
@ -296,9 +296,9 @@ GEM
sassc (> 2.0.1, < 3.0)
jekyll-seo-tag (2.7.1)
jekyll (>= 3.8, < 5.0)
jekyll-spree-client (0.1.18)
jekyll-spree-client (0.1.15)
fast_blank (~> 1)
spree-api-client (>= 0.2.3)
spree-api-client (~> 0.2)
jekyll-turbolinks (0.0.5)
jekyll (~> 4)
turbolinks-source (~> 5)
@ -306,7 +306,7 @@ GEM
jekyll (~> 4)
jekyll-watch (2.2.1)
listen (~> 3.0)
jekyll-write-and-commit-changes (0.2.0)
jekyll-write-and-commit-changes (0.1.2)
jekyll (~> 4)
rugged (~> 1)
kramdown (2.3.1)
@ -330,7 +330,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.12.0)
loofah (2.10.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -341,25 +341,23 @@ GEM
method_source (1.0.0)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0901)
mime-types-data (3.2021.0704)
mini_histogram (0.3.1)
mini_magick (4.11.0)
mini_mime (1.1.1)
mini_portile2 (2.5.3)
mini_mime (1.1.0)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.4)
mobility (1.1.3)
mobility (1.1.2)
i18n (>= 0.6.10, < 2)
request_store (~> 1.0)
multi_xml (0.6.0)
net-ssh (6.1.0)
netaddr (2.0.4)
nio4r (2.5.8-x86_64-linux-musl)
nokogiri (1.11.7-x86_64-linux-musl)
mini_portile2 (~> 2.5.0)
nio4r (2.5.7-x86_64-linux-musl)
nokogiri (1.11.7-x86_64-linux)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.20.1)
@ -372,21 +370,21 @@ GEM
activerecord (>= 5.2)
activesupport (>= 5.2)
popper_js (1.16.0)
prometheus_exporter (0.8.1)
prometheus_exporter (0.8.0)
webrick
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (4.0.6)
puma (5.4.0-x86_64-linux-musl)
puma (5.3.2-x86_64-linux-musl)
nio4r (~> 2.0)
pundit (2.1.1)
pundit (2.1.0)
activesupport (>= 3.0.0)
racc (1.5.2-x86_64-linux-musl)
rack (2.2.3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-mini-profiler (2.3.3)
rack-mini-profiler (2.3.2)
rack (>= 1.2.0)
rack-proxy (0.7.0)
rack
@ -403,34 +401,34 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
rails (6.1.4)
actioncable (= 6.1.4)
actionmailbox (= 6.1.4)
actionmailer (= 6.1.4)
actionpack (= 6.1.4)
actiontext (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activemodel (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
bundler (>= 1.15.0)
railties (= 6.1.4.1)
railties (= 6.1.4)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
rails_warden (0.6.0)
warden (>= 1.2.0)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
railties (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
method_source
rake (>= 0.13)
thor (~> 1.0)
@ -457,7 +455,7 @@ GEM
jekyll-unique-urls (~> 0.1)
sutty-archives (~> 2.2)
sutty-liquid (~> 0)
redis (4.4.0)
redis (4.3.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
@ -482,18 +480,18 @@ GEM
railties (>= 5.0)
rexml (3.2.5)
rouge (3.26.0)
rubocop (1.20.0)
rubocop (1.18.3)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.9.1, < 2.0)
rubocop-ast (>= 1.7.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.11.0)
rubocop-ast (1.7.0)
parser (>= 3.0.1.1)
rubocop-rails (2.12.2)
rubocop-rails (2.11.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
@ -502,16 +500,16 @@ GEM
ruby-filemagic (0.7.2-x86_64-linux-musl)
ruby-progressbar (1.11.0)
ruby-statistics (2.1.3)
ruby-vips (2.1.3)
ruby-vips (2.1.2)
ffi (~> 1.12)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_dep (1.5.0)
ruby_parser (3.17.0)
ruby_parser (3.16.0)
sexp_processor (~> 4.15, >= 4.15.1)
rubyzip (2.3.2)
rugged (1.2.0-x86_64-linux-musl)
rugged (1.1.1-x86_64-linux-musl)
safe_yaml (1.0.6)
safely_block (0.3.0)
errbase (>= 0.1.1)
@ -539,7 +537,7 @@ GEM
simpleidn (0.2.1)
unf (~> 0.1.4)
sourcemap (0.1.1)
spree-api-client (0.2.3)
spree-api-client (0.2.2)
fast_blank (~> 1)
httparty (~> 0.18.0)
spring (2.1.1)
@ -612,7 +610,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webpacker (5.4.2)
webpacker (5.4.0)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@ -679,7 +677,6 @@ DEPENDENCIES
minima
mobility
net-ssh
nokogiri (~> 1.11.0)
pg
pg_search
prometheus_exporter

View file

@ -102,20 +102,20 @@ save: ## Subir la imagen Docker al nodo delegado
@echo -e "\a"
ota-js: assets ## Actualizar Javascript en el nodo delegado
rsync -avi --chown=:82 --delete-after public/ root@$(delegate):/srv/sutty/srv/http/data/_$(public)/
rsync -avi --chown=:82 --delete-after public/ root@$(delegate):/srv/sutty/srv/http/data/_public/_staging/
sudo chgrp -R 82 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"
ota: ## Actualizar Rails en el nodo delegado
umask 022; git format-patch $(commit)
scp ./0*.patch root@$(delegate):/tmp/
ssh root@$(delegate) mkdir -p /tmp/patches-$(commit)/
scp ./0*.patch root@$(delegate):/tmp/patches-$(commit)/
scp ./ota.sh root@$(delegate):/tmp/
ssh root@$(delegate) docker cp /tmp/patches-$(shell echo $(commit) | cut -d / -f 1) $(container):/tmp/
ssh root@$(delegate) docker cp /tmp/ota.sh $(container):/usr/local/bin/ota
ssh root@$(delegate) docker exec $(container) apk add --no-cache patch
ssh root@$(delegate) docker exec $(container) ota $(commit)
scp ./0*.patch $(delegate):/tmp/
ssh $(delegate) mkdir -p /tmp/patches-$(commit)/
scp ./0*.patch $(delegate):/tmp/patches-$(commit)/
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

View file

@ -11,58 +11,20 @@ module Api
private
# Por retrocompatibilidad con la forma en que estábamos
# gestionando los hostnames históricamente, necesitamos poder
# encontrar el sitio a partir de cualquiera de sus hostnames.
# Realiza la inversa de Site#hostname
#
# Aunque en realidad con el hostname a partir del Origin nos
# bastaría.
#
# TODO: Generar API v2 que use solo el hostname y no haya que
# pasar site_id como parámetro redundante.
# TODO: El sitio sutty.nl no aplica a ninguno de estos y le
# tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test.
def site_id
@site_id ||= Deploy.site_name_from_hostname(params[:site_id])
@site_id ||= if params[:site_id].end_with? Site.domain
params[:site_id].sub(/\.#{Site.domain}\z/, '')
else
params[:site_id] + '.'
end
end
# @return [Site]
def site
@site ||= Site.find_by_name(site_id)
end
# Obtiene el hostname desde el Origin, con el hostname local como
# fallback.
#
# @return [String]
def origin_hostname
URI.parse(origin || origin_from_referer).host
rescue StandardError
"#{site_id}.#{Site.domain}"
end
# Referer
#
# @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer}
# @return [String,Nil]
def referer
request.referer
end
alias referrer referer
# Origin
#
# @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin}
# @return [String,Nil]
def origin
request.origin
end
# Genera un header Origin a partir del Referer si existe.
#
# @return [String,Nil]
def origin_from_referer
return if referer.blank?
referer.split('/', 4).tap { |u| u.pop if u.size > 3 }.join('/')
request.headers['Origin']
end
# Los navegadores antiguos no envían Origin

View file

@ -23,7 +23,7 @@ module Api
contact_params.to_h.symbolize_keys,
params[:redirect]
redirect_to params[:redirect] || referer || site.url
redirect_to params[:redirect] || origin.to_s
end
private

View file

@ -44,10 +44,11 @@ module Api
# Genera el Origin correcto a partir de la URL del sitio.
#
# @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin}
# En desarrollo devuelve el Origin enviado.
#
# @return [String]
def return_origin
site&.deploys&.find_by_hostname(origin_hostname)&.url
Rails.env.production? ? Site.find_by(name: site_id).url : origin
end
# La cookie no es accesible a través de JS y todo su contenido
@ -58,8 +59,6 @@ module Api
# TODO: Volver configurable por sitio
expires = ENV.fetch('COOKIE_DURATION', '30').to_i.minutes
# TODO: ¿Son necesarios estos headers en la descarga de una
# imagen? ¿No será mejor moverlos al envío de datos?
headers['Access-Control-Allow-Origin'] = return_origin
headers['Access-Control-Allow-Credentials'] = true
headers['Vary'] = 'Origin'

View file

@ -17,7 +17,7 @@ module Api
site.touch if service.create_anonymous.persisted?
# Redirigir a la URL de agradecimiento
redirect_to params[:redirect_to] || referer || site.url
redirect_to params[:redirect_to] || origin.to_s
end
private

View file

@ -85,9 +85,7 @@ module Api
# XXX: Este header se puede falsificar de todas formas pero al
# menos es una trampa.
def site_is_origin?
return if site.urls(slash: false).any? do |u|
(origin || origin_from_referer).to_s.start_with? u
end
return if origin? && site.urls(slash: false).any? { |u| origin.to_s.start_with? u }
@reason = 'site_is_not_origin'
render plain: Rails.env.production? ? nil : @reason, status: :precondition_required
@ -118,6 +116,11 @@ module Api
raise NotImplementedError
end
# Encuentra el sitio o devuelve nulo
def site
@site ||= Site.find_by(name: site_id)
end
# Genera un registro con información básica para debug, quizás no
# quede asociado a ningún sitio.
#

View file

@ -9,14 +9,14 @@ module Api
# Lista de nombres de dominios a emitir certificados
def index
render json: Deploy.all.pluck(:hostname)
render json: sites_names + alternative_names + api_names
end
# Sitios con hidden service de Tor
#
# @return [Array] lista de nombres de sitios sin onion aun
def hidden_services
render json: DeployHiddenService.temporary.includes(:site).pluck(:name)
render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name)
end
# Tor va a enviar el onion junto con el nombre del sitio y tenemos
@ -25,8 +25,10 @@ module Api
# @params [String] name
# @params [String] onion
def add_onion
if (site = Site.find_by_name(params[:name]))
usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
site = Site.find_by(name: params[:name])
if site
usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie,
params: params
service.add_onion
@ -34,6 +36,28 @@ module Api
head :ok
end
private
# Nombres de los sitios
def sites_names
Site.all.order(:name).pluck(:name)
end
# Dominios alternativos
def alternative_names
DeployAlternativeDomain.all.map(&:hostname)
end
# Obtener todos los sitios con API habilitada, es decir formulario
# de contacto y/o colaboración anónima.
#
# TODO: Optimizar
def api_names
Site.where(contact: true)
.or(Site.where(colaboracion_anonima: true))
.select("'api.' || name as name").map(&:name)
end
end
end
end

View file

@ -3,21 +3,18 @@
# Forma de ingreso a Sutty
class ApplicationController < ActionController::Base
include ExceptionHandler
include Pundit
protect_from_forgery with: :null_session, prepend: true
before_action :prepare_exception_notifier
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :redirect_to_site_name!, only: %i[index show edit new], if: :site_id_contains_hostname?
around_action :set_locale
rescue_from Pundit::NilPolicyError, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found
rescue_from ActionController::ParameterMissing, with: :page_not_found
before_action do
Rack::MiniProfiler.authorize_request if Rails.env.development?
Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl'))
end
# No tenemos índice de sutty, vamos directamente a ver el listado de
@ -32,11 +29,15 @@ class ApplicationController < ActionController::Base
/[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/ =~ string
end
# Encontrar un sitio por su nombre.
# Encontrar un sitio por su nombre
def find_site
current_usuarie&.sites&.find_by_name(site_id).tap do |site|
raise SiteNotFound unless site
id = params[:site_id] || params[:id]
unless (site = current_usuarie.sites.find_by_name(id))
raise SiteNotFound
end
site
end
# Devuelve el idioma actual y si no lo encuentra obtiene uno por
@ -61,54 +62,6 @@ class ApplicationController < ActionController::Base
render 'application/page_not_found', status: :not_found
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
# Retrocompatibilidad con sitios cuyo nombre era su hostname.
#
# @see Deploy
def site_id_contains_hostname?
site_id&.end_with? '.'
end
# Redirigir a la misma URL con el site_id cambiado.
#
# TODO: Eliminar cuando detectemos que no hay más redirecciones.
def redirect_to_site_name!
params.permit!
params[:site_id] = Deploy.site_name_from_hostname(site_id[0..-2])
redirect_to params, status: :moved_permanently
end
# Los controladores dentro de SitesController van a usar site_id
# mientras que SiteController va a usar ID.
#
# @see SitesController
# @return [String,Nil]
def site_id
@site_id ||= params[:site_id]
end
# El sitio actual
#
# @return [Site]
def site
@site ||= find_site
end
protected
def configure_permitted_parameters

View file

@ -7,7 +7,7 @@ class CollaborationsController < ApplicationController
include Pundit
def collaborate
@site = find_site
@site = Site.find_by_name(params[:site_id])
authorize Collaboration.new(@site)
@invitade = current_usuarie || @site.usuaries.build
@ -21,7 +21,7 @@ class CollaborationsController < ApplicationController
#
# * Si le usuarie existe y no está logueade, pedirle la contraseña
def accept_collaboration
@site = find_site
@site = Site.find_by_name(params[:site_id])
authorize Collaboration.new(@site)
@invitade = current_usuarie

View file

@ -1,194 +0,0 @@
# 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,6 +2,9 @@
# Controlador para artículos
class PostsController < ApplicationController
include Pundit
rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie!
# TODO: Traer los comunes desde ApplicationController

View file

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

View file

@ -2,6 +2,9 @@
# Controlador de sitios
class SitesController < ApplicationController
include Pundit
rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie!
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
@ -136,10 +139,8 @@ class SitesController < ApplicationController
private
# En los controladores dentro de este controlador vamos a usar :id
# para obtener el nombre.
def site_id
@site_id ||= params[:site_id] || params[:id]
def site
@site ||= find_site
end
def site_params

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
# Estadísticas del sitio
class StatsController < ApplicationController
include Pundit
before_action :authenticate_usuarie!
def index
@site = find_site
authorize SiteStat.new(@site)
# Solo queremos el promedio de tiempo de compilación, no de
# instalación de dependencias.
stats = @site.build_stats.jekyll
@build_avg = stats.average(:seconds).to_f.round(2)
@build_max = stats.maximum(:seconds).to_f.round(2)
end
end

View file

@ -1,6 +1,315 @@
/// @ts-ignore
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
import { storeContent, restoreContent, forgetContent } from "editor/storage";
import {
isDirectChild,
moveChildren,
safeGetSelection,
safeGetRangeAt,
setAuxiliaryToolbar,
parentBlockNames,
clearSelected,
} from "editor/utils";
import { types, getValidChildren, getType } from "editor/types";
import { setupButtons as setupMarksButtons } from "editor/types/marks";
import { setupButtons as setupBlocksButtons } from "editor/types/blocks";
import { setupButtons as setupParentBlocksButtons } from "editor/types/parentBlocks";
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from "editor/types/link";
import {
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
setupButtons as setupMultimediaButtons,
} from "editor/types/multimedia";
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
// Esta funcion corrije errores que pueden haber como:
// * que un nodo que no tiene 'text' permitido no tenga children (se les
// inserta un allowedChildren[0])
// * TODO: que haya una imágen sin <figure> o que no esté como bloque (se ponen
// después del bloque en el que están como bloque de por si)
// * convierte <i> y <b> en <em> y <strong>
// Lo hace para que siga la estructura del documento y que no se borren por
// cleanContent luego.
function fixContent(editor: Editor, node: Element = editor.contentEl): void {
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
node.parentElement?.removeChild(node);
return;
}
if (node.tagName === "I") {
const el = document.createElement("em");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node.tagName === "B") {
const el = document.createElement("strong");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node instanceof HTMLImageElement) {
node.dataset.multimediaInner = "";
const figureEl = types.multimedia.create(editor);
let targetEl = node.parentElement;
if (!targetEl) throw new Error("No encontré lx objetivo");
while (true) {
const type = getType(targetEl);
if (!type) throw new Error("lx objetivo tiene tipo");
if (type.type.allowedChildren.includes("multimedia")) break;
if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
targetEl = targetEl.parentElement;
}
let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
if (!parentEl) throw new Error("no encontré lx pariente");
targetEl.insertBefore(figureEl, parentEl);
const innerEl = figureEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error("Raro.");
figureEl.replaceChild(node, innerEl);
node = figureEl;
}
const _type = getType(node);
if (!_type) return;
const { typeName, type } = _type;
if (type.allowedChildren !== "ignore-children") {
const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel);
if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== "string") {
const el = type.handleEmpty.create(editor);
// mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá
moveChildren(node, el, null);
node.appendChild(el);
if (range?.intersectsNode(node)) sel?.collapse(el);
}
}
for (const child of node.childNodes) {
if (!(child instanceof Element)) continue;
fixContent(editor, child);
}
}
}
// Esta funcion hace que los elementos del editor sigan la estructura.
// TODO: nos falta borrar atributos (style, y básicamente cualquier otra cosa)
// Edge cases:
// * no borramos los <br> por que se requieren para que los navegadores
// funcionen bien al escribir. no se deberían mostrar de todas maneras
function cleanContent(editor: Editor, node: Element = editor.contentEl): void {
const _type = getType(node);
if (!_type) {
node.parentElement?.removeChild(node);
return;
}
const { type } = _type;
if (type.allowedChildren !== "ignore-children") {
for (const child of node.childNodes) {
if (
child.nodeType === Node.TEXT_NODE &&
!type.allowedChildren.includes("text")
) {
node.removeChild(child);
continue;
}
if (!(child instanceof Element)) continue;
const childType = getType(child);
if (childType?.typeName === "br") continue;
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
// XXX: esto extrae las cosas de adentro para que no sea destructivo
moveChildren(child, node, child);
node.removeChild(child);
return;
}
cleanContent(editor, child);
}
// solo contar children válido para ese nodo
const validChildrenLength = getValidChildren(node, type).length;
const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel);
if (
type.handleEmpty === "remove" &&
validChildrenLength == 0
//&& (!range || !range.intersectsNode(node))
) {
node.parentNode?.removeChild(node);
return;
}
}
}
function routine(editor: Editor): void {
try {
fixContent(editor);
cleanContent(editor);
storeContent(editor);
editor.htmlEl.value = editor.contentEl.innerHTML;
} catch (error) {
console.error("Hubo un problema corriendo la rutina", editor, error);
}
}
export interface Editor {
editorEl: HTMLElement;
toolbarEl: HTMLElement;
toolbar: {
auxiliary: {
mark: {
parentEl: HTMLElement;
colorEl: HTMLInputElement;
textColorEl: HTMLInputElement;
};
multimedia: {
parentEl: HTMLElement;
fileEl: HTMLInputElement;
uploadEl: HTMLButtonElement;
altEl: HTMLInputElement;
removeEl: HTMLButtonElement;
};
link: {
parentEl: HTMLElement;
urlEl: HTMLInputElement;
};
};
};
contentEl: HTMLElement;
wordAlertEl: HTMLElement;
htmlEl: HTMLTextAreaElement;
}
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
const el = parentEl.querySelector<T>(selector);
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
return el;
}
function setupEditor(editorEl: HTMLElement): void {
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
document.execCommand("defaultParagraphSeparator", false, "p");
const editor: Editor = {
editorEl,
toolbarEl: getSel(editorEl, ".editor-toolbar"),
toolbar: {
auxiliary: {
mark: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"),
colorEl: getSel(
editorEl,
"[data-editor-auxiliary=mark] [name=mark-color]"
),
textColorEl: getSel(
editorEl,
"[data-editor-auxiliary=mark] [name=mark-text-color]"
),
},
multimedia: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"),
fileEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-file]"
),
uploadEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]"
),
altEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-alt]"
),
removeEl: getSel(
editorEl,
"[data-editor-auxiliary=multimedia] [name=multimedia-remove]"
),
},
link: {
parentEl: getSel(editorEl, "[data-editor-auxiliary=link]"),
urlEl: getSel(
editorEl,
"[data-editor-auxiliary=link] [name=link-url]"
),
},
},
},
contentEl: getSel(editorEl, ".editor-content"),
wordAlertEl: getSel(editorEl, ".editor-aviso-word"),
htmlEl: getSel(editorEl, "textarea"),
};
console.debug("iniciando editor", editor);
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
// de última edición podríamos saber si el artículo fue editado
// después o la versión local es la última.
//
// TODO: Preguntar si se lo quiere recuperar.
restoreContent(editor);
// Word alert
editor.contentEl.addEventListener("paste", () => {
editor.wordAlertEl.style.display = "block";
});
// Setup routine listeners
const observer = new MutationObserver(() => routine(editor));
observer.observe(editor.contentEl, {
childList: true,
attributes: true,
subtree: true,
characterData: true,
});
document.addEventListener("selectionchange", () => routine(editor));
// Capture onClick
editor.contentEl.addEventListener(
"click",
(event) => {
const target = event.target! as Element;
const type = getType(target);
if (!type || !type.type.onClick) {
setAuxiliaryToolbar(editor, null);
clearSelected(editor);
return true;
}
type.type.onClick(editor, target);
return false;
},
true
);
// Clean seleted
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
// Setup botones
setupMarksButtons(editor);
setupBlocksButtons(editor);
setupParentBlocksButtons(editor);
setupMultimediaButtons(editor);
setupLinkAuxiliaryToolbar(editor);
setupMultimediaAuxiliaryToolbar(editor);
setupMarkAuxiliaryToolbar(editor);
// Finally...
routine(editor);
}
document.addEventListener("turbolinks:load", () => {
const flash = document.querySelector<HTMLElement>(".js-flash");
@ -21,15 +330,10 @@ document.addEventListener("turbolinks:load", () => {
".editor[data-editor]"
)) {
try {
new SuttyEditor({
target: editorEl,
props: {
textareaEl: editorEl.parentElement!.querySelector("textarea"),
},
});
setupEditor(editorEl);
} catch (error) {
console.error(error);
alert(error);
// TODO: mostrar error
console.error("no se pudo iniciar el editor, error completo", error);
}
}
});

View file

@ -0,0 +1,38 @@
import { Editor } from "editor/editor";
/*
* Guarda una copia local de los cambios para poder recuperarlos
* después.
*
* Usamos la URL completa sin anchors.
*/
function getStorageKey(editor: Editor): string {
const keyEl = editor.editorEl.querySelector<HTMLInputElement>(
'[data-target="storage-key"]'
);
if (!keyEl)
throw new Error("No encuentro la llave para guardar los artículos");
return keyEl.value;
}
export function forgetContent(storedKey: string): void {
window.localStorage.removeItem(storedKey);
}
export function storeContent(editor: Editor): void {
if (editor.contentEl.innerText.trim().length === 0) return;
window.localStorage.setItem(
getStorageKey(editor),
editor.contentEl.innerHTML
);
}
export function restoreContent(editor: Editor): void {
const content = window.localStorage.getItem(getStorageKey(editor));
if (!content) return;
if (content.trim().length === 0) return;
editor.contentEl.innerHTML = content;
}

View file

@ -0,0 +1,140 @@
import { Editor } from "editor/editor";
import { marks } from "editor/types/marks";
import { blocks, li, EditorBlock } from "editor/types/blocks";
import { parentBlocks } from "editor/types/parentBlocks";
import { multimedia } from "editor/types/multimedia";
import {
blockNames,
parentBlockNames,
safeGetRangeAt,
safeGetSelection,
} from "editor/utils";
export interface EditorNode {
selector: string;
// la string es el nombre en la gran lista de types O 'text'
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
// quizás podemos hacer que esto sea una función que retorna bool
allowedChildren: string[] | "ignore-children";
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
// * si es 'remove', sacamos el coso si está vacío.
// ej: strong: { handleNothing: 'remove' }
// * si es un block, insertamos el bloque y movemos la selección ahí
// ej: ul: { handleNothing: li }
handleEmpty: "do-nothing" | "remove" | EditorBlock;
// esta función puede ser llamada para cosas que no necesariamente sea la
// creación del nodo con el botón; por ejemplo, al intentar recuperar
// el formato. esto es importante por que, por ejemplo, no deberíamos
// cambiar la selección acá.
create: (editor: Editor) => HTMLElement;
onClick?: (editor: Editor, target: Element) => void;
}
export const types: { [propName: string]: EditorNode } = {
...marks,
...blocks,
li,
...parentBlocks,
contentEl: {
selector: ".editor-content",
allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"],
handleEmpty: blocks.paragraph,
create: () => {
throw new Error("se intentó crear contentEl");
},
},
br: {
selector: "br",
allowedChildren: [],
handleEmpty: "do-nothing",
create: () => {
throw new Error("se intentó crear br");
},
},
multimedia,
};
export function getType(
node: Element
): { typeName: string; type: EditorNode } | null {
for (let [typeName, type] of Object.entries(types)) {
if (node.matches(type.selector)) {
return { typeName, type };
}
}
return null;
}
// encuentra el primer pariente que pueda tener al type, y retorna un array
// donde
// array[0] = elemento que matchea el type
// array[array.len - 1] = primer elemento seleccionado
export function getValidParentInSelection(args: {
editor: Editor;
type: string;
}): Element[] {
const sel = safeGetSelection(args.editor);
if (!sel) throw new Error("No se donde insertar esto");
const range = safeGetRangeAt(sel);
if (!range) throw new Error("No se donde insertar esto");
let list: Element[] = [];
if (!sel.anchorNode) {
throw new Error("No se donde insertar esto");
} else if (sel.anchorNode instanceof Element) {
list = [sel.anchorNode];
} else if (sel.anchorNode.parentElement) {
list = [sel.anchorNode.parentElement];
} else {
throw new Error("No se donde insertar esto");
}
while (true) {
const el = list[0];
if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl)
throw new Error("No se donde insertar esto");
const type = getType(el);
if (type) {
//if (type.typeName === 'contentEl') break
//if (parentBlockNames.includes(type.typeName)) break
if (
type.type.allowedChildren instanceof Array &&
type.type.allowedChildren.includes(args.type)
)
break;
}
if (el.parentElement) {
list = [el.parentElement, ...list];
} else {
throw new Error("No se donde insertar esto");
}
}
return list;
}
export function getValidChildren(node: Element, type: EditorNode): Node[] {
if (type.allowedChildren === "ignore-children")
throw new Error(
"se llamó a getValidChildren con un type que no lo permite!"
);
return [...node.childNodes].filter((n) => {
// si permite texto y esto es un texto, es válido
if (n.nodeType === Node.TEXT_NODE)
return type.allowedChildren.includes("text") && n.textContent?.length;
// si no es un elemento, no es válido
if (!(n instanceof Element)) return false;
const t = getType(n);
if (!t) return false;
return type.allowedChildren.includes(t.typeName);
});
}

View file

@ -0,0 +1,76 @@
import { Editor } from "editor/editor";
import {
safeGetSelection,
safeGetRangeAt,
moveChildren,
markNames,
blockNames,
parentBlockNames,
} from "editor/utils";
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
export interface EditorBlock extends EditorNode {}
function makeBlock(tag: string): EditorBlock {
return {
selector: tag,
allowedChildren: [...markNames, "text"],
handleEmpty: "do-nothing",
create: () => document.createElement(tag),
};
}
export const li: EditorBlock = makeBlock("li");
// XXX: si agregás algo acá, agregalo a blockNames
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
export const blocks: { [propName: string]: EditorBlock } = {
paragraph: makeBlock("p"),
h1: makeBlock("h1"),
h2: makeBlock("h2"),
h3: makeBlock("h3"),
h4: makeBlock("h4"),
h5: makeBlock("h5"),
h6: makeBlock("h6"),
unordered_list: {
...makeBlock("ul"),
allowedChildren: ["li"],
handleEmpty: li,
},
ordered_list: {
...makeBlock("ol"),
allowedChildren: ["li"],
handleEmpty: li,
},
};
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(blocks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="block-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const list = getValidParentInSelection({ editor, type: name });
// No borrar cosas como multimedia
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
return;
}
let replacementType = list[1].matches(type.selector)
? blocks.paragraph
: type;
const el = replacementType.create(editor);
replacementType.onClick && replacementType.onClick(editor, el);
moveChildren(list[1], el, null);
list[0].replaceChild(el, list[1]);
window.getSelection()?.collapse(el);
return false;
});
}
}

View file

@ -0,0 +1,37 @@
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
function select(editor: Editor, el: HTMLAnchorElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
editor.toolbar.auxiliary.link.urlEl.value = el.href;
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl);
}
export const link: EditorNode = {
selector: "a",
allowedChildren: [...markNames.filter((n) => n !== "link"), "text"],
handleEmpty: "remove",
create: () => document.createElement("a"),
onClick(editor, el) {
if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no");
select(editor, el);
},
};
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => {
const url = editor.toolbar.auxiliary.link.urlEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
"a[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el link para setear el enlace");
selectedEl.href = url;
});
editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => {
if (event.keyCode == 13) event.preventDefault();
});
}

View file

@ -0,0 +1,66 @@
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);
// https://stackoverflow.com/a/3627747
// TODO: cambiar por una solución más copada
function rgbToHex(rgb: string): string {
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (!matches) throw new Error("no pude parsear el rgb()");
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
}
function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor
? rgbToHex(el.style.backgroundColor)
: "#f206f9";
editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color
? rgbToHex(el.style.color)
: "#000000";
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl);
}
export const mark: EditorNode = {
selector: "mark",
allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"],
handleEmpty: "remove",
create: () => document.createElement("mark"),
onClick(editor, el) {
if (!(el instanceof HTMLElement)) throw new Error("oh no");
select(editor, el);
},
};
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => {
const color = editor.toolbar.auxiliary.mark.colorEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"mark[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el mark para setear el color");
selectedEl.style.backgroundColor = color;
});
editor.toolbar.auxiliary.mark.textColorEl.addEventListener(
"input",
(event) => {
const color = editor.toolbar.auxiliary.mark.textColorEl.value;
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"mark[data-editor-selected]"
);
if (!selectedEl)
throw new Error(
"No pude encontrar el mark para setear el color del text"
);
selectedEl.style.color = color;
}
);
editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => {
if (event.keyCode == 13) event.preventDefault();
});
}

View file

@ -0,0 +1,102 @@
import { Editor } from "editor/editor";
import { EditorNode } from "editor/types";
import {
safeGetSelection,
safeGetRangeAt,
moveChildren,
markNames,
} from "editor/utils";
import { link } from "editor/types/link";
import { mark } from "editor/types/mark";
function makeMark(name: string, tag: string): EditorNode {
return {
selector: tag,
allowedChildren: [...markNames.filter((n) => n !== name), "text"],
handleEmpty: "remove",
create: () => document.createElement(tag),
};
}
// XXX: si agregás algo acá, agregalo a markNames
export const marks: { [propName: string]: EditorNode } = {
bold: makeMark("bold", "strong"),
italic: makeMark("italic", "em"),
deleted: makeMark("deleted", "del"),
underline: makeMark("underline", "u"),
sub: makeMark("sub", "sub"),
super: makeMark("super", "sup"),
mark,
link,
small: makeMark("small", "small"),
};
function recursiveFilterSelection(
node: Element,
selection: Selection,
selector: string
): Element[] {
let output: Element[] = [];
for (const child of [...node.children]) {
if (child.matches(selector) && selection.containsNode(child))
output.push(child);
output = [
...output,
...recursiveFilterSelection(child, selection, selector),
];
}
return output;
}
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(marks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="mark-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const sel = safeGetSelection(editor);
if (!sel) return;
const range = safeGetRangeAt(sel);
if (!range) return;
let parentEl = range.commonAncestorContainer;
while (!(parentEl instanceof Element)) {
if (!parentEl.parentElement) return;
parentEl = parentEl.parentElement;
}
const existingMarks = recursiveFilterSelection(
parentEl,
sel,
type.selector
);
console.debug("marks encontradas:", existingMarks);
if (existingMarks.length > 0) {
const mark = existingMarks[0];
if (!mark.parentElement) throw new Error(":/");
moveChildren(mark, mark.parentElement, mark);
mark.parentElement.removeChild(mark);
} else {
if (range.commonAncestorContainer === editor.contentEl)
// TODO: mostrar error
return console.error(
"No puedo marcar cosas a través de distintos bloques!"
);
const tagEl = type.create(editor);
type.onClick && type.onClick(editor, tagEl);
tagEl.appendChild(range.extractContents());
range.insertNode(tagEl);
range.selectNode(tagEl);
}
return false;
});
}
}

View file

@ -0,0 +1,230 @@
import * as ActiveStorage from "@rails/activestorage";
import { Editor } from "editor/editor";
import { EditorNode, getValidParentInSelection } from "editor/types";
import {
safeGetSelection,
safeGetRangeAt,
markNames,
parentBlockNames,
setAuxiliaryToolbar,
clearSelected,
} from "editor/utils";
function uploadFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const upload = new ActiveStorage.DirectUpload(
file,
origin + "/rails/active_storage/direct_uploads"
);
upload.create((error: any, blob: any) => {
if (error) {
reject(error);
} else {
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
resolve(url);
}
});
});
}
function getAlt(multimediaInnerEl: HTMLElement): string | null {
switch (multimediaInnerEl.tagName) {
case "VIDEO":
case "AUDIO":
return multimediaInnerEl.getAttribute("aria-label");
case "IMG":
return (multimediaInnerEl as HTMLImageElement).alt;
case "IFRAME":
return multimediaInnerEl.title;
default:
throw new Error("no pude conseguir el alt");
}
}
function setAlt(multimediaInnerEl: HTMLElement, value: string): void {
switch (multimediaInnerEl.tagName) {
case "VIDEO":
case "AUDIO":
multimediaInnerEl.setAttribute("aria-label", value);
break;
case "IMG":
(multimediaInnerEl as HTMLImageElement).alt = value;
break;
case "IFRAME":
multimediaInnerEl.title = value;
break;
default:
throw new Error("no pude setear el alt");
}
}
function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor);
el.dataset.editorSelected = "";
const innerEl = el.querySelector<HTMLElement>("[data-multimedia-inner]");
if (!innerEl) throw new Error("No hay multimedia válida");
if (innerEl.tagName === "P") {
editor.toolbar.auxiliary.multimedia.altEl.value = "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
} else {
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
}
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl);
}
export const multimedia: EditorNode = {
selector: "figure[data-multimedia]",
allowedChildren: "ignore-children",
handleEmpty: "remove",
create: () => {
const figureEl = document.createElement("figure");
figureEl.dataset.multimedia = "";
figureEl.contentEditable = "false";
const placeholderEl = document.createElement("p");
placeholderEl.dataset.multimediaInner = "";
// TODO i18n
placeholderEl.append("¡Clickeame para subir un archivo!");
figureEl.appendChild(placeholderEl);
const descriptionEl = document.createElement("figcaption");
descriptionEl.contentEditable = "true";
// TODO i18n
descriptionEl.append("Escribí acá la descripción del archivo.");
figureEl.appendChild(descriptionEl);
return figureEl;
},
onClick(editor, el) {
if (!(el instanceof HTMLElement)) throw new Error("oh no");
select(editor, el);
},
};
function createElementWithFile(url: string, type: string): HTMLElement {
if (type.match(/^image\/.+$/)) {
const el = document.createElement("img");
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^video\/.+$/)) {
const el = document.createElement("video");
el.controls = true;
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^audio\/.+$/)) {
const el = document.createElement("audio");
el.controls = true;
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else if (type.match(/^application\/pdf$/)) {
const el = document.createElement("iframe");
el.dataset.multimediaInner = "";
el.src = url;
return el;
} else {
// TODO: chequear si el archivo es válido antes de subir
throw new Error("Tipo de archivo no reconocido");
}
}
export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener(
"click",
(event) => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
if (!files || !files.length)
throw new Error("no hay archivos para subir");
const file = files[0];
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el elemento para setear el archivo");
selectedEl.dataset.editorLoading = "";
uploadFile(file)
.then((url) => {
const innerEl = selectedEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error("No hay multimedia a reemplazar");
const el = createElementWithFile(url, file.type);
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value);
selectedEl.replaceChild(el, innerEl);
select(editor, selectedEl);
delete selectedEl.dataset.editorError;
})
.catch((err) => {
console.error(err);
// TODO: mostrar error
selectedEl.dataset.editorError = "";
})
.finally(() => {
delete selectedEl.dataset.editorLoading;
});
}
);
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener(
"click",
(event) => {
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el elemento para borrar");
selectedEl.parentElement?.removeChild(selectedEl);
setAuxiliaryToolbar(editor, null);
}
);
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
"input",
(event) => {
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
"figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el multimedia para setear el alt");
const innerEl = selectedEl.querySelector<HTMLElement>(
"[data-multimedia-inner]"
);
if (!innerEl) throw new Error("No hay multimedia a para setear el alt");
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value);
}
);
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
"keydown",
(event) => {
if (event.keyCode == 13) event.preventDefault();
}
);
}
export function setupButtons(editor: Editor): void {
const buttonEl = editor.toolbarEl.querySelector(
'[data-editor-button="multimedia"]'
);
if (!buttonEl) throw new Error("No encontre el botón de multimedia");
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const list = getValidParentInSelection({ editor, type: "multimedia" });
const el = multimedia.create(editor);
list[0].insertBefore(el, list[1].nextElementSibling);
select(editor, el);
return false;
});
}

View file

@ -0,0 +1,78 @@
import { Editor } from "editor/editor";
import {
safeGetSelection,
safeGetRangeAt,
moveChildren,
blockNames,
parentBlockNames,
} from "editor/utils";
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
function makeParentBlock(
tag: string,
create: EditorNode["create"]
): EditorNode {
return {
selector: tag,
allowedChildren: [...blockNames, "multimedia"],
handleEmpty: "remove",
create,
};
}
// TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = {
left: makeParentBlock("div[data-align=left]", () => {
const el = document.createElement("div");
el.dataset.align = "left";
el.style.textAlign = "left";
return el;
}),
center: makeParentBlock("div[data-align=center]", () => {
const el = document.createElement("div");
el.dataset.align = "center";
el.style.textAlign = "center";
return el;
}),
right: makeParentBlock("div[data-align=right]", () => {
const el = document.createElement("div");
el.dataset.align = "right";
el.style.textAlign = "right";
return el;
}),
};
export function setupButtons(editor: Editor): void {
for (const [name, type] of Object.entries(parentBlocks)) {
const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="parentBlock-${name}"]`
);
if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
// TODO: Esto solo mueve el bloque en el que está el final de la selección
// (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
// el parentBlock)
const list = getValidParentInSelection({ editor, type: name });
const replacementEl = type.create(editor);
if (list[0] == editor.contentEl) {
// no está en un parentBlock
editor.contentEl.insertBefore(replacementEl, list[1]);
replacementEl.appendChild(list[1]);
} else {
// está en un parentBlock
moveChildren(list[0], replacementEl, null);
editor.contentEl.replaceChild(replacementEl, list[0]);
}
window.getSelection()?.collapse(replacementEl);
return false;
});
}
}

View file

@ -0,0 +1,101 @@
import { Editor } from "editor/editor";
export const blockNames = [
"paragraph",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"unordered_list",
"ordered_list",
];
export const markNames = [
"bold",
"italic",
"deleted",
"underline",
"sub",
"super",
"mark",
"link",
"small",
];
export const parentBlockNames = ["left", "center", "right"];
export function moveChildren(from: Element, to: Element, toRef: Node | null) {
while (from.firstChild) to.insertBefore(from.firstChild, toRef);
}
export function isDirectChild(node: Node, supposedChild: Node): boolean {
for (const child of node.childNodes) {
if (child == supposedChild) return true;
}
return false;
}
export function safeGetSelection(editor: Editor): Selection | null {
const sel = window.getSelection();
if (!sel) return null;
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
// deberíamos mostrar un error?
if (
!editor.contentEl.contains(sel.anchorNode) ||
!editor.contentEl.contains(sel.focusNode) ||
sel.anchorNode == editor.contentEl ||
sel.focusNode == editor.contentEl
)
return null;
return sel;
}
export function safeGetRangeAt(selection: Selection, num = 0): Range | null {
try {
return selection.getRangeAt(num);
} catch (error) {
return null;
}
}
interface SplitNode {
range: Range;
node: Node;
}
export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] {
const [left, right] = [
{ range: document.createRange(), node: node.cloneNode(false) },
{ range: document.createRange(), node: node.cloneNode(false) },
];
if (node.firstChild) left.range.setStartBefore(node.firstChild);
left.range.setEnd(range.startContainer, range.startOffset);
left.range.surroundContents(left.node);
right.range.setStart(range.endContainer, range.endOffset);
if (node.lastChild) right.range.setEndAfter(node.lastChild);
right.range.surroundContents(right.node);
if (!node.parentElement)
throw new Error("No pude separar los nodos por que no tiene parentNode");
moveChildren(node, node.parentElement, node);
node.parentElement.removeChild(node);
return [left, right];
}
export function setAuxiliaryToolbar(
editor: Editor,
bar: HTMLElement | null
): void {
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
delete parentEl.dataset.editorAuxiliaryActive;
}
if (bar) bar.dataset.editorAuxiliaryActive = "active";
}
export function clearSelected(editor: Editor): void {
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
}

View file

@ -4,63 +4,70 @@
class DeployJob < ApplicationJob
class DeployException < StandardError; end
attr_reader :site, :deployed
# rubocop:disable Metrics/MethodLength
def perform(site_id, notify = true, time = Time.now)
def perform(site, notify = true, time = Time.now)
ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site_id)
@deployed = {}
@site = Site.find(site)
# Si ya hay una tarea corriendo, aplazar esta. Si estuvo
# esperando más de 10 minutos, recuperar el estado anterior.
#
# Como el trabajo actual se aplaza al siguiente, arrastrar la
# hora original para poder ir haciendo timeouts.
if site.building?
if @site.building?
if 10.minutes.ago >= time
site.update status: 'waiting'
@site.update status: 'waiting'
raise DeployException,
"#{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
DeployJob.perform_in(60, site_id, notify, time)
DeployJob.perform_in(60, site, notify, time)
return
end
site.update status: 'building'
@site.update status: 'building'
# Asegurarse que DeployLocal sea el primero!
deployed[:deploy_local] = site.deploy_local.deploy
@deployed = { deploy_local: deploy_locally }
deploy_others if deployed[:deploy_local]
# No es opcional
unless @deployed[:deploy_local]
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea
raise DeployException, deploy_local.build_stats.last.log
end
deploy_others
# Volver a la espera
site.update status: 'waiting'
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea para enterarnos.
raise DeployException, site.deploy_local.build_stats.last.log unless deployed[:deploy_local]
end
end
# rubocop:enable Metrics/MethodLength
private
# Correr todas las tareas que no sean el deploy local.
def deploy_local
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end
def deploy_locally
deploy_local.deploy
end
def deploy_others
site.deploys.where.not(type: 'DeployLocal').find_each do |d|
deployed[d.type.underscore.to_sym] = d.deploy
@site.deploys.where.not(type: 'DeployLocal').find_each do |d|
@deployed[d.type.underscore.to_sym] = d.deploy
end
end
# Notificar a todes les usuaries no temporales.
#
# TODO: Poder configurar quiénes quieren recibir notificaciones.
def notify_usuaries
site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie|
DeployMailer.with(usuarie: usuarie, site: site.id)
.deployed(deployed)
@site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie|
DeployMailer.with(usuarie: usuarie, site: @site.id)
.deployed(@deployed)
.deliver_now
end
end

View file

@ -13,7 +13,7 @@ class DeployMailer < ApplicationMailer
@usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site])
@deploys = which_ones
@deploy_local = @site.deploy_local
@deploy_local = @site.deploys.find_by(type: 'DeployLocal')
# Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben

View file

@ -1,138 +1,44 @@
# frozen_string_literal: true
require 'open3'
# Este modelo implementa los distintos tipos de alojamiento que provee
# Sutty.
#
# Cuando cambia el hostname de un Deploy, generamos un
# DeployAlternativeDomain en su lugar. Esto permite que no se rompan
# links preexistentes y que el nombre no pueda ser tomado por alguien
# más.
#
# TODO: Cambiar el nombre a algo que no sea industrial/militar.
# Los datos se guardan en la tabla `deploys`. Para guardar los
# atributos, cada modelo tiene que definir su propio `store
# :attributes`.
class Deploy < ApplicationRecord
# Un sitio puede tener muchas formas de publicarse.
belongs_to :site
# Puede tener muchos access logs a través del hostname
has_many :access_logs, primary_key: 'hostname', foreign_key: 'host'
# Registro de las tareas ejecutadas
has_many :build_stats, dependent: :destroy
# Siempre generar el hostname
after_initialize :default_hostname!
# Eliminar los archivos generados por el deploy.
before_destroy :remove_destination!
# Cambiar el lugar del destino antes de guardar los cambios, para que
# el hostname anterior siga estando disponible.
before_update :rename_destination!, if: :destination_changed?
# Los hostnames alternativos se crean después de actualizar, cuando ya
# se modificó el hostname.
around_update :create_alternative_domain!, if: :destination_changed?
# Siempre tienen que pertenecer a un sitio
validates :site, presence: true
# El hostname tiene que ser único en toda la plataforma
validates :hostname, uniqueness: true
# Cada deploy puede implementar su propia validación
validates :hostname, hostname: true, unless: :implements_hostname_validation?
# Verificar que se puede cambiar de lugar el destino y no hay nada
# preexistente.
validate :destination_can_change?, if: :destination_changed?
# Retrocompatibilidad: Encuentra el site_name a partir del hostname.
#
# @return [String,Nil]
def self.site_name_from_hostname(hostname)
where(hostname: hostname).includes(:site).pluck(:name).first
end
# Detecta si el destino existe y si no es un symlink roto.
def exist?
File.exist? destination
end
# Detecta si el link está roto
def broken?
File.symlink?(destination) && !File.exist?(File.readlink(destination))
end
# Ubicación del deploy
#
# @return [String] Una ruta en el sistema de archivos
def destination
File.join(Rails.root, '_deploy', hostname)
end
# Ubicación anterior del deploy
#
# @return [String] Una ruta en el sistema de archivos
def destination_was
return destination unless will_save_change_to_hostname?
File.join(Rails.root, '_deploy', hostname_was)
end
# Determina si la ubicación cambió
def destination_changed?
persisted? && will_save_change_to_hostname?
end
# Genera el hostname
#
# @return [String]
def default_hostname
raise NotImplementedError
end
# Devolver la URL
#
# @return [String]
def url
"https://#{hostname}"
end
# Ejecutar la tarea
#
# @return [Boolean]
def deploy
raise NotImplementedError
end
# El espacio ocupado por este deploy.
#
# @return [Integer]
def limit
raise NotImplementedError
end
def size
raise NotImplementedError
end
# Empezar a contar el tiempo
#
# @return [Time]
def time_start
@start = Time.now
end
# Detener el contador
#
# @return [Time]
def time_stop
@stop = Time.now
end
# Obtener la demora de la tarea
#
# @return [Float]
def time_spent_in_seconds
(@stop - @start).round(3)
end
# El directorio donde se almacenan las gemas.
#
# TODO: En un momento podíamos tenerlas todas compartidas y ahorrar
# espacio, pero bundler empezó a mezclar cosas.
#
# @return [String]
def home_dir
site.path
end
def gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end
@ -171,67 +77,9 @@ class Deploy < ApplicationRecord
private
# Genera el hostname pero permitir la inicialización del valor. Luego
# validamos que sea el formato correcto.
#
# @return [Boolean]
def default_hostname!
self.hostname ||= default_hostname
end
# Cambia la ubicación de destino cuando cambia el hostname.
def rename_destination!
return unless File.exist? destination_was
FileUtils.mv destination_was, destination
end
# Elimina los archivos generados por el deploy
#
# @return [Boolean]
def remove_destination!
raise NotImplementedError
end
# Cuando el deploy cambia de hostname, generamos un dominio
# alternativo para no romper links hacia este sitio.
def create_alternative_domain!
hw = hostname_was
# Aplicar la actualización
yield
# Crear el deploy alternativo con el nombre anterior una vez que
# lo cambiamos en la base de datos.
ad = site.deploys.create(type: 'DeployAlternativeDomain', hostname: hw)
ad.deploy if ad.persisted?
end
# Devuelve un error si el destino ya existe. No debería fallar si ya
# pasamos la validación de cambio de nombres, pero siempre puede haber
# directorios y links sueltos.
def destination_can_change?
return true unless persisted?
remove_destination! if broken?
return true unless exist?
errors.add :hostname, I18n.t('activerecord.errors.models.deploy.attributes.hostname.destination_exist')
end
# Convierte el comando en una versión resumida.
#
# @param [String]
# @return [String]
def readable_cmd(cmd)
cmd.split(' -', 2).first.tr(' ', '_')
end
# Cada deploy puede decidir su propia validación
#
# @return [Boolean]
def implements_hostname_validation?
false
end
end

View file

@ -1,21 +1,23 @@
# frozen_string_literal: true
# Soportar dominios alternativos.
class DeployAlternativeDomain < DeployWww
validates :hostname, domainname: true
# Soportar dominios alternativos
class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON
# No hay un hostname por defecto
#
# @return [Nil]
def default_hostname; end
private
def implements_hostname_validation?
true
# Generar un link simbólico del sitio principal al alternativo
def deploy
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
# No hay un hostname por defecto. Debe ser informado por les
# usuaries.
def default_hostname!; end
# No hay límite para los dominios alternativos
def limit; end
def size
File.size destination
end
def destination
File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
end
end

View file

@ -1,61 +1,18 @@
# frozen_string_literal: true
# Alojar el sitio como un servicio oculto de Tor, que en realidad es un
# link simbólico al DeployLocal.
# Genera una versión onion
class DeployHiddenService < DeployWww
validates :hostname, format: { with: /\A[a-z2-7]{56}.onion\z/ }
def deploy
return true if fqdn.blank?
# Sufijo para todos los dominios temporales.
TEMPORARY_SUFFIX = 'temporary'
super
end
# Traer todos los servicios ocultos temporales.
scope :temporary, -> { where("hostname not like '#{TEMPORARY_SUFFIX}%'") }
def fqdn
values[:onion]
end
# Los servicios ocultos son su propio transporte cifrado y
# autenticado.
#
# @return [String]
def url
"http://#{hostname}"
end
# Los onions no son creados por Sutty sino por Tor y enviados luego a
# través de la API. El hostname por defecto es un nombre temporal que
# se parece a una dirección OnionV3.
#
# @return [String]
def default_hostname
"#{TEMPORARY_SUFFIX}#{random_base32}.onion"
end
# Detecta si es una dirección temporal.
#
# @return [Boolean]
def temporary?
hostname.start_with? TEMPORARY_SUFFIX
end
private
# No soportamos cambiar de onion
def destination_changed?
false
end
def implements_hostname_validation?
true
end
# Adaptado de base32
#
# @see {https://github.com/stesla/base32/blob/master/lib/base32.rb}
# @see {https://github.com/stesla/base32/blob/master/LICENSE}
def random_base32(length = nil)
table = 'abcdefghijklmnopqrstuvwxyz234567'
length ||= 56 - TEMPORARY_SUFFIX.length
OpenSSL::Random.random_bytes(length).each_byte.map do |b|
table[b % 32]
end.join
'http://' + fqdn
end
end

View file

@ -1,11 +1,11 @@
# frozen_string_literal: true
# Alojamiento local, genera el sitio como si corriéramos `jekyll build`.
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
# nada más
class DeployLocal < Deploy
# Asegurarse que el hostname es el permitido.
before_validation :reset_hostname!, :default_hostname!
# Actualiza el hostname con www si cambiamos el hostname
before_update :update_deploy_www!, if: :hostname_changed?
store :values, accessors: %i[], coder: JSON
before_destroy :remove_destination!
# Realizamos la construcción del sitio usando Jekyll y un entorno
# limpio para no pasarle secretos
@ -20,52 +20,42 @@ class DeployLocal < Deploy
jekyll_build
end
# Sólo permitimos un deploy local
def limit
1
end
# Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :)
#
# @return [Integer]
def size
paths = [destination, File.join(destination, '**', '**')]
Dir.glob(paths).map do |file|
File.symlink?(file) ? 0 : File.size(file)
if File.symlink? file
0
else
File.size(file)
end
end.inject(:+)
end
# El hostname es el nombre del sitio más el dominio principal.
#
# @return [String]
def default_hostname
"#{site.name}.#{Site.domain}"
def destination
File.join(Rails.root, '_deploy', site.hostname)
end
private
def reset_hostname!
self.hostname = nil
end
# XXX: En realidad el DeployWww debería regenerar su propio hostname.
def update_deploy_www!
site.deploys.where(type: 'DeployWww').map do |www|
www.update hostname: www.default_hostname
end
end
# Crea el directorio destino si no existe.
def mkdir
FileUtils.mkdir_p destination
end
# Un entorno que solo tiene lo que necesitamos
#
# @return [Hash]
def env
# XXX: This doesn't support Windows paths :B
paths = [File.dirname(`which bundle`), '/usr/bin', '/bin']
{
'HOME' => site.path,
'HOME' => home_dir,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
@ -76,15 +66,10 @@ class DeployLocal < Deploy
}
end
# @return [String]
def yarn_lock
File.join(site.path, 'yarn.lock')
end
# Determina si este proyecto se gestiona con Yarn, buscando si el
# archivo yarn.lock existe.
#
# @return [Boolean]
def yarn_lock?
File.exist? yarn_lock
end
@ -94,17 +79,12 @@ class DeployLocal < Deploy
end
# Corre yarn dentro del repositorio
#
# @return [Boolean,Nil]
def yarn
return true unless yarn_lock?
run 'yarn'
end
# Instala las dependencias.
#
# @return [Boolean]
def bundle
if Rails.env.production?
run %(bundle install --no-cache --path="#{gems_dir}")
@ -113,9 +93,6 @@ class DeployLocal < Deploy
end
end
# Genera el sitio.
#
# @return [Boolean]
def jekyll_build
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}")
end

View file

@ -11,31 +11,12 @@ class DeployPrivate < DeployLocal
jekyll_build
end
# La URL del sitio dentro del panel.
#
# @return [String]
def url
Rails.application.routes.url_for(controller: :private, action: :show, site_id: site)
end
# Hacer el deploy a un directorio privado.
#
# @return [String]
# Hacer el deploy a un directorio privado
def destination
File.join(Rails.root, '_private', site.name)
end
# El hostname no se usa para nada, porque el sitio es solo accesible a
# través del panel de Sutty.
#
# @return [String]
def default_hostname
"#{site.name}.private.#{Site.domain}"
end
# No usar recursos en compresión y habilitar los datos privados
#
# @return [Hash]
def env
@env ||= super.merge({
'JEKYLL_ENV' => 'development',

View file

@ -2,48 +2,34 @@
# Vincula la versión del sitio con www a la versión sin
class DeployWww < Deploy
# La forma de hacer este deploy es generar un link simbólico entre el
# directorio canónico y el actual.
#
# @return [Boolean]
store :values, accessors: %i[], coder: JSON
before_destroy :remove_destination!
def deploy
# Eliminar los links rotos
remove_destination! if broken?
# No hacer nada si ya existe.
return true if exist?
# Generar un link simbólico con la ruta relativa al destino
File.symlink(relative_path, destination).zero?
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
def limit
1
end
# Siempre devuelve el espacio ocupado por el link simbólico, no el
# destino.
#
# @return [Integer]
def size
relative_path.size
File.size destination
end
# El hostname por defecto incluye WWW
#
# @return [String]
def default_hostname
"www.#{site.deploy_local.hostname}"
def destination
File.join(Rails.root, '_deploy', fqdn)
end
def fqdn
"www.#{site.hostname}"
end
private
# Elimina el link simbólico si se elimina este deploy.
def remove_destination!
FileUtils.rm_f destination
end
# Obtiene la ubicación relativa del deploy local hacia la ubicación de
# este deploy
#
# @return [String]
def relative_path
Pathname.new(site.deploy_local.destination).relative_path_from(File.dirname(destination)).to_s
end
end

View file

@ -2,24 +2,22 @@
require 'zip'
# Genera un ZIP a partir del sitio ya generado y lo coloca para descarga
# dentro del sitio público.
# Genera un ZIP a partir del sitio ya construido
#
# TODO: Firmar con minisign
class DeployZip < Deploy
# El hostname es el nombre del archivo.
validates :hostname, format: { with: /\.zip\z/ }
store :values, accessors: %i[], coder: JSON
# Una vez que el sitio está generado, tomar todos los archivos y
# y generar un ZIP accesible públicamente.
# y generar un zip accesible públicamente.
#
# @return [Boolean]
# rubocop:disable Metrics/MethodLength
def deploy
remove_destination!
FileUtils.rm_f path
time_start
Dir.chdir(destination) do
Zip::File.open(hostname, Zip::File::CREATE) do |z|
Zip::File.open(path, Zip::File::CREATE) do |z|
Dir.glob('./**/**').each do |f|
File.directory?(f) ? z.mkdir(f) : z.add(f, f)
end
@ -33,47 +31,25 @@ class DeployZip < Deploy
File.exist? path
end
# rubocop:enable Metrics/MethodLength
# La URL de descarga del archivo.
#
# @return [String]
def url
"#{site.deploy_local.url}/#{hostname}"
def limit
1
end
# Devuelve el tamaño del ZIP en bytes
#
# @return [Integer]
def size
File.size path
end
# El archivo ZIP se guarda dentro del sitio local para poder
# descargarlo luego.
#
# @return [String]
def destination
site.deploy_local.destination
File.join(Rails.root, '_deploy', site.hostname)
end
# El "hostname" es la ubicación del archivo.
#
# @return [String]
def default_hostname
"#{site.deploy_local.hostname}.zip"
def file
"#{site.hostname}.zip"
end
def path
File.join(destination, hostname)
end
private
def remove_destination!
FileUtils.rm_f path
end
def implements_hostname_validation?
true
File.join(destination, file)
end
end

View file

@ -56,7 +56,7 @@ class MetadataContent < MetadataTemplate
uri = URI element['src']
# No permitimos recursos externos
element.remove unless uri.scheme == 'https' && uri.hostname.end_with?(Site.domain)
element.remove unless uri.hostname.end_with? Site.domain
rescue URI::Error
element.remove
end

View file

@ -7,7 +7,6 @@ class Site < ApplicationRecord
include Site::Forms
include Site::FindAndReplace
include Site::Api
include Site::Deployment
include Tienda
# Cifrar la llave privada que cifra y decifra campos ocultos. Sutty
@ -16,11 +15,19 @@ class Site < ApplicationRecord
# protege de acceso al panel de Sutty!
encrypts :private_key
# TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb
DEPLOYS = %i[local private www zip hidden_service].freeze
validates :name, uniqueness: true, hostname: {
allow_root_label: true
}
validates :design_id, presence: true
validates_uniqueness_of :name
validates_inclusion_of :status, in: %w[waiting enqueued building]
validates_presence_of :title
validates :description, length: { in: 50..160 }
validate :deploy_local_presence
validate :compatible_layouts, on: :update
attr_reader :incompatible_layouts
@ -31,6 +38,8 @@ class Site < ApplicationRecord
belongs_to :licencia
has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
has_many :build_stats, through: :deploys
has_many :roles, dependent: :destroy
has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') },
through: :roles
@ -49,11 +58,13 @@ class Site < ApplicationRecord
after_initialize :load_jekyll
after_create :load_jekyll, :static_file_migration!
# Cambiar el nombre del directorio
before_update :update_name!, if: :name_changed?
before_update :update_name!
before_save :add_private_key_if_missing!
# Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config!
accepts_nested_attributes_for :deploys, allow_destroy: true
# El sitio en Jekyll
attr_reader :jekyll
@ -74,6 +85,49 @@ class Site < ApplicationRecord
@repository ||= Site::Repository.new path
end
def hostname
sub = name || I18n.t('deploys.deploy_local.ejemplo')
if sub.ends_with? '.'
sub.gsub(/\.\Z/, '')
else
"#{sub}.#{Site.domain}"
end
end
# Devuelve la URL siempre actualizada a través del hostname
#
# @param slash Boolean Agregar / al final o no
# @return String La URL con o sin / al final
def url(slash: true)
"https://#{hostname}#{slash ? '/' : ''}"
end
# Obtiene los dominios alternativos
#
# @return Array
def alternative_hostnames
deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h|
h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}"
end
end
# Obtiene todas las URLs alternativas para este sitio
#
# @return Array
def alternative_urls(slash: true)
alternative_hostnames.map do |h|
"https://#{h}#{slash ? '/' : ''}"
end
end
# Todas las URLs posibles para este sitio
#
# @return Array
def urls(slash: true)
alternative_urls(slash: slash) << url(slash: slash)
end
def invitade?(usuarie)
!invitades.find_by(id: usuarie.id).nil?
end
@ -399,6 +453,8 @@ class Site < ApplicationRecord
end
def update_name!
return unless name_changed?
FileUtils.mv path_was, path
reload_jekyll!
end
@ -421,6 +477,19 @@ class Site < ApplicationRecord
Site::StaticFileMigration.new(site: self).migrate!
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada
# y es la local
#
# TODO: Volver opcional el alojamiento local, pero ahora mismo está
# atado a la generación del sitio así que no puede faltar
def deploy_local_presence
# Usamos size porque queremos saber la cantidad de deploys sin
# guardar también
return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence'))
end
# Valida que al cambiar de plantilla no tengamos artículos en layouts
# inexistentes.
def compatible_layouts

View file

@ -1,113 +0,0 @@
# frozen_string_literal: true
class Site
# Abstrae todo el comportamiento de publicación del sitio en un
# módulo.
module Deployment
extend ActiveSupport::Concern
included do
# TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb
DEPLOYS = %i[local private www zip hidden_service].freeze
validates :name,
format: { with: /\A[0-9a-z\-]+\z/,
message: I18n.t('activerecord.errors.models.site.attributes.name.no_subdomains') }
validates :name, hostname: true
validates_presence_of :canonical_deploy
validate :deploy_local_presence
validate :name_changed_is_unique_hostname, if: :name_changed?
has_one :canonical_deploy, class_name: 'Deploy'
has_many :deploys, dependent: :destroy
has_many :access_logs, through: :deploys
has_many :build_stats, through: :deploys
before_validation :deploy_local_is_default_canonical_deploy!, unless: :canonical_deploy_id?
before_update :update_deploy_local_hostname!, if: :name_changed?
accepts_nested_attributes_for :deploys, allow_destroy: true
# El primer deploy del sitio, si no existe en la base de datos es
# porque recién estamos creando el sitio y todavía no se guardó.
#
# @return [DeployLocal]
def deploy_local
@deploy_local ||= deploys.order(created_at: :asc).find_by(type: 'DeployLocal') || deploys.find do |d|
d.type == 'DeployLocal'
end
end
# Obtiene la URL principal
#
# @param :slash [Boolean]
# @return [String]
def canonical_url(slash: true)
canonical_deploy.url.dup.tap do |url|
url << '/' if slash
end
end
alias_method :url, :canonical_url
# Devuelve todas las URLs posibles
#
# @param :slash [Boolean]
# @return [Array]
def urls(slash: true)
deploys.map(&:url).map do |url|
slash ? "#{url}/" : url
end
end
# Obtiene el hostname principal
#
# @return [String]
def hostname
canonical_deploy.hostname
end
private
# Validar que al cambiar el nombre no estemos utilizando un
# hostname reservado por otro sitio.
#
# Al cambiar el nombre del DeployLocal se va a validar que el
# hostname nuevo sea único.
def name_changed_is_unique_hostname
deploy_local.hostname = nil
return if deploy_local.valid?
errors.add :name, I18n.t('activerecord.errors.models.site.attributes.name.duplicated_hostname')
end
# Si cambia el nombre queremos actualizarlo en el DeployLocal y
# recargar el deploy canónico para tomar el nombre que
# corresponda.
def update_deploy_local_hostname!
deploy_local.update(hostname: name)
canonical_deploy.reload if canonical_deploy == deploy_local
end
# Si no asignamos un deploy canónico en el momento le asignamos el
# deploy local
def deploy_local_is_default_canonical_deploy!
self.canonical_deploy ||= deploy_local
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada
# y es la local
#
# TODO: Volver opcional el alojamiento local, pero ahora mismo está
# atado a la generación del sitio así que no puede faltar
def deploy_local_presence
# Usamos size porque queremos saber la cantidad de deploys sin
# guardar también
return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence'))
end
end
end
end

View file

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

View file

@ -11,8 +11,6 @@ class Usuarie < ApplicationRecord
has_many :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
email.split('@', 2).first

View file

@ -1,10 +0,0 @@
# 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,15 @@
# frozen_string_literal: true
# Política de acceso a las estadísticas
class SiteStatPolicy
attr_reader :site_stat, :usuarie
def initialize(usuarie, site_stat)
@usuarie = usuarie
@site_stat = site_stat
end
def index?
site_stat.site.usuarie? usuarie
end
end

View file

@ -13,10 +13,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.save &&
site.config.write &&
commit_config(action: :create) &&
add_licencias
commit_config(action: :create)
end
add_licencias
site
end

View file

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

View file

@ -1,30 +0,0 @@
!!!
%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

@ -1,9 +0,0 @@
#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

@ -1,51 +0,0 @@
- 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,6 +1,6 @@
%h1= t('.hi')
= sanitize_markdown t('.explanation', url: @site.deploy_local.url),
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname),
tags: %w[p a strong em]
%table

View file

@ -1,6 +1,6 @@
= '# ' + t('.hi')
\
= t('.explanation', url: @site.deploy_local.url)
= t('.explanation', fqdn: @deploy_local.site.hostname)
\
= Terminal::Table.new do |table|
- table << [t('.th.type'), t('.th.status')]

View file

@ -14,10 +14,10 @@
'0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title')
= sanitize_markdown t('.help', public_url: site.deploy_local.url),
= sanitize_markdown t('.help', public_url: deploy.object.site.url),
tags: %w[p strong em a]
- unless deploy.object.temporary?
- if deploy.object.fqdn
= sanitize_markdown t('.help_2', url: deploy.object.url),
tags: %w[p strong em a]
%hr/

View file

@ -6,9 +6,7 @@
.row
.col
%h3= t('.title')
= sanitize_markdown t('.help', url: deploy.object.url),
= sanitize_markdown t('.help', fqdn: deploy.object.site.hostname),
tags: %w[p strong em a]
-# No duplicarlos una vez que existen.
- unless deploy.object.persisted?
= deploy.hidden_field :type
= deploy.hidden_field :type

View file

@ -15,6 +15,6 @@
'0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title')
= sanitize_markdown t('.help', url: deploy.object.url),
= sanitize_markdown t('.help', fqdn: deploy.object.fqdn),
tags: %w[p strong em a]
%hr/

View file

@ -15,5 +15,10 @@
'0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title')
= sanitize_markdown t('.help', url: deploy.object.url), tags: %w[p strong em a]
-# TODO: secar la generación de URLs
- name = site.name || t('.ejemplo')
= sanitize_markdown t('.help',
fqdn: deploy.object.site.hostname,
file: deploy.object.file || "#{name}.zip"),
tags: %w[p strong em a]
%hr/

View file

@ -12,7 +12,7 @@
- else
%span.line-clamp-1= link_to crumb.name, crumb.url
- if @current_usuarie || current_usuarie
- if current_usuarie
%ul.navbar-nav
- if @site&.tienda?
%li.nav-item
@ -20,5 +20,5 @@
role: 'button', class: 'btn'
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
= link_to t('.logout'), destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn'

View file

@ -1,14 +0,0 @@
!!!
%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

@ -9,6 +9,119 @@
.alert.alert-info
:markdown
#{t('editor.alert')}
= text_area_tag "#{base}[#{attribute}]", metadata.value.html_safe,
= text_area_tag "#{base}[#{attribute}]", '',
dir: dir, lang: locale,
**field_options(attribute, metadata)
**field_options(attribute, metadata), class: 'd-none'
-#
el > se come el salto de línea y hace que los botones no tengan
espacio adicional
TODO: Eliminar todo el espacio en blanco para minificar HTML
.editor-toolbar{ style: 'z-index: 1' }
.editor-primary-toolbar.scrollbar-black
%button.btn{ type: 'button', title: t('editor.multimedia'), data: { editor_button: 'multimedia' } }>
%i.fa.fa-fw.fa-upload>
%span.sr-only>= t('editor.multimedia')
%button.btn{ type: 'button', title: t('editor.bold'), data: { editor_button: 'mark-bold' } }>
%i.fa.fa-fw.fa-bold>
%span.sr-only>= t('editor.bold')
%button.btn{ type: 'button', title: t('editor.italic'), data: { editor_button: 'mark-italic' } }>
%i.fa.fa-fw.fa-italic>
%span.sr-only>= t('editor.italic')
%button.btn{ type: 'button', title: t('editor.mark'), data: { editor_button: 'mark-mark' } }>
%i.fa.fa-fw.fa-tint>
%span.sr-only>= t('editor.mark')
%button.btn{ type: 'button', title: t('editor.link'), data: { editor_button: 'mark-link' } }>
%i.fa.fa-fw.fa-link>
%span.sr-only>= t('editor.link')
%button.btn{ type: 'button', title: t('editor.deleted'), data: { editor_button: 'mark-deleted' } }>
%i.fa.fa-fw.fa-strikethrough>
%span.sr-only>= t('editor.deleted')
%button.btn{ type: 'button', title: t('editor.underline'), data: { editor_button: 'mark-underline' } }>
%i.fa.fa-fw.fa-underline>
%span.sr-only>= t('editor.underline')
%button.btn{ type: 'button', title: t('editor.super'), data: { editor_button: 'mark-super' } }>
%i.fa.fa-fw.fa-superscript>
%span.sr-only>= t('editor.super')
%button.btn{ type: 'button', title: t('editor.sub'), data: { editor_button: 'mark-sub' } }>
%i.fa.fa-fw.fa-subscript>
%span.sr-only>= t('editor.sub')
%button.btn{ type: 'button', title: t('editor.small'), data: { editor_button: 'mark-small' } }>
%i.fa.fa-fw.fa-subscript>
%span.sr-only>= t('editor.small')
%button.btn.mr-0{ type: 'button', title: t('editor.h1'), data: { editor_button: 'block-h1' } }>
%i.fa.fa-fw.fa-heading>
1
%span.sr-only>= t('editor.h1')
%details.d-inline>
%summary.d-inline>
%span.btn.ml-0{ role: 'button', title: t('editor.more') }>
%i.fa.fa-caret-right>
%span.sr-only= t('editor.more')
.d-inline>
%button.btn{ type: 'button', title: t('editor.h2'), data: { editor_button: 'block-h2' } }>
%i.fa.fa-fw.fa-heading>
2
%span.sr-only>= t('editor.h2')
%button.btn{ type: 'button', title: t('editor.h3'), data: { editor_button: 'block-h3' } }>
%i.fa.fa-fw.fa-heading>
3
%span.sr-only>= t('editor.h3')
%button.btn{ type: 'button', title: t('editor.h4'), data: { editor_button: 'block-h4' } }>
%i.fa.fa-fw.fa-heading>
4
%span.sr-only>= t('editor.h4')
%button.btn{ type: 'button', title: t('editor.h5'), data: { editor_button: 'block-h5' } }>
%i.fa.fa-fw.fa-heading>
5
%span.sr-only>= t('editor.h5')
%button.btn{ type: 'button', title: t('editor.h6'), data: { editor_button: 'block-h6' } }>
%i.fa.fa-fw.fa-heading>
6
%span.sr-only>= t('editor.h6')
%button.btn{ type: 'button', title: t('editor.ul'), data: { editor_button: 'block-unordered_list' } }>
%i.fa.fa-fw.fa-list-ul>
%span.sr-only>= t('editor.ul')
%button.btn{ type: 'button', title: t('editor.ol'), data: { editor_button: 'block-ordered_list' } }>
%i.fa.fa-fw.fa-list-ol>
%span.sr-only>= t('editor.ol')
%button.btn{ type: 'button', title: t('editor.left'), data: { editor_button: 'parentBlock-left' } }>
%i.fa.fa-fw.fa-align-left>
%span.sr-only>= t('editor.left')
%button.btn{ type: 'button', title: t('editor.center'), data: { editor_button: 'parentBlock-center' } }>
%i.fa.fa-fw.fa-align-center>
%span.sr-only>= t('editor.center')
%button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }>
%i.fa.fa-fw.fa-align-right>
%span.sr-only>= t('editor.right')
-# HAML cringe
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } }
.form-group{ data: { editor_auxiliary: 'mark' } }
%label{ for: 'mark-color' }= t('editor.color')
%input.form-control{ type: 'color', name: 'mark-color' }/
%label{ for: 'mark-text-color' }= t('editor.text-color')
%input.form-control{ type: 'color', name: 'mark-text-color' }/
%div{ data: { editor_auxiliary: 'multimedia' } }
.form-group
.custom-file
%input.custom-file-input{ type: 'file', id: 'multimedia-file', name: 'multimedia-file' }/
%label.custom-file-label{ for: 'multimedia-file' }= t('editor.multimedia-select')
.form-group
%label{ for: 'multimedia-alt' }= t('editor.description')
%input.form-control{ type: 'text', id: 'multimedia-alt', name: 'multimedia-alt' }/
.form-group
%button.btn{ type: 'button', id: 'multimedia-file-upload', name: 'multimedia-file-upload' }= t('editor.multimedia-upload')
%button.btn{ type: 'button', id: 'multimedia-remove', name: 'multimedia-remove' }= t('editor.multimedia-remove')
.form-group{ data: { editor_auxiliary: 'link' } }
%label{ for: 'link-url' }= t('editor.url')
%input.form-control{ type: 'url', id: 'link-url', name: 'link-url' }/
.editor-aviso-word.alert.alert-info
%p= t('editor.word')
.editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' }
= metadata.value.html_safe

View file

@ -38,13 +38,6 @@ module Sutty
config.active_storage.variant_processor = :vips
config.to_prepare do
# Load application's model / class decorators
Dir.glob(File.join(File.dirname(__FILE__), '../app/**/*_decorator.rb')) do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.after_initialize do
ActiveStorage::DirectUploadsController.include ActiveStorage::AuthenticatedDirectUploadsController

View file

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

View file

@ -26,7 +26,8 @@ test:
user: <%= ENV['USER'] %>
production:
<<: *default
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
database: <%= ENV.fetch('DATABASE') { 'sutty' } %>
user: sutty
host: postgresql

View file

@ -13,19 +13,29 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
# El problema sería que otros sitios con JS malicioso hagan pedidos
# a nuestra API desde otros sitios infectados.
#
# XXX: La primera parte del dominio tiene que coincidir con el
# nombre del sitio.
#
# XXX: Al terminar de entender esto nos pasó que el servidor recibe
# la petición de todas maneras, con lo que no estamos previniendo
# que nos hablen, sino que lean información. Solo va a funcionar si
# el servidor no tiene el Preflight cacheado.
#
# TODO: Limitar el acceso desde Nginx también.
#
# TODO: Poder consultar por sitios por todas sus URLs posibles.
origins do |source, _|
# Cacheamos la respuesta para no tener que volver a procesarla
# cada vez.
Rails.cache.fetch(source, expires_in: 1.hour) do
hostname = URI(source)&.host
hostname.present? && Deploy.find_by_hostname(hostname).present?
rescue StandardError
uri = URI(source)
if (name = uri&.host&.split('.', 2)&.first).present?
Site.where(name: [name, uri.host + '.']).pluck(:name).first.present?
else
false
end
rescue URI::Error
false
end
end

View file

@ -1,6 +0,0 @@
# frozen_string_literal: true
# Agrega el subdominio .local a menos que estemos en producción.
#
# TODO: Permitir TLDs que no sean de ICANN aquí.
PAK::ValidatesHostname::ALLOWED_TLDS << 'local' unless Rails.env.production?

View file

@ -70,7 +70,7 @@ en:
hi: "Hi!"
explanation: |
This e-mail is to notify you that Sutty has built your site, which is
available at <%{url}/>.
available at <https://%{fqdn}>.
You'll find details below.
th:
@ -143,15 +143,8 @@ en:
tienda_api_key: Store access key
errors:
models:
deploy:
attributes:
hostname:
destination_exist: 'There already is a file in the destination'
site:
attributes:
name:
no_subdomains: 'Name cannot contain dots'
duplicated_hostname: 'There already is a site with this address'
deploys:
deploy_local_presence: 'We need to be build the site!'
design_id:
@ -202,7 +195,7 @@ en:
deploy_local:
title: 'Host at Sutty'
help: |
The site will be available at <%{url}/>.
The site will be available at <https://%{fqdn}/>.
We're working out the details to allow you to use your own site
domains, you can [help us](https://sutty.nl/en/index.html#contact)!
@ -218,7 +211,7 @@ en:
title: 'Add www to the address'
help: |
When you enable this option, your site will also be available
under <%{url}/>.
under <https://%{fqdn}/>.
The www prefix has been a way of referring to
computers that are available on the World Wide Web. Since
@ -229,7 +222,7 @@ en:
help: |
ZIP files contain and compress all your site's files. With
this option you can download and also share your entire site
through the <%{url}> address, keep it as backup
through the <https://%{fqdn}/%{file}> address, keep it as backup
or have a strategy of solidary hosting, where many people
share a copy of your site.
@ -583,14 +576,3 @@ en:
edit: 'Editing'
usuaries:
index: 'Users'
stats:
index: 'Statistics'
blazer:
columns:
total: 'Total'
dia: 'Date'
date: 'Date'
visitas: 'Visits'
queries:
show:
empty: '(empty)'

View file

@ -70,7 +70,7 @@ es:
hi: "¡Hola!"
explanation: |
Este correo es para notificarte que Sutty ha generado tu sitio y
ya está disponible en la dirección <%{url}>.
ya está disponible en la dirección <https://%{fqdn}>.
A continuación encontrarás el detalle de lo que hicimos.
th:
@ -143,15 +143,8 @@ es:
tienda_api_key: Clave de acceso
errors:
models:
deploy:
attributes:
hostname:
destination_exist: 'Ya hay un archivo en esta ubicación'
site:
attributes:
name:
no_subdomains: 'El nombre no puede contener puntos'
duplicated_hostname: 'Ya existe un sitio con ese nombre'
deploys:
deploy_local_presence: '¡Necesitamos poder generar el sitio!'
design_id:
@ -204,7 +197,7 @@ es:
deploy_local:
title: 'Alojar en Sutty'
help: |
El sitio estará disponible en <%{url}/>.
El sitio estará disponible en <https://%{fqdn}/>.
Estamos desarrollando la posibilidad de agregar tus propios
dominios, ¡ayudanos!
@ -220,7 +213,7 @@ es:
title: 'Agregar www a la dirección'
help: |
Cuando habilitas esta opción, tu sitio también estará disponible
como <%{url}/>.
como <https://%{fqdn}/>.
El prefijo www para las direcciones web ha sido una forma de
referirse a las computadoras que están disponibles en la _World
@ -233,7 +226,7 @@ es:
help: |
Los archivos ZIP contienen y comprimen todos los archivos de tu
sitio. Con esta opción podrás descargar y compartir tu sitio
entero a través de la dirección <%{url}> y
entero a través de la dirección <https://%{fqdn}/%{file}> y
guardarla como copia de seguridad o una estrategia de
alojamiento solidario, donde muchas personas comparten una copia
de tu sitio.
@ -591,14 +584,3 @@ es:
edit: 'Editando'
usuaries:
index: 'Usuaries'
stats:
index: 'Estadísticas'
blazer:
columns:
total: 'Total'
dia: 'Fecha'
date: 'Fecha'
visitas: 'Visitas'
queries:
show:
empty: '(vacío)'

View file

@ -4,6 +4,8 @@ Rails.application.routes.draw do
devise_for :usuaries
get '/.well-known/change-password', to: redirect('/usuaries/edit')
mount Blazer::Engine, at: 'blazer'
root 'application#index'
constraints(Constraints::ApiSubdomain.new) do
@ -36,9 +38,6 @@ Rails.application.routes.draw do
match '/api/v3/projects/:site_id/notices' => 'api/v1/notices#create', via: %i[post]
resources :sites, constraints: { site_id: %r{[^/]+}, id: %r{[^/]+} } do
# Usar Blazer para mostrar estadísticas
mount Blazer::Engine, at: 'stats', as: 'stats'
# Gestionar actualizaciones del sitio
get 'pull', to: 'sites#fetch'
post 'pull', to: 'sites#merge'
@ -74,5 +73,7 @@ Rails.application.routes.draw do
# Compilar el sitio
post 'enqueue', to: 'sites#enqueue'
post 'reorder_posts', to: 'sites#reorder_posts'
resources :stats, only: [:index]
end
end

View file

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

View file

@ -1,56 +0,0 @@
# frozen_string_literal: true
# Recupera la funcionalidad que estamos deprecando.
module AddValuesToDeploy
extend ActiveSupport::Concern
included do
store :values, accessors: %i[hostname onion], coder: JSON
end
end
# Convertir todos los valores serializados de Deploy en una columna,
# porque al final el único uso que tuvo fue para guardar los hostnames
# alternativos.
#
# ¡El hostname es único para poder evitar que haya duplicados!
class AddHostnameToDeploys < ActiveRecord::Migration[6.1]
# Crea una columna temporal y guarda todos los valores. Los traspasa
# y luego elimina la columna.
def up
Deploy.include AddValuesToDeploy
# Ya que estamos hacer limpieza.
Deploy.where(site_id: nil).destroy_all
add_column :deploys, :hostname_tmp, :string
Site.find_each do |site|
site.deploys.find_each do |deploy|
deploy.hostname_tmp = deploy.values[:hostname] || deploy.values[:onion] || deploy.hostname
end
end
rename_column :deploys, :hostname_tmp, :hostname
remove_column :deploys, :values
add_index :deploys, :hostname, unique: true
# A esta altura todos los dominios deberían estar migrados.
change_column :deploys, :hostname, :string, null: false
end
# Recupera los valores desde la columna creada.
def down
Deploy.include AddValuesToDeploy
rename_column :deploys, :hostname, :hostname_tmp
add_column :deploys, :values, :text
Site.find_each do |site|
site.deploys.find_each do |deploy|
deploy.values[(deploy.is_a? DeployHiddenService ? :onion : :hostname)] = deploy.hostname_tmp
end
end
remove_column :deploys, :hostname_tmp
end
end

View file

@ -1,26 +0,0 @@
# frozen_string_literal: true
# Los sitios pueden tener muchos tipos de publicación pero solo uno es
# el principal. Al usar un campo específico, podemos validar mejor su
# presencia y modificación.
#
# El valor por defecto es 0 para poder crear la columna sin modificarla
# después, pero la idea es que nunca haya ceros.
class AddCanonicalDeployToSites < ActiveRecord::Migration[6.1]
def up
add_belongs_to :sites, :canonical_deploy, index: true, null: false, default: 0
# Si el sitio tenía un dominio alternativo, usar ese en lugar del
# local, asumiendo que es el primero de todos los posibles.
Site.find_each do |site|
deploy = site.deploys.order(created_at: :asc).find_by_type('DeployAlternativeDomain')
deploy ||= site.deploy_local
site.update canonical_deploy_id: deploy.id
end
end
def down
remove_belongs_to :sites, :canonical_deploy, index: true
end
end

View file

@ -12,10 +12,7 @@
"@rails/activestorage": "^6.1.3-1",
"@rails/ujs": "^6.1.3-1",
"@rails/webpacker": "5.2.1",
"@suttyweb/editor": "0.0.8",
"babel-loader": "^8.2.2",
"chart.js": "2.9.3",
"chartkick": "3.2.1",
"circular-dependency-plugin": "^5.2.2",
"commonmark": "^0.29.0",
"fork-awesome": "^1.1.7",

View file

@ -21,14 +21,13 @@ module Api
end
test 'el sitio tiene que existir' do
hostname = @site.hostname
@site.destroy
get v1_site_contact_cookie_url(hostname, **@host)
get v1_site_contact_cookie_url(@site.hostname, **@host)
assert_not cookies[@site.name]
post v1_site_contact_url(site_id: hostname, form: :contacto, **@host),
post v1_site_contact_url(site_id: @site.hostname, form: :contacto, **@host),
params: {
name: SecureRandom.hex,
pronouns: SecureRandom.hex,
@ -107,7 +106,7 @@ module Api
test 'se puede enviar mensajes a dominios propios' do
ActionMailer::Base.deliveries.clear
@site.update name: 'example'
@site.update name: 'example.org.'
redirect = "#{@site.url}?thanks"
@ -131,34 +130,6 @@ module Api
assert_equal redirect, response.headers['Location']
assert_equal 2, ActionMailer::Base.deliveries.size
end
test 'algunos navegadores no soportan Origin' do
ActionMailer::Base.deliveries.clear
@site.update name: 'example'
redirect = "#{@site.url}?thanks"
10.times do
create :rol, site: @site
end
get v1_site_contact_cookie_url(@site.hostname, **@host)
post v1_site_contact_url(site_id: @site.hostname, form: :contacto, **@host),
headers: { referer: @site.url },
params: {
name: SecureRandom.hex,
pronouns: SecureRandom.hex,
contact: SecureRandom.hex,
from: "#{SecureRandom.hex}@sutty.nl",
body: SecureRandom.hex,
consent: true,
redirect: redirect
}
assert_equal redirect, response.headers['Location']
assert_equal 2, ActionMailer::Base.deliveries.size
end
end
end
end

View file

@ -23,7 +23,7 @@ module Api
test 'se puede obtener un listado de todos' do
get v1_sites_url(host: "api.#{Site.domain}"), headers: @authorization, as: :json
assert_equal Deploy.all.pluck(:hostname), JSON.parse(response.body)
assert_equal Site.all.pluck(:name), JSON.parse(response.body)
end
end
end

View file

@ -119,7 +119,12 @@ class SitesControllerTest < ActionDispatch::IntegrationTest
title: name,
description: name * 2,
design_id: design.id,
licencia_id: Licencia.all.second.id
licencia_id: Licencia.all.second.id,
deploys_attributes: {
'0' => {
type: 'DeployLocal'
}
}
}
}

View file

@ -9,11 +9,11 @@ FactoryBot.define do
licencia
after :build do |site|
# XXX: Generamos un DeployLocal normalmente y no a través de una
# Factory porque necesitamos que el sitio se genere solo.
#
# @see {https://github.com/thoughtbot/factory_bot/wiki/How-factory_bot-interacts-with-ActiveRecord}
site.deploys.build(type: 'DeployLocal')
site.deploys << build(:deploy_local, site: site)
end
after :create do |site|
site.deploys << create(:deploy_local, site: site)
end
end
end

View file

@ -2,7 +2,11 @@
class DeployJobTest < ActiveSupport::TestCase
test 'se puede compilar' do
site = create :site
rol = create :rol
site = rol.site
site.deploys << create(:deploy_zip, site: site)
site.save
DeployJob.perform_async(site.id)

View file

@ -1,44 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class DeployAlternativeDomainTest < ActiveSupport::TestCase
setup do
@site = create :site
@deploy_alt = @site.deploys.build type: 'DeployAlternativeDomain'
end
teardown do
@site&.destroy
end
def random_tld
PAK::ValidatesHostname::ALLOWED_TLDS.sample
end
test 'el hostname se ingresa manualmente' do
assert_nil @deploy_alt.hostname
end
test 'el hostname es obligatorio' do
assert_not @deploy_alt.valid?
end
test 'el hostname es válido' do
assert_not @deploy_alt.update(hostname: ' ')
assert_not @deploy_alt.update(hostname: 'custom.domain.root.')
assert_not @deploy_alt.update(hostname: 'custom.domain')
assert @deploy_alt.update(hostname: "custom.domain.#{random_tld}")
end
test 'el hostname tiene que ser único' do
assert_not @deploy_alt.update(hostname: @site.hostname)
end
test 'se puede deployear' do
assert @site.deploy_local.deploy
assert @deploy_alt.update(hostname: "#{SecureRandom.hex}.sutty.#{random_tld}")
assert @deploy_alt.deploy
assert File.symlink?(@deploy_alt.destination)
end
end

View file

@ -1,36 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class DeployHiddenServiceTest < ActiveSupport::TestCase
setup do
@site = create :site
@deploy_hidden = @site.deploys.build type: 'DeployHiddenService'
end
teardown do
@site&.destroy
end
test 'el hostname es válido' do
assert_not @deploy_hidden.update(hostname: ' ')
assert_not @deploy_hidden.update(hostname: 'custom.domain.root.')
assert_not @deploy_hidden.update(hostname: 'custom.domain')
assert @deploy_hidden.update(hostname: "#{@deploy_hidden.send(:random_base32, 56)}.onion")
end
test 'los hostnames pueden ser temporales' do
assert @deploy_hidden.hostname.start_with? 'temporary'
end
test 'el hostname tiene que ser único' do
assert @deploy_hidden.save
assert_not @site.deploys.create(type: 'DeployHiddenService', hostname: @deploy_hidden.hostname).valid?
end
test 'se puede deployear' do
assert @site.deploy_local.deploy
assert @deploy_hidden.deploy
assert File.symlink?(@deploy_hidden.destination)
end
end

View file

@ -1,44 +1,24 @@
# frozen_string_literal: true
require 'test_helper'
class DeployLocalTest < ActiveSupport::TestCase
setup do
@site = create :site
end
teardown do
@site&.destroy
end
test 'se pueden crear' do
assert @site.deploy_local.valid?
assert_equal @site.hostname, @site.deploy_local.hostname
end
test 'no se puede cambiar el hostname' do
hostname = @site.deploy_local.hostname
@site.deploy_local.hostname = SecureRandom.hex
assert @site.deploy_local.save
assert_equal hostname, @site.deploy_local.hostname
end
test 'se puede deployear' do
deploy_local = @site.deploy_local
site = create :site
local = create :deploy_local, site: site
deploy = create :deploy_zip, site: site
assert deploy_local.deploy
assert File.directory?(deploy_local.destination)
assert File.exist?(File.join(deploy_local.destination, 'index.html'))
assert_equal 3, deploy_local.build_stats.count
# Primero tenemos que generar el sitio
local.deploy
assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive?
assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive?
end
escaped_path = Shellwords.escape(deploy.path)
test 'al eliminarlos se elimina el directorio' do
deploy_local = @site.deploy_local
assert deploy_local.destroy
assert_not File.directory?(deploy_local.destination)
assert deploy.deploy
assert File.file?(deploy.path)
assert_equal 'application/zip',
`file --mime-type "#{escaped_path}"`.split(' ').last
assert_equal 1, deploy.build_stats.count
assert deploy.build_stats.map(&:bytes).inject(:+).positive?
assert deploy.build_stats.map(&:seconds).inject(:+).positive?
local.destroy
end
end

View file

@ -3,23 +3,17 @@
require 'test_helper'
class DeployWwwTest < ActiveSupport::TestCase
setup do
@site = create :site
@deploy_www = @site.deploys.create type: 'DeployWww'
end
teardown do
@site&.destroy
end
test 'el hostname empieza con www' do
assert @deploy_www.hostname.start_with?('www.')
end
test 'se puede deployear' do
assert @site.deploy_local.deploy
site = create :site
local = create :deploy_local, site: site
deploy = create :deploy_www, site: site
assert @deploy_www.deploy
assert File.symlink?(@deploy_www.destination)
# Primero tenemos que generar el sitio
local.deploy
assert deploy.deploy
assert File.symlink?(deploy.destination)
local.destroy
end
end

View file

@ -3,29 +3,18 @@
require 'test_helper'
class DeployZipTest < ActiveSupport::TestCase
setup do
@site = create :site
@deploy_zip = @site.deploys.create(type: 'DeployZip')
end
teardown do
@site&.destroy
end
test 'el nombre es el hostname.zip' do
assert_equal "#{@site.hostname}.zip", @deploy_zip.hostname
end
test 'se puede deployear' do
# Primero tenemos que generar el sitio
assert @site.deploy_local.deploy
deploy_local = create :deploy_local
assert @deploy_zip.deploy
assert File.file?(@deploy_zip.path)
assert_equal 'application/zip',
`file --mime-type "#{@deploy_zip.path}"`.split.last
assert_equal 1, @deploy_zip.build_stats.count
assert @deploy_zip.build_stats.map(&:bytes).inject(:+).positive?
assert @deploy_zip.build_stats.map(&:seconds).inject(:+).positive?
assert deploy_local.deploy
assert File.directory?(deploy_local.destination)
assert File.exist?(File.join(deploy_local.destination, 'index.html'))
assert_equal 3, deploy_local.build_stats.count
assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive?
assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive?
assert deploy_local.destroy
assert_not File.directory?(deploy_local.destination)
end
end

View file

@ -1,88 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class Site::DeploymentTest < ActiveSupport::TestCase
def site
@site ||= create :site
end
teardown do
@site&.destroy
end
test 'al publicar el sitio se crea el directorio' do
assert site.deploy_local.deploy
assert site.deploy_local.exist?
end
test 'al cambiar el nombre no puede pisar un dominio que ya existe' do
site_pre = create :site
dup_name = "test-#{SecureRandom.hex}"
assert site_pre.deploys.create(type: 'DeployAlternativeDomain', hostname: "#{dup_name}.#{Site.domain}")
assert_not site.update(name: dup_name)
end
test 'al cambiar el nombre se crea un deploy alternativo' do
site_name = site.name
new_name = SecureRandom.hex
original_destination = site.deploy_local.destination
urls = [site.url]
assert site.deploy_local.deploy
assert_not site.deploy_local.destination_changed?
assert site.update(name: new_name)
urls << site.url
assert_equal urls.sort, site.urls.sort
assert File.symlink?(original_destination)
assert File.exist?(site.deploy_local.destination)
assert_equal 2, site.deploys.count
end
test 'al cambiar el nombre se renombra el directorio' do
site_name = site.name
new_name = "test-#{SecureRandom.hex}"
original_destination = site.deploy_local.destination
assert site.deploy_local.deploy
assert_not site.deploy_local.destination_changed?
assert site.update(name: new_name)
assert site.deploy_local.hostname.start_with?(new_name)
assert File.symlink?(original_destination)
assert File.exist?(site.deploy_local.destination)
end
test 'al cambiar el nombre se actualiza el www' do
site_name = site.name
new_name = "test-#{SecureRandom.hex}"
assert (deploy_www = site.deploys.create(type: 'DeployWww'))
assert site.deploy_local.deploy
assert_not site.deploy_local.destination_changed?
assert site.update(name: new_name)
assert deploy_www.reload.hostname.include?(new_name)
assert_equal 4, site.deploys.count
end
test 'al cambiar el nombre varias veces se crean varios links' do
assert site.deploy_local.deploy
q = rand(3..10)
q.times do
assert site.update(name: "test-#{SecureRandom.hex}")
end
assert_equal q, site.deploys.count
end
test 'no se puede cambiar el nombre si ya existía un archivo en el mismo lugar' do
assert site.deploy_local.deploy
new_name = "test-#{SecureRandom.hex}"
FileUtils.mkdir File.join(Rails.root, '_deploy', "#{new_name}.#{Site.domain}")
assert_not site.update(name: new_name)
end
end

View file

@ -26,18 +26,18 @@ class SiteTest < ActiveSupport::TestCase
assert_not site2.valid?
end
test 'el nombre del sitio no puede contener subdominios' do
test 'el nombre del sitio puede contener subdominios' do
@site = build :site, name: 'hola.chau'
site.validate
assert site.errors.messages[:name].present?
assert_not site.errors.messages[:name].present?
end
test 'el nombre del sitio no puede terminar con punto' do
test 'el nombre del sitio puede terminar con punto' do
@site = build :site, name: 'hola.chau.'
site.validate
assert site.errors.messages[:name].present?
assert_not site.errors.messages[:name].present?
end
test 'el nombre del sitio no puede contener wildcard' do
@ -93,9 +93,9 @@ class SiteTest < ActiveSupport::TestCase
test 'tienen un hostname que puede cambiar' do
assert_equal "#{site.name}.#{Site.domain}", site.hostname
site.update(name: (new_name = SecureRandom.hex))
site.name = name = SecureRandom.hex
assert_equal "#{new_name}.#{Site.domain}", site.hostname
assert_equal "#{name}.#{Site.domain}", site.hostname
end
test 'se pueden traer los datos de una plantilla' do

View file

@ -1171,11 +1171,6 @@
resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2"
integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A==
"@suttyweb/editor@0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.8.tgz#5803b9bcbab69fc4bf40fb939d1ec2283d44d2fd"
integrity sha512-vBBfTaGwu8IH4Gd+Q8cFC+XjjeEZ/8gSqT830hCO0kHzEvHEPTSEokffVR5DffBkS7ZKCvwsNXKzz/QuvkfHuQ==
"@types/caseless@*":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
@ -2124,34 +2119,6 @@ chalk@^4.1.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chart.js@2.9.3:
version "2.9.3"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.3.tgz#ae3884114dafd381bc600f5b35a189138aac1ef7"
integrity sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chartjs-color-string@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
dependencies:
color-name "^1.0.0"
chartjs-color@^2.1.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
dependencies:
chartjs-color-string "^0.6.0"
color-convert "^1.9.3"
chartkick@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/chartkick/-/chartkick-3.2.1.tgz#a80c2005ae353c5ae011d0a756b6f592fc8fc7a9"
integrity sha512-zV0kUeZNqrX28AmPt10QEDXHKadbVFOTAFkCMyJifHzGFkKzGCDXxVR8orZ0fC1HbePzRn5w6kLCOVxDQbMUCg==
chokidar@^2.1.8:
version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
@ -2271,7 +2238,7 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
color-convert@^1.9.0, color-convert@^1.9.1, color-convert@^1.9.3:
color-convert@^1.9.0, color-convert@^1.9.1:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@ -5038,11 +5005,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
moment@^2.10.2:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"