Compare commits
138 commits
rails
...
staging-cd
Author | SHA1 | Date | |
---|---|---|---|
59506708cc | |||
0dfaee9502 | |||
65798b9c91 | |||
29300fed42 | |||
a103b53d74 | |||
a5b1af5a6b | |||
436ee1d048 | |||
abf8b8851c | |||
3442b63b49 | |||
41044b2716 | |||
27c92bb185 | |||
9832d0ec12 | |||
098a69c047 | |||
98d2bd9a12 | |||
a5be895321 | |||
78a5a513b6 | |||
a3c3ef4d05 | |||
69b56a0451 | |||
84a0635185 | |||
2374719d62 | |||
c26d69777e | |||
2af04cf76e | |||
04077b3891 | |||
ce308defb9 | |||
6144ed4708 | |||
c00635cc19 | |||
097fed7634 | |||
76de4233a2 | |||
d39f1809e6 | |||
090bf122bd | |||
fbe743a652 | |||
a4f2b6a5df | |||
f162c21742 | |||
943dcfdd22 | |||
![]() |
b204314c6c | ||
![]() |
ed213cda9f | ||
![]() |
4753b162eb | ||
![]() |
a4ca89a36b | ||
![]() |
792038ce64 | ||
![]() |
be549c5cd5 | ||
![]() |
12d5c3ae8c | ||
![]() |
9ccc1be898 | ||
![]() |
d151fffdd2 | ||
![]() |
1333eed1d3 | ||
![]() |
2eff3ba716 | ||
![]() |
c1a9aaa037 | ||
![]() |
339c02c92b | ||
![]() |
5a53facaa7 | ||
![]() |
3e76ffe4c4 | ||
![]() |
b20b15317b | ||
![]() |
341a693a35 | ||
![]() |
e6ea1001cd | ||
![]() |
df29c4ca95 | ||
![]() |
a1fde94378 | ||
![]() |
dcaac06fa4 | ||
![]() |
baf6d203b8 | ||
![]() |
75e7fe76fe | ||
![]() |
0bd8a2243e | ||
![]() |
4666963c91 | ||
![]() |
c0b5863573 | ||
![]() |
6467a265d3 | ||
![]() |
063d2625fb | ||
![]() |
77e583c7cb | ||
![]() |
aebe48c784 | ||
![]() |
b2508de39b | ||
![]() |
22a7a811b4 | ||
![]() |
61b3b97313 | ||
![]() |
e14e53c2a1 | ||
![]() |
11e45bbc05 | ||
![]() |
70c8bcbcd1 | ||
![]() |
45559ad58f | ||
![]() |
c08cfb1637 | ||
![]() |
b9d7a27105 | ||
![]() |
4ea2169c68 | ||
![]() |
f50e202d3a | ||
![]() |
f8f9e722be | ||
![]() |
64a2dc146d | ||
![]() |
5885dd7e96 | ||
![]() |
249b115af8 | ||
![]() |
71436d3be4 | ||
![]() |
71ff9e5e7b | ||
![]() |
e87fad33ea | ||
![]() |
489cbb414c | ||
![]() |
6f08ca6c36 | ||
![]() |
44450da520 | ||
![]() |
af67c39dc4 | ||
![]() |
841279f6cf | ||
![]() |
75e6b7a801 | ||
![]() |
85ad518d8d | ||
![]() |
49e5603687 | ||
![]() |
ddc459130a | ||
![]() |
7511afbf88 | ||
![]() |
3a2ce1d47d | ||
![]() |
bb2697f63e | ||
![]() |
2be57ad3af | ||
![]() |
0d8f0ec5ee | ||
![]() |
e49d8484a2 | ||
![]() |
27b4494333 | ||
![]() |
fa9884afdd | ||
![]() |
e1749d6c70 | ||
![]() |
e1664d0c12 | ||
![]() |
7cfd55e126 | ||
![]() |
3e0b94fbce | ||
![]() |
113498b4bf | ||
![]() |
5d9c379e0e | ||
![]() |
6b67f13aaf | ||
![]() |
1b099cf96b | ||
![]() |
3a170c191b | ||
![]() |
360eec5c2c | ||
![]() |
05a38f5b7e | ||
![]() |
ffa2c80bf1 | ||
![]() |
06a9b78eee | ||
![]() |
a64f554f0e | ||
![]() |
7e0600779e | ||
![]() |
583a5c0ebc | ||
![]() |
985ee40aa7 | ||
![]() |
1fd9524150 | ||
![]() |
99e9ec2659 | ||
![]() |
316c04e86a | ||
![]() |
0ffa7feb29 | ||
![]() |
0b9d178d90 | ||
![]() |
134535cb1c | ||
![]() |
5ab754ff5d | ||
![]() |
d087c3bb09 | ||
![]() |
c6b6a1f74e | ||
![]() |
908a0a90a8 | ||
![]() |
3bae282d8b | ||
![]() |
43927d78e7 | ||
![]() |
f192c14947 | ||
![]() |
11bbbe3b06 | ||
![]() |
db8ed8d776 | ||
![]() |
4bc163a2fb | ||
![]() |
464ac4df8d | ||
![]() |
bc1566735d | ||
![]() |
9473fa7b93 | ||
![]() |
d6c41d468b | ||
![]() |
0f92cf193f | ||
![]() |
8b9fddc171 |
83 changed files with 1571 additions and 1806 deletions
21
.woodpecker.yml
Normal file
21
.woodpecker.yml
Normal 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
|
|
@ -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
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -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'
|
||||
|
|
185
Gemfile.lock
185
Gemfile.lock
|
@ -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
|
||||
|
|
20
Makefile
20
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
#
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
194
app/controllers/concerns/blazer_decorator.rb
Normal file
194
app/controllers/concerns/blazer_decorator.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
113
app/models/site/deployment.rb
Normal file
113
app/models/site/deployment.rb
Normal 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
|
|
@ -1,3 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
SiteStat = Struct.new(:site)
|
||||
SiteBlazer = Struct.new(:site)
|
|
@ -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
|
||||
|
|
10
app/policies/site_blazer_policy.rb
Normal file
10
app/policies/site_blazer_policy.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
5
app/views/blazer/check_mailer/failing_checks.haml
Normal file
5
app/views/blazer/check_mailer/failing_checks.haml
Normal file
|
@ -0,0 +1,5 @@
|
|||
%ul
|
||||
- @checks.each do |check|
|
||||
%li
|
||||
= check.query.name
|
||||
= check.state
|
30
app/views/blazer/check_mailer/state_change.haml
Normal file
30
app/views/blazer/check_mailer/state_change.haml
Normal 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
|
9
app/views/blazer/queries/home.haml
Normal file
9
app/views/blazer/queries/home.haml
Normal 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}"
|
51
app/views/blazer/queries/show.haml
Normal file
51
app/views/blazer/queries/show.haml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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')]
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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'
|
||||
|
|
14
app/views/layouts/blazer/application.haml
Normal file
14
app/views/layouts/blazer/application.haml
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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') %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
6
config/initializers/validates_hostname.rb
Normal file
6
config/initializers/validates_hostname.rb
Normal 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?
|
|
@ -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)'
|
||||
|
|
|
@ -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)'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
56
db/migrate/20210801060844_add_hostname_to_deploys.rb
Normal file
56
db/migrate/20210801060844_add_hostname_to_deploys.rb
Normal 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
|
26
db/migrate/20210809155434_add_canonical_deploy_to_sites.rb
Normal file
26
db/migrate/20210809155434_add_canonical_deploy_to_sites.rb
Normal 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
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
44
test/models/deploy_alternative_domain_test.rb
Normal file
44
test/models/deploy_alternative_domain_test.rb
Normal 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
|
36
test/models/deploy_hidden_service_test.rb
Normal file
36
test/models/deploy_hidden_service_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
88
test/models/site/deployment_test.rb
Normal file
88
test/models/site/deployment_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
40
yarn.lock
40
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue