Compare commits

...

138 commits

Author SHA1 Message Date
Cat /dev/Nulo 59506708cc ci: volver a agregar el chown porque el repo lo trae root
All checks were successful
continuous-integration/drone the build was successful
2021-09-28 18:23:25 -03:00
Cat /dev/Nulo 0dfaee9502 Merge remote-tracking branch 'origin/rails' into staging-cd
Some checks failed
continuous-integration/drone the build failed
2021-09-28 18:21:08 -03:00
Cat /dev/Nulo 65798b9c91 ci: usar contenedor nuevo y no crear ~/.ssh ya que ahora se crea solo 2021-09-28 18:20:14 -03:00
Cat /dev/Nulo 29300fed42 woodpecker: HOME workaround
All checks were successful
continuous-integration/drone the build was successful
2021-09-18 13:41:09 -03:00
Cat /dev/Nulo a103b53d74 woodpecker: darnos permisos
Some checks failed
continuous-integration/drone the build failed
2021-09-18 13:24:02 -03:00
Cat /dev/Nulo a5b1af5a6b woodpecker: no leakear llaves 2021-09-18 13:08:36 -03:00
Cat /dev/Nulo 436ee1d048 woodpecker: usar haini.sh con HOME arreglada
Some checks failed
continuous-integration/drone the build failed
2021-09-18 12:52:42 -03:00
Cat /dev/Nulo abf8b8851c woodpecker: usar haini.sh con usuarix no root
Some checks failed
continuous-integration/drone the build failed
2021-09-18 12:32:32 -03:00
Cat /dev/Nulo 3442b63b49 woodpecker: sacar notificaciones de Telegram por ahora 2021-09-18 12:07:57 -03:00
Cat /dev/Nulo 41044b2716 woodpecker: known_hosts
Some checks failed
continuous-integration/drone the build failed
2021-09-18 11:49:04 -03:00
Cat /dev/Nulo 27c92bb185 woodpecker: apuntar a imagen correctamente
Some checks failed
continuous-integration/drone the build failed
2021-09-18 11:39:39 -03:00
Cat /dev/Nulo 9832d0ec12 woodpecker: usar haini.sh con rsync
Some checks failed
continuous-integration/drone the build failed
2021-09-18 11:28:28 -03:00
Cat /dev/Nulo 098a69c047 Makefile: especificar root en ssh
Some checks failed
continuous-integration/drone the build failed
2021-09-18 11:13:44 -03:00
Cat /dev/Nulo 98d2bd9a12 Makefile(ota-js): usar rsync --chgrp
Así no rompemos en entornos sin sudo (la CI) y es menos hacky
2021-09-18 11:12:01 -03:00
Cat /dev/Nulo a5be895321 woodpecker: no correr yarn ya que se corre solo 2021-09-18 11:04:06 -03:00
Cat /dev/Nulo 78a5a513b6 woodpecker: mandar Telegram siempre 2021-09-18 11:01:40 -03:00
Cat /dev/Nulo a3c3ef4d05 woodpecker: guardar mock credenciales bien
Some checks failed
continuous-integration/drone the build failed
2021-09-18 10:57:14 -03:00
Cat /dev/Nulo 69b56a0451 woodpecker: añadir credentials mock
Some checks failed
continuous-integration/drone the build failed
2021-09-18 10:47:41 -03:00
Cat /dev/Nulo 84a0635185 woodpecker: Telegram 2021-09-18 10:43:42 -03:00
Cat /dev/Nulo 2374719d62 woodpecker: usar ssh-agent para llaves 2021-09-18 10:41:21 -03:00
Cat /dev/Nulo c26d69777e woodpecker: usar registry.nulo.in/sutty/haini.sh
Some checks failed
continuous-integration/drone the build failed
2021-09-17 16:23:28 -03:00
Cat /dev/Nulo 2af04cf76e woodpecker: decir que hainish es una shell
Some checks failed
continuous-integration/drone the build failed
2021-09-17 16:22:55 -03:00
Cat /dev/Nulo 04077b3891 woodpecker: usar hain= en vez de HAINISH=
Some checks failed
continuous-integration/drone the build failed
2021-09-17 11:58:02 -03:00
Cat /dev/Nulo ce308defb9 woodpecker: usar openssh-client
Some checks failed
continuous-integration/drone the build failed
openssh-client-default no existe en 3.13
2021-09-17 11:04:31 -03:00
Cat /dev/Nulo 6144ed4708 woodpecker: downgradear a Alpine 3.13
Some checks failed
continuous-integration/drone the build failed
2021-09-17 11:02:51 -03:00
Cat /dev/Nulo c00635cc19 woodpecker: deswrappear paquetes
Some checks failed
continuous-integration/drone the build failed
2021-09-17 11:00:55 -03:00
Cat /dev/Nulo 097fed7634 woodpecker: arreglar paths 2
Some checks failed
continuous-integration/drone the build failed
2021-09-17 10:59:43 -03:00
Cat /dev/Nulo 76de4233a2 woodpecker: arreglar paths de haini.sh
Some checks failed
continuous-integration/drone the build failed
2021-09-17 10:58:25 -03:00
Cat /dev/Nulo d39f1809e6 woodpecker: probar sin hainish
Some checks failed
continuous-integration/drone the build failed
2021-09-17 10:52:54 -03:00
Cat /dev/Nulo 090bf122bd woodpecker: agregar diffutils correctamente
Some checks failed
continuous-integration/drone the build failed
2021-09-14 20:59:28 -03:00
Cat /dev/Nulo fbe743a652 woodpecker: agregar diff
Some checks failed
continuous-integration/drone the build failed
2021-09-14 20:57:09 -03:00
Cat /dev/Nulo a4f2b6a5df woodpecker: agregar bash y coreutils
Some checks failed
continuous-integration/drone the build failed
2021-09-14 20:56:30 -03:00
Cat /dev/Nulo f162c21742 woodpecker: intentar hacer deploy
Some checks failed
continuous-integration/drone the build failed
2021-09-14 20:55:49 -03:00
Cat /dev/Nulo 943dcfdd22 woodpecker: empezar a probar CD 2021-09-14 20:55:49 -03:00
f b204314c6c Merge branch 'actualizacion-de-cuidados' into staging 2021-09-11 20:18:39 -03:00
f ed213cda9f Actualización de cuidados 2021-09-11 20:17:35 -03:00
f 4753b162eb Merge branch 'usar-sutty-editor' into staging 2021-09-11 20:08:07 -03:00
f a4ca89a36b @suttyweb/editor@0.0.8 2021-09-11 20:07:51 -03:00
f 792038ce64 Merge branch 'actualizacion-de-cuidados' into staging 2021-08-28 13:04:42 -03:00
f be549c5cd5 ruby 2.7.4 2021-08-28 13:02:12 -03:00
f 12d5c3ae8c Merge branch 'actualizacion-de-cuidados' into staging 2021-08-28 12:56:33 -03:00
f 9ccc1be898 actualización de cuidados 2021-08-28 12:56:04 -03:00
f d151fffdd2 Merge branch 'origin-referer' into staging 2021-08-28 12:27:49 -03:00
f 1333eed1d3 Merge branch 'rails' into staging 2021-08-28 12:25:02 -03:00
f 2eff3ba716 Agregar las licencias solo si el sitio se pudo crear 2021-08-28 12:20:40 -03:00
Maki c1a9aaa037 Merge branch 'only-urls-allowed' into 'rails'
Solo permitir URLs web al sanitizar

Closes #2382

See merge request sutty/sutty!54
2021-08-16 15:36:30 +00:00
f 339c02c92b Siempre vincular con la ubicación relativa
Para que podamos cambiar la estructura de directorios sin romper los
links.
2021-08-14 20:31:28 -03:00
f 5a53facaa7 Al cambiar el nombre actualizamos el www 2021-08-14 20:31:28 -03:00
f 3e76ffe4c4 Si ya existía algo en el mismo lugar no se puede cambiar el nombre 2021-08-14 20:31:28 -03:00
f b20b15317b Cambiar la ubicación del sitio al cambiar su nombre 2021-08-14 20:31:28 -03:00
f 341a693a35 Mantener los hostnames anteriores a medida que se cambia de nombre
Para no romper links preexistentes.  Lo ideal sería que todos los Deploy
se vinculen al directorio principal para no romper links, pero la idea
es no romperlos (?)
2021-08-14 20:31:28 -03:00
f e6ea1001cd La URL termina en / por defecto 2021-08-14 20:31:28 -03:00
f df29c4ca95 Al cambiar el nombre del sitio no puede haber un dominio alternativo igual 2021-08-14 20:31:28 -03:00
f a1fde94378 Poder validar localmente los .local 2021-08-14 20:31:28 -03:00
f dcaac06fa4 Los sitios pueden tener una ubicación canónica 2021-08-14 20:31:28 -03:00
f baf6d203b8 Dentro de SitesController algunos usan site_id 2021-08-14 20:29:50 -03:00
f 75e7fe76fe Simplificar emisión y renovación de certificados
https://0xacab.org/sutty/sutty/-/issues/123#note_299672
2021-08-14 20:29:50 -03:00
f 0bd8a2243e Solo permitir URLs web al sanitizar
fixes #2382
2021-08-11 10:25:05 -03:00
f 4666963c91 Merge branch 'usar-sutty-editor' into staging 2021-08-10 18:39:31 -03:00
f c0b5863573 Deprecar el editor incorporado 2021-08-10 18:38:33 -03:00
f 6467a265d3 Deprecar el editor 2021-08-10 18:36:55 -03:00
f 063d2625fb Merge branch 'blazer' into staging 2021-08-10 18:20:56 -03:00
f 77e583c7cb Simplificar emisión y renovación de certificados
https://0xacab.org/sutty/sutty/-/issues/123#note_299672
2021-08-08 22:23:03 -03:00
f aebe48c784 Simplificar el procesamiento de CORS
Y ahora permitir cualquier URL posible :D
2021-08-08 22:23:03 -03:00
f b2508de39b Redirigir al nombre del sitio sin incluir dominio 2021-08-08 22:23:03 -03:00
f 22a7a811b4 Acomodar la API de formularios de contacto a los deploys nuevos.
La API no cambia por retrocompatibilidad pero ameritaría una v2 sabiendo
más cosas sobre CORS.
2021-08-08 22:23:03 -03:00
f 61b3b97313 Obtener los AccessLog para cada Site
A través del hostname, ¡magia!  Esto nos habilita a obtener estadísticas
más adelante...
2021-08-08 22:23:03 -03:00
f e14e53c2a1 No hacer malabares con los nombres de dominio
Esta era toda la razón para las modificaciones hechas hasta ahora, no
tener que tener que determinar el dominio por si tiene o no tiene punto
final o contiene el dominio del sitio.
2021-08-08 22:23:03 -03:00
f 11e45bbc05 Solo crear el DeployLocal una vez 2021-08-08 22:23:03 -03:00
f 70c8bcbcd1 Detectar cuando el servicio oculto es opcional 2021-08-08 22:23:03 -03:00
f 45559ad58f Refactorizar DeployJob 2021-08-07 18:30:07 -03:00
f c08cfb1637 Actualizar el hostname si cambia el name 2021-08-07 18:26:52 -03:00
f b9d7a27105 Arreglar y agregar tests 2021-08-07 18:25:54 -03:00
f 4ea2169c68 Poder acceder directo al archivo zip 2021-08-07 17:40:13 -03:00
f f50e202d3a Los servicios ocultos son direcciones en base32 2021-08-07 17:38:01 -03:00
f f8f9e722be Permitir asignar un hostname al iniciar 2021-08-07 17:37:19 -03:00
f 64a2dc146d Merge branch 'hotfix-yarn-lock' into origin-referer 2021-08-07 16:04:33 -03:00
f 5885dd7e96 fixup! No enviar URLs en los chequeos. 2021-08-06 17:47:27 -03:00
f 249b115af8 No enviar URLs en los chequeos.
Necesita más trabajo hacer esto y no tenemos un uso inmediato para
chequeos específicos de sitios.

fixes #2331
2021-08-04 20:16:56 -03:00
f 71436d3be4 Usar el sistema de autorización de Sutty
Y eliminar código sin utilizar.
2021-08-04 12:17:49 -03:00
f 71ff9e5e7b Traducir columnas o mostrarlas como títulos
Los nombres más comunas de columnas se pueden agregar al archivo de
traducción, si la traducción no existe se convierte a un título.

```ruby
"total_horas".titleize => "Total Horas"
```
2021-08-03 10:24:18 -03:00
f e87fad33ea La tabla se puede scrollear y mantiene los títulos 2021-08-03 10:16:26 -03:00
f 489cbb414c Ya no usamos un hash de queries 2021-08-03 10:15:48 -03:00
f 6f08ca6c36 A veces se pasa el parámetro en set_queries 2021-08-03 10:10:30 -03:00
f 44450da520 Les usuaries solo pueden ver sus propias consultas. 2021-08-02 18:41:09 -03:00
f af67c39dc4 Agregar el nombre del sitio en la miga de pan 2021-08-02 18:37:15 -03:00
f 841279f6cf fixup! Agregar los helpers de Devise también 2021-08-02 18:31:17 -03:00
f 75e6b7a801 Agregar los helpers de Devise también
Para que tengamos `current_usuarie` dentro de las vistas de Blazer.
2021-08-02 18:25:42 -03:00
f 85ad518d8d Correr Blazer sincrónicamente
Deshabilitamos la funcionalidad asincrónica de Blazer porque ejecuta
unos malabares extraños con JS, que filtran la consulta hacia el código.
La idea es ejecutar consultas más livianas, con lo que por ahora no lo
necesitamos.  Podríamos recuperar esto usando ActionCable luego.

Además, reimplementa la lógica de generación de gráficos en el
controlador, para simplificar la vista.
2021-08-02 18:01:34 -03:00
f 49e5603687 Por cuidados, modificar Blazer pero no poder crear consultas 2021-08-02 17:58:46 -03:00
f ddc459130a Acceder a la lista de consultas disponibles con la identidad de Sutty 2021-08-02 17:54:27 -03:00
f 7511afbf88 Les usuaries tienen consultas 2021-08-02 17:51:35 -03:00
f 3a2ce1d47d Configurar Blazer para mostrar estadísticas 2021-08-02 17:51:24 -03:00
f bb2697f63e Arreglar vistas 2021-08-01 22:06:17 -03:00
f 2be57ad3af Refactorizar DeployAlternativeDomain
Les usuaries pueden incorporar sus propios nombres de dominio y ya no
dependemos de verificar si tienen punto al final para saber si son
subdominios de Sutty o no.
2021-08-01 22:06:17 -03:00
f 0d8f0ec5ee Refactorizar DeployHiddenService 2021-08-01 22:06:17 -03:00
f e49d8484a2 Refactorizar DeployPrivate 2021-08-01 22:06:17 -03:00
f 27b4494333 Refactorizar DeployZip 2021-08-01 22:06:17 -03:00
f fa9884afdd Refactorizar DeployWww 2021-08-01 22:06:17 -03:00
f e1749d6c70 Refactorizar DeployLocal
En realidad no hay mucho cambio interno, pero se deprecaron algunos
métodos de Deploy y se ajustaron otros.
2021-08-01 22:06:17 -03:00
f e1664d0c12 Refactorizar Deploy
* Validar los hostnames
* Generar los hostnames por defecto
* Cada Deploy sabe su propia URL
2021-08-01 22:06:17 -03:00
f 7cfd55e126 Cada Deploy tiene un hostname único guardado en la base de datos
Con esto simplificamos la gestión de nombres de dominio, porque podemos
validar en la base de datos que solo hay uno por Deploy.

La migración recupera la información que antes guardábamos serializada
como JSON en la base de datos.
2021-08-01 21:48:49 -03:00
f 3e0b94fbce Una nota para el futuro 2021-07-29 11:01:44 -03:00
f 113498b4bf Usar el referer como fallback
Mucha gente no puede enviar el formulario de contacto porque su
navegador no envía el Origin, con esto al menos podemos recuperarlo del
Referer.
2021-07-29 10:55:53 -03:00
f 5d9c379e0e Volver al referer o al sitio después de enviar formularios 2021-07-29 10:55:21 -03:00
f 6b67f13aaf Origin y Referer
Algunos navegadores no envían Origin, la forma de obtenerlo es mirar en
el Referer.
2021-07-29 10:53:54 -03:00
f 1b099cf96b Merge branch 'actualizacion-de-cuidados' into staging 2021-07-13 17:25:11 -03:00
f 3a170c191b Merge branch 'rails' into staging 2021-07-13 17:25:06 -03:00
f 360eec5c2c Merge branch 'usar-sutty-editor' into staging 2021-06-28 14:30:19 -03:00
f 05a38f5b7e Merge branch 'rails' into staging 2021-06-28 14:29:54 -03:00
f ffa2c80bf1 @suttyweb/editor@0.0.7 2021-06-28 14:29:37 -03:00
f 06a9b78eee usar @suttyweb/editor@0.0.6 2021-06-28 14:29:03 -03:00
void a64f554f0e usar @suttyweb/editor@0.0.4
subida de archivos :)
2021-06-28 14:29:03 -03:00
void 7e0600779e usar @suttyweb/editor 2021-06-28 14:29:03 -03:00
f 583a5c0ebc un arreglo temporal hasta que reiniciemos el contenedor de nginx 2021-06-21 22:31:48 -03:00
f 985ee40aa7 Merge remote-tracking branch 'origin/usar-sutty-editor' into staging 2021-06-16 11:47:55 -03:00
f 1fd9524150 Merge branch 'search-engine' into staging 2021-06-16 11:37:14 -03:00
f 99e9ec2659 Merge branch 'search-engine' into staging 2021-06-16 09:42:48 -03:00
f 316c04e86a Merge branch 'actualizacion-de-cuidados' into staging 2021-06-15 17:08:20 -03:00
f 0ffa7feb29 Merge branch 'rails' into staging 2021-06-15 16:50:05 -03:00
f 0b9d178d90 Merge branch 'ssh-git' into staging 2021-05-26 16:43:07 -03:00
f 134535cb1c Rails.root.join devuelve Pathname, no String 2021-05-26 16:42:34 -03:00
void 5ab754ff5d usar @suttyweb/editor@0.0.4
subida de archivos :)
2021-05-22 22:03:18 +00:00
void d087c3bb09 usar @suttyweb/editor 2021-05-22 21:19:40 +00:00
f c6b6a1f74e Merge branch 'ssh-git' into staging 2021-05-20 20:02:38 -03:00
f 908a0a90a8 poder traer actualizaciones desde repositorios remotos
sin credenciales ssh no era posible traer los cambios desde repositorios
remotos y permitir actualizar desde el panel.
2021-05-20 19:58:08 -03:00
f 3bae282d8b Merge branch 'staging-process' into staging 2021-05-19 17:58:55 -03:00
f 43927d78e7 Merge branch 'search-engine' into staging 2021-05-18 11:30:14 -03:00
f f192c14947 Merge branch 'no-romperse-con-sitios-faltantes' into staging 2021-05-17 18:02:26 -03:00
f 11bbbe3b06 Merge branch 'validar-has-many' into staging 2021-05-17 18:01:45 -03:00
f db8ed8d776 Merge branch 'search-engine' into staging 2021-05-17 17:27:31 -03:00
f 4bc163a2fb Merge branch 'search-engine' into staging 2021-05-17 16:08:18 -03:00
f 464ac4df8d Merge branch 'rails' into staging 2021-05-17 15:52:30 -03:00
f bc1566735d Merge branch 'search-engine' into staging 2021-05-17 15:34:24 -03:00
f 9473fa7b93 Merge branch 'rails' into staging 2021-05-17 14:40:59 -03:00
f d6c41d468b Merge branch 'vali-date' into staging 2021-05-14 17:42:19 -03:00
f 0f92cf193f Merge branch 'belongs-to' into staging 2021-05-14 17:22:44 -03:00
f 8b9fddc171 Merge branch 'vali-date' into staging 2021-05-14 17:22:34 -03:00
83 changed files with 1571 additions and 1806 deletions

21
.woodpecker.yml Normal file
View file

@ -0,0 +1,21 @@
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.3" = `ruby -e 'puts RUBY_VERSION'`
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
@ -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.3" = `ruby -e 'puts RUBY_VERSION'`
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
# https://github.com/rubygems/rubygems/issues/2918
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808

View file

@ -19,6 +19,8 @@ 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)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.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)
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)
mail (>= 2.7.1)
actionmailer (6.1.4)
actionpack (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activesupport (= 6.1.4)
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)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4)
actionview (= 6.1.4)
activesupport (= 6.1.4)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
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)
actionpack (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
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)
nokogiri (>= 1.8.5)
actionview (6.1.4)
activesupport (= 6.1.4)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4)
activesupport (= 6.1.4)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
globalid (>= 0.3.6)
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)
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)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4)
activesupport (6.1.4.1)
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.2.5.1)
execjs (> 0)
autoprefixer-rails (10.3.3.0)
execjs (~> 2)
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.2)
blazer (2.4.3)
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.0.4)
brakeman (5.1.1)
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.2)
addressable (~> 2.5)
down (5.2.3)
addressable (~> 2.8)
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.0-x86_64-linux-musl)
fast_blank (1.0.1-x86_64-linux-musl)
fast_jsonparser (0.5.0-x86_64-linux-musl)
ffi (1.15.3-x86_64-linux-musl)
ffi (1.15.4-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.4.2)
activesupport (>= 4.2.0)
globalid (0.5.2)
activesupport (>= 5.0)
hairtrigger (0.2.24)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
haml (5.2.1)
haml (5.2.2)
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.0-x86_64-linux-musl)
hamlit (2.15.1-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.0)
jekyll-ignore-layouts (0.1.2)
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.0)
jekyll-linked-posts (0.4.2)
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.15)
jekyll-spree-client (0.1.18)
fast_blank (~> 1)
spree-api-client (~> 0.2)
spree-api-client (>= 0.2.3)
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.1.2)
jekyll-write-and-commit-changes (0.2.0)
jekyll (~> 4)
rugged (~> 1)
kramdown (2.3.1)
@ -330,7 +330,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.10.0)
loofah (2.12.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -341,23 +341,25 @@ GEM
method_source (1.0.0)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0704)
mime-types-data (3.2021.0901)
mini_histogram (0.3.1)
mini_magick (4.11.0)
mini_mime (1.1.0)
mini_mime (1.1.1)
mini_portile2 (2.5.3)
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.2)
mobility (1.1.3)
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.7-x86_64-linux-musl)
nokogiri (1.11.7-x86_64-linux)
nio4r (2.5.8-x86_64-linux-musl)
nokogiri (1.11.7-x86_64-linux-musl)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.20.1)
@ -370,21 +372,21 @@ GEM
activerecord (>= 5.2)
activesupport (>= 5.2)
popper_js (1.16.0)
prometheus_exporter (0.8.0)
prometheus_exporter (0.8.1)
webrick
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (4.0.6)
puma (5.3.2-x86_64-linux-musl)
puma (5.4.0-x86_64-linux-musl)
nio4r (~> 2.0)
pundit (2.1.0)
pundit (2.1.1)
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.2)
rack-mini-profiler (2.3.3)
rack (>= 1.2.0)
rack-proxy (0.7.0)
rack
@ -401,34 +403,34 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
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)
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)
bundler (>= 1.15.0)
railties (= 6.1.4)
railties (= 6.1.4.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
rails-html-sanitizer (1.4.2)
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)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
method_source
rake (>= 0.13)
thor (~> 1.0)
@ -455,7 +457,7 @@ GEM
jekyll-unique-urls (~> 0.1)
sutty-archives (~> 2.2)
sutty-liquid (~> 0)
redis (4.3.1)
redis (4.4.0)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
@ -480,18 +482,18 @@ GEM
railties (>= 5.0)
rexml (3.2.5)
rouge (3.26.0)
rubocop (1.18.3)
rubocop (1.20.0)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.7.0, < 2.0)
rubocop-ast (>= 1.9.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.7.0)
rubocop-ast (1.11.0)
parser (>= 3.0.1.1)
rubocop-rails (2.11.3)
rubocop-rails (2.12.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
@ -500,16 +502,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.2)
ruby-vips (2.1.3)
ffi (~> 1.12)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_dep (1.5.0)
ruby_parser (3.16.0)
ruby_parser (3.17.0)
sexp_processor (~> 4.15, >= 4.15.1)
rubyzip (2.3.2)
rugged (1.1.1-x86_64-linux-musl)
rugged (1.2.0-x86_64-linux-musl)
safe_yaml (1.0.6)
safely_block (0.3.0)
errbase (>= 0.1.1)
@ -537,7 +539,7 @@ GEM
simpleidn (0.2.1)
unf (~> 0.1.4)
sourcemap (0.1.1)
spree-api-client (0.2.2)
spree-api-client (0.2.3)
fast_blank (~> 1)
httparty (~> 0.18.0)
spring (2.1.1)
@ -610,7 +612,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webpacker (5.4.0)
webpacker (5.4.2)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@ -677,6 +679,7 @@ 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
sudo chgrp -R 82 public/
rsync -avi --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)/
rsync -avi --chown=:82 --delete-after public/ root@$(delegate):/srv/sutty/srv/http/data/_public/_staging/
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 $(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)
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)
rm ./0*.patch
# Todos los archivos de assets. Si alguno cambia, se van a recompilar

View file

@ -11,20 +11,58 @@ module Api
private
# Realiza la inversa de Site#hostname
# 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.
#
# TODO: El sitio sutty.nl no aplica a ninguno de estos y le
# tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test.
# 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.
def site_id
@site_id ||= if params[:site_id].end_with? Site.domain
params[:site_id].sub(/\.#{Site.domain}\z/, '')
else
params[:site_id] + '.'
end
@site_id ||= Deploy.site_name_from_hostname(params[:site_id])
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.headers['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('/')
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] || origin.to_s
redirect_to params[:redirect] || referer || site.url
end
private

View file

@ -44,11 +44,10 @@ module Api
# Genera el Origin correcto a partir de la URL del sitio.
#
# En desarrollo devuelve el Origin enviado.
#
# @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin}
# @return [String]
def return_origin
Rails.env.production? ? Site.find_by(name: site_id).url : origin
site&.deploys&.find_by_hostname(origin_hostname)&.url
end
# La cookie no es accesible a través de JS y todo su contenido
@ -59,6 +58,8 @@ 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] || origin.to_s
redirect_to params[:redirect_to] || referer || site.url
end
private

View file

@ -85,7 +85,9 @@ module Api
# XXX: Este header se puede falsificar de todas formas pero al
# menos es una trampa.
def site_is_origin?
return if origin? && site.urls(slash: false).any? { |u| origin.to_s.start_with? u }
return if site.urls(slash: false).any? do |u|
(origin || origin_from_referer).to_s.start_with? u
end
@reason = 'site_is_not_origin'
render plain: Rails.env.production? ? nil : @reason, status: :precondition_required
@ -116,11 +118,6 @@ 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: sites_names + alternative_names + api_names
render json: Deploy.all.pluck(:hostname)
end
# Sitios con hidden service de Tor
#
# @return [Array] lista de nombres de sitios sin onion aun
def hidden_services
render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name)
render json: DeployHiddenService.temporary.includes(:site).pluck(:name)
end
# Tor va a enviar el onion junto con el nombre del sitio y tenemos
@ -25,10 +25,8 @@ module Api
# @params [String] name
# @params [String] onion
def add_onion
site = Site.find_by(name: params[:name])
if site
usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor'
if (site = Site.find_by_name(params[:name]))
usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie,
params: params
service.add_onion
@ -36,28 +34,6 @@ 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,18 +3,21 @@
# 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 current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl'))
Rack::MiniProfiler.authorize_request if Rails.env.development?
end
# No tenemos índice de sutty, vamos directamente a ver el listado de
@ -29,15 +32,11 @@ 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
id = params[:site_id] || params[:id]
unless (site = current_usuarie.sites.find_by_name(id))
raise SiteNotFound
current_usuarie&.sites&.find_by_name(site_id).tap do |site|
raise SiteNotFound unless site
end
site
end
# Devuelve el idioma actual y si no lo encuentra obtiene uno por
@ -62,6 +61,54 @@ 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 = Site.find_by_name(params[:site_id])
@site = find_site
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 = Site.find_by_name(params[:site_id])
@site = find_site
authorize Collaboration.new(@site)
@invitade = current_usuarie

View file

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

View file

@ -2,9 +2,6 @@
# Controlador para artículos
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,8 +6,6 @@ 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,9 +2,6 @@
# 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
@ -139,8 +136,10 @@ class SitesController < ApplicationController
private
def site
@site ||= find_site
# 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]
end
def site_params

View file

@ -1,18 +0,0 @@
# 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,315 +1,6 @@
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);
}
/// @ts-ignore
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
document.addEventListener("turbolinks:load", () => {
const flash = document.querySelector<HTMLElement>(".js-flash");
@ -330,10 +21,15 @@ document.addEventListener("turbolinks:load", () => {
".editor[data-editor]"
)) {
try {
setupEditor(editorEl);
new SuttyEditor({
target: editorEl,
props: {
textareaEl: editorEl.parentElement!.querySelector("textarea"),
},
});
} catch (error) {
// TODO: mostrar error
console.error("no se pudo iniciar el editor, error completo", error);
console.error(error);
alert(error);
}
}
});

View file

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

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

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

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

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

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

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

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

@ -1,101 +0,0 @@
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,70 +4,63 @@
class DeployJob < ApplicationJob
class DeployException < StandardError; end
attr_reader :site, :deployed
# rubocop:disable Metrics/MethodLength
def perform(site, notify = true, time = Time.now)
def perform(site_id, notify = true, time = Time.now)
ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site)
@site = Site.find(site_id)
@deployed = {}
# 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, notify, time)
DeployJob.perform_in(60, site_id, notify, time)
return
end
@site.update status: 'building'
site.update status: 'building'
# Asegurarse que DeployLocal sea el primero!
@deployed = { deploy_local: deploy_locally }
deployed[:deploy_local] = site.deploy_local.deploy
# 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
deploy_others if deployed[:deploy_local]
# 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
def deploy_local
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end
def deploy_locally
deploy_local.deploy
end
# Correr todas las tareas que no sean el deploy local.
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.deploys.find_by(type: 'DeployLocal')
@deploy_local = @site.deploy_local
# Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben

View file

@ -1,44 +1,138 @@
# frozen_string_literal: true
require 'open3'
# Este modelo implementa los distintos tipos de alojamiento que provee
# Sutty.
#
# Los datos se guardan en la tabla `deploys`. Para guardar los
# atributos, cada modelo tiene que definir su propio `store
# :attributes`.
# 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.
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
def limit
raise NotImplementedError
end
# El espacio ocupado por este deploy.
#
# @return [Integer]
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
def home_dir
site.path
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 gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end
@ -77,9 +171,67 @@ 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,23 +1,21 @@
# frozen_string_literal: true
# Soportar dominios alternativos
class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON
# Soportar dominios alternativos.
class DeployAlternativeDomain < DeployWww
validates :hostname, domainname: true
# Generar un link simbólico del sitio principal al alternativo
def deploy
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
# No hay un hostname por defecto
#
# @return [Nil]
def default_hostname; end
private
def implements_hostname_validation?
true
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
# No hay un hostname por defecto. Debe ser informado por les
# usuaries.
def default_hostname!; end
end

View file

@ -1,18 +1,61 @@
# frozen_string_literal: true
# Genera una versión onion
# Alojar el sitio como un servicio oculto de Tor, que en realidad es un
# link simbólico al DeployLocal.
class DeployHiddenService < DeployWww
def deploy
return true if fqdn.blank?
validates :hostname, format: { with: /\A[a-z2-7]{56}.onion\z/ }
super
end
# Sufijo para todos los dominios temporales.
TEMPORARY_SUFFIX = 'temporary'
def fqdn
values[:onion]
end
# Traer todos los servicios ocultos temporales.
scope :temporary, -> { where("hostname not like '#{TEMPORARY_SUFFIX}%'") }
# Los servicios ocultos son su propio transporte cifrado y
# autenticado.
#
# @return [String]
def url
'http://' + fqdn
"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
end
end

View file

@ -1,11 +1,11 @@
# frozen_string_literal: true
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
# nada más
# Alojamiento local, genera el sitio como si corriéramos `jekyll build`.
class DeployLocal < Deploy
store :values, accessors: %i[], coder: JSON
before_destroy :remove_destination!
# 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?
# Realizamos la construcción del sitio usando Jekyll y un entorno
# limpio para no pasarle secretos
@ -20,42 +20,52 @@ 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|
if File.symlink? file
0
else
File.size(file)
end
File.symlink?(file) ? 0 : File.size(file)
end.inject(:+)
end
def destination
File.join(Rails.root, '_deploy', site.hostname)
# El hostname es el nombre del sitio más el dominio principal.
#
# @return [String]
def default_hostname
"#{site.name}.#{Site.domain}"
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' => home_dir,
'HOME' => site.path,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
@ -66,10 +76,15 @@ 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
@ -79,12 +94,17 @@ 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}")
@ -93,6 +113,9 @@ 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,12 +11,31 @@ class DeployPrivate < DeployLocal
jekyll_build
end
# Hacer el deploy a un directorio privado
# 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]
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,34 +2,48 @@
# Vincula la versión del sitio con www a la versión sin
class DeployWww < Deploy
store :values, accessors: %i[], coder: JSON
before_destroy :remove_destination!
# La forma de hacer este deploy es generar un link simbólico entre el
# directorio canónico y el actual.
#
# @return [Boolean]
def deploy
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
def limit
1
# 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?
end
# Siempre devuelve el espacio ocupado por el link simbólico, no el
# destino.
#
# @return [Integer]
def size
File.size destination
relative_path.size
end
def destination
File.join(Rails.root, '_deploy', fqdn)
end
def fqdn
"www.#{site.hostname}"
# El hostname por defecto incluye WWW
#
# @return [String]
def default_hostname
"www.#{site.deploy_local.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,22 +2,24 @@
require 'zip'
# Genera un ZIP a partir del sitio ya construido
# Genera un ZIP a partir del sitio ya generado y lo coloca para descarga
# dentro del sitio público.
#
# TODO: Firmar con minisign
class DeployZip < Deploy
store :values, accessors: %i[], coder: JSON
# El hostname es el nombre del archivo.
validates :hostname, format: { with: /\.zip\z/ }
# 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.
#
# rubocop:disable Metrics/MethodLength
# @return [Boolean]
def deploy
FileUtils.rm_f path
remove_destination!
time_start
Dir.chdir(destination) do
Zip::File.open(path, Zip::File::CREATE) do |z|
Zip::File.open(hostname, Zip::File::CREATE) do |z|
Dir.glob('./**/**').each do |f|
File.directory?(f) ? z.mkdir(f) : z.add(f, f)
end
@ -31,25 +33,47 @@ class DeployZip < Deploy
File.exist? path
end
# rubocop:enable Metrics/MethodLength
def limit
1
# La URL de descarga del archivo.
#
# @return [String]
def url
"#{site.deploy_local.url}/#{hostname}"
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
File.join(Rails.root, '_deploy', site.hostname)
site.deploy_local.destination
end
def file
"#{site.hostname}.zip"
# El "hostname" es la ubicación del archivo.
#
# @return [String]
def default_hostname
"#{site.deploy_local.hostname}.zip"
end
def path
File.join(destination, file)
File.join(destination, hostname)
end
private
def remove_destination!
FileUtils.rm_f path
end
def implements_hostname_validation?
true
end
end

View file

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

View file

@ -7,6 +7,7 @@ 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
@ -15,19 +16,11 @@ 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
@ -38,8 +31,6 @@ 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
@ -58,13 +49,11 @@ class Site < ApplicationRecord
after_initialize :load_jekyll
after_create :load_jekyll, :static_file_migration!
# Cambiar el nombre del directorio
before_update :update_name!
before_update :update_name!, if: :name_changed?
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
@ -85,49 +74,6 @@ 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
@ -453,8 +399,6 @@ class Site < ApplicationRecord
end
def update_name!
return unless name_changed?
FileUtils.mv path_was, path
reload_jekyll!
end
@ -477,19 +421,6 @@ 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

@ -0,0 +1,113 @@
# 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
SiteStat = Struct.new(:site)
SiteBlazer = Struct.new(:site)

View file

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

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Les invitades no pueden ver las estadísticas (aun)
SiteBlazerPolicy = Struct.new(:usuarie, :site_blazer) do
def home?
site_blazer&.site&.usuarie? usuarie
end
alias_method :show?, :home?
end

View file

@ -1,15 +0,0 @@
# 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,11 +13,10 @@ 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)
commit_config(action: :create) &&
add_licencias
end
add_licencias
site
end

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
%h1= t('.hi')
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname),
= sanitize_markdown t('.explanation', url: @site.deploy_local.url),
tags: %w[p a strong em]
%table

View file

@ -1,6 +1,6 @@
= '# ' + t('.hi')
\
= t('.explanation', fqdn: @deploy_local.site.hostname)
= t('.explanation', url: @site.deploy_local.url)
\
= 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: deploy.object.site.url),
= sanitize_markdown t('.help', public_url: site.deploy_local.url),
tags: %w[p strong em a]
- if deploy.object.fqdn
- unless deploy.object.temporary?
= sanitize_markdown t('.help_2', url: deploy.object.url),
tags: %w[p strong em a]
%hr/

View file

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

View file

@ -15,10 +15,5 @@
'0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title')
-# 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]
= sanitize_markdown t('.help', url: deploy.object.url), 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
- if @current_usuarie || 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'), destroy_usuarie_session_path,
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn'

View file

@ -0,0 +1,14 @@
!!!
%html
%head
%meta{content: 'text/html; charset=UTF-8', 'http-equiv': 'Content-Type'}/
%title= blazer_title ? blazer_title : 'Sutty'
%meta{charset: 'utf-8'}/
= favicon_link_tag 'blazer/favicon.png'
= stylesheet_link_tag 'application'
= javascript_pack_tag 'blazer', 'data-turbolinks-track': 'reload'
= csrf_meta_tags
%body{ class: yield(:body) }
.container-fluid#sutty
= render 'layouts/breadcrumb'
= yield

View file

@ -9,119 +9,6 @@
.alert.alert-info
:markdown
#{t('editor.alert')}
= text_area_tag "#{base}[#{attribute}]", '',
= text_area_tag "#{base}[#{attribute}]", metadata.value.html_safe,
dir: dir, lang: locale,
**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
**field_options(attribute, metadata)

View file

@ -38,6 +38,13 @@ 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_admin
before_action_method: require_usuarie
# email to send checks from
from_email: blazer@<%= ENV.fetch('SUTTY', 'sutty.nl') %>

View file

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

View file

@ -13,29 +13,19 @@ 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
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
hostname = URI(source)&.host
hostname.present? && Deploy.find_by_hostname(hostname).present?
rescue StandardError
false
end
end

View file

@ -0,0 +1,6 @@
# 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 <https://%{fqdn}>.
available at <%{url}/>.
You'll find details below.
th:
@ -143,8 +143,15 @@ 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:
@ -195,7 +202,7 @@ en:
deploy_local:
title: 'Host at Sutty'
help: |
The site will be available at <https://%{fqdn}/>.
The site will be available at <%{url}/>.
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)!
@ -211,7 +218,7 @@ en:
title: 'Add www to the address'
help: |
When you enable this option, your site will also be available
under <https://%{fqdn}/>.
under <%{url}/>.
The www prefix has been a way of referring to
computers that are available on the World Wide Web. Since
@ -222,7 +229,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 <https://%{fqdn}/%{file}> address, keep it as backup
through the <%{url}> address, keep it as backup
or have a strategy of solidary hosting, where many people
share a copy of your site.
@ -576,3 +583,14 @@ 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 <https://%{fqdn}>.
ya está disponible en la dirección <%{url}>.
A continuación encontrarás el detalle de lo que hicimos.
th:
@ -143,8 +143,15 @@ 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:
@ -197,7 +204,7 @@ es:
deploy_local:
title: 'Alojar en Sutty'
help: |
El sitio estará disponible en <https://%{fqdn}/>.
El sitio estará disponible en <%{url}/>.
Estamos desarrollando la posibilidad de agregar tus propios
dominios, ¡ayudanos!
@ -213,7 +220,7 @@ es:
title: 'Agregar www a la dirección'
help: |
Cuando habilitas esta opción, tu sitio también estará disponible
como <https://%{fqdn}/>.
como <%{url}/>.
El prefijo www para las direcciones web ha sido una forma de
referirse a las computadoras que están disponibles en la _World
@ -226,7 +233,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 <https://%{fqdn}/%{file}> y
entero a través de la dirección <%{url}> y
guardarla como copia de seguridad o una estrategia de
alojamiento solidario, donde muchas personas comparten una copia
de tu sitio.
@ -584,3 +591,14 @@ 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,8 +4,6 @@ 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
@ -38,6 +36,9 @@ 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'
@ -73,7 +74,5 @@ 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,8 +3,6 @@
# 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

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

@ -0,0 +1,26 @@
# 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,7 +12,10 @@
"@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,13 +21,14 @@ module Api
end
test 'el sitio tiene que existir' do
hostname = @site.hostname
@site.destroy
get v1_site_contact_cookie_url(@site.hostname, **@host)
get v1_site_contact_cookie_url(hostname, **@host)
assert_not cookies[@site.name]
post v1_site_contact_url(site_id: @site.hostname, form: :contacto, **@host),
post v1_site_contact_url(site_id: hostname, form: :contacto, **@host),
params: {
name: SecureRandom.hex,
pronouns: SecureRandom.hex,
@ -106,7 +107,7 @@ module Api
test 'se puede enviar mensajes a dominios propios' do
ActionMailer::Base.deliveries.clear
@site.update name: 'example.org.'
@site.update name: 'example'
redirect = "#{@site.url}?thanks"
@ -130,6 +131,34 @@ 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 Site.all.pluck(:name), JSON.parse(response.body)
assert_equal Deploy.all.pluck(:hostname), JSON.parse(response.body)
end
end
end

View file

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

View file

@ -9,11 +9,11 @@ FactoryBot.define do
licencia
after :build do |site|
site.deploys << build(:deploy_local, site: site)
end
after :create do |site|
site.deploys << create(:deploy_local, site: 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')
end
end
end

View file

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

View file

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

@ -0,0 +1,36 @@
# 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,24 +1,44 @@
# 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
site = create :site
local = create :deploy_local, site: site
deploy = create :deploy_zip, site: site
deploy_local = @site.deploy_local
# Primero tenemos que generar el sitio
local.deploy
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
escaped_path = Shellwords.escape(deploy.path)
assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive?
assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive?
end
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
test 'al eliminarlos se elimina el directorio' do
deploy_local = @site.deploy_local
assert deploy_local.destroy
assert_not File.directory?(deploy_local.destination)
end
end

View file

@ -3,17 +3,23 @@
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
site = create :site
local = create :deploy_local, site: site
deploy = create :deploy_www, site: site
assert @site.deploy_local.deploy
# Primero tenemos que generar el sitio
local.deploy
assert deploy.deploy
assert File.symlink?(deploy.destination)
local.destroy
assert @deploy_www.deploy
assert File.symlink?(@deploy_www.destination)
end
end

View file

@ -3,18 +3,29 @@
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
deploy_local = create :deploy_local
# Primero tenemos que generar el sitio
assert @site.deploy_local.deploy
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)
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?
end
end

View file

@ -0,0 +1,88 @@
# 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 puede contener subdominios' do
test 'el nombre del sitio no puede contener subdominios' do
@site = build :site, name: 'hola.chau'
site.validate
assert_not site.errors.messages[:name].present?
assert site.errors.messages[:name].present?
end
test 'el nombre del sitio puede terminar con punto' do
test 'el nombre del sitio no puede terminar con punto' do
@site = build :site, name: 'hola.chau.'
site.validate
assert_not site.errors.messages[:name].present?
assert 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.name = name = SecureRandom.hex
site.update(name: (new_name = SecureRandom.hex))
assert_equal "#{name}.#{Site.domain}", site.hostname
assert_equal "#{new_name}.#{Site.domain}", site.hostname
end
test 'se pueden traer los datos de una plantilla' do

View file

@ -1171,6 +1171,11 @@
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"
@ -2119,6 +2124,34 @@ 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"
@ -2238,7 +2271,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.0, color-convert@^1.9.1, color-convert@^1.9.3:
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==
@ -5005,6 +5038,11 @@ 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"