diff --git a/.dockerignore b/.dockerignore
index afe4e8d7..7b84d429 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,3 +2,4 @@
*
# Solo agregar lo que usamos en COPY
# !./archivo
+!./monit.conf
diff --git a/.env.example b/.env.example
index fb086224..f3cf48d9 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,9 @@
+# pwgen -1 32
+RAILS_MASTER_KEY=11111111111111111111111111111111
RAILS_GROUPS=assets
DELEGATE=athshe.sutty.nl
HAINISH=../haini.sh/haini.sh
-DATABASE=
+DATABASE_URL=postgres://suttier@postgresql.sutty.local/sutty
RAILS_ENV=development
IMAP_SERVER=
DEFAULT_FROM=
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 00000000..f8994356
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,33 @@
+image: "gitea.nulo.in/sutty/panel:3.14.10-2.7.8-panel.sutty.nl"
+variables:
+ RAILS_ENV: "production"
+ LC_ALL: "C.UTF-8"
+cache:
+ paths:
+ - "vendor/ruby"
+assets:
+ stage: "build"
+ rules:
+ - if: "$CI_COMMIT_BRANCH == \"panel.sutty.nl\""
+ - if: "$CI_COMMIT_BRANCH"
+ changes:
+ compare_to: "refs/heads/rails"
+ paths:
+ - "package.json"
+ - "app/javascript/**/*"
+ - "app/assets/**/*"
+ before_script:
+ - "git config --global user.email \"${GIT_USER_EMAIL:-$GITLAB_USER_EMAIL}\""
+ - "git config --global user.name \"${GIT_USER_NAME:-$GITLAB_USER_NAME}\""
+ - "git remote set-url --push origin \"https://${GITLAB_USERNAME}:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git\""
+ - "apk add python2 dotenv brotli"
+ - "mv config/credentials.yml.enc.ci config/credentials.yml.enc"
+ - "cp .env.example .env"
+ - "dotenv bundle install --path=vendor"
+ script:
+ - "dotenv RAILS_ENV=production bundle exec rails webpacker:clobber"
+ - "dotenv RAILS_ENV=production bundle exec rails assets:precompile"
+ - "dotenv RAILS_ENV=production bundle exec rails assets:clean"
+ after_script:
+ - "git add public && git commit -m \"ci: assets [skip ci]\""
+ - "git push -o ci.skip"
diff --git a/.profile b/.profile
new file mode 100644
index 00000000..3c73ffa9
--- /dev/null
+++ b/.profile
@@ -0,0 +1,9 @@
+Color_Off='\e[0m'
+BPurple='\e[1;35m'
+BBlue='\e[1;34m'
+
+is_git() {
+ git rev-parse --abbrev-ref HEAD 2>/dev/null
+}
+
+PS1="\[${BPurple}\]\$(is_git) \[${BBlue}\]\W\[${Color_Off}\] >_ "
diff --git a/.woodpecker.yml b/.woodpecker.yml
new file mode 100644
index 00000000..cdd99651
--- /dev/null
+++ b/.woodpecker.yml
@@ -0,0 +1,72 @@
+pipeline:
+ publish:
+ image: "docker.io/woodpeckerci/plugin-docker-buildx"
+ settings:
+ registry: "gitea.nulo.in"
+ username: "sutty"
+ repo: "gitea.nulo.in/sutty/panel"
+ tags:
+ - "${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}-${CI_COMMIT_BRANCH}"
+ - "latest"
+ build_args:
+ - "RUBY_VERSION=${RUBY_VERSION}"
+ - "RUBY_PATCH=${RUBY_PATCH}"
+ - "ALPINE_VERSION=${ALPINE_VERSION}"
+ - "BASE_IMAGE=gitea.nulo.in/sutty/rails"
+ purge: false
+ secrets:
+ - "DOCKER_PASSWORD"
+ when:
+ branch:
+ - "rails"
+ - "panel.sutty.nl"
+ event: "push"
+ path:
+ include:
+ - "Dockerfile"
+ - ".dockerignore"
+ - ".woodpecker.yml"
+ assets:
+ image: "gitea.nulo.in/sutty/panel:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}"
+ commands:
+ - "apk add python2 dotenv openssh-client brotli"
+ - "install -d -m 700 ~/.ssh/"
+ - "echo \"$${KNOWN_HOSTS}\" | base64 -d >> ~/.ssh/known_hosts"
+ - "chmod 600 ~/.ssh/known_hosts"
+ - "eval $(ssh-agent -s)"
+ - "echo \"$${SSH_KEY}\" | base64 -d | ssh-add -"
+ - "ssh $${ORIGIN%:*}"
+ - "git config user.name Woodpecker"
+ - "git config user.email ci@sutty.coop.ar"
+ - "git remote add upstream $${ORIGIN}"
+ - "git checkout -B ${CI_COMMIT_BRANCH}"
+ - "mv config/credentials.yml.enc.ci config/credentials.yml.enc"
+ - "yarn"
+ - "cp .env.example .env"
+ - "dotenv bundle install --path=vendor"
+ - "dotenv RAILS_ENV=production bundle exec rails webpacker:clobber"
+ - "dotenv RAILS_ENV=production bundle exec rails assets:precompile"
+ - "dotenv RAILS_ENV=production bundle exec rails assets:clean"
+ - "find public -type f -print0 | xargs -r0 brotli -k9f"
+ - "git add public && git commit -m \"ci: assets [skip ci]\""
+ - "git pull upstream ${CI_COMMIT_BRANCH}"
+ - "git push upstream ${CI_COMMIT_BRANCH}"
+ secrets:
+ - "SSH_KEY"
+ - "KNOWN_HOSTS"
+ - "ORIGIN"
+ when:
+ branch:
+ - "rails"
+ - "panel.sutty.nl"
+ path:
+ include:
+ - "app/assets/**/*"
+ - "app/javascript/**/*"
+ - "package.json"
+ - "yarn.lock"
+matrix:
+ include:
+ - ALPINE_VERSION: "3.14.10"
+ RUBY_VERSION: "2.7"
+ RUBY_PATCH: "8"
diff --git a/Dockerfile b/Dockerfile
index ecf43cbc..e0f1dc9f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,9 @@
-FROM registry.nulo.in/sutty/rails:3.13.6-2.7.5
-ARG PANDOC_VERSION=2.17.1.1
+ARG RUBY_VERSION=2.7
+ARG RUBY_PATCH=6
+ARG ALPINE_VERSION=3.13.10
+ARG BASE_IMAGE=registry.nulo.in/sutty/rails
+FROM ${BASE_IMAGE}:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}
+ARG PANDOC_VERSION=2.18
ENV RAILS_ENV production
# Instalar las dependencias, separamos la librería de base de datos para
@@ -10,10 +14,15 @@ ENV RAILS_ENV production
# principal
RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \
- yarn daemonize ruby-webrick
+ yarn daemonize ruby-webrick postgresql-client dateutils file
RUN gem install --no-document --no-user-install foreman
RUN wget https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz -O - | tar --strip-components 1 -xvzf - pandoc-${PANDOC_VERSION}/bin/pandoc && mv /bin/pandoc /usr/bin/pandoc
+RUN apk add npm && npm install -g pnpm@~7 && apk del npm
+
+COPY ./monit.conf /etc/monit.d/sutty.conf
+
+RUN apk add npm && npm install -g pnpm && apk del npm
VOLUME "/srv"
diff --git a/Gemfile b/Gemfile
index 1e476dde..b2472035 100644
--- a/Gemfile
+++ b/Gemfile
@@ -23,6 +23,7 @@ if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
end
gem 'nokogiri'
+gem 'rgl'
# Turbolinks makes navigating your web application faster. Read more:
# https://github.com/turbolinks/turbolinks
@@ -38,6 +39,8 @@ gem 'commonmarker'
gem 'devise'
gem 'devise-i18n'
gem 'devise_invitable'
+gem 'distributed-press-api-client', '~> 0.2.3'
+gem 'njalla-api-client', '~> 0.2.0'
gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n'
gem 'exception_notification'
gem 'fast_blank'
@@ -48,9 +51,9 @@ gem 'image_processing'
gem 'icalendar'
gem 'inline_svg'
gem 'httparty'
-gem 'safe_yaml', source: 'https://gems.sutty.nl'
+gem 'safe_yaml'
gem 'jekyll', '~> 4.2'
-gem 'jekyll-data', source: 'https://gems.sutty.nl'
+gem 'jekyll-data'
gem 'jekyll-commonmark'
gem 'jekyll-images'
gem 'jekyll-include-cache'
@@ -89,7 +92,7 @@ gem 'stackprof'
gem 'prometheus_exporter'
# debug
-gem 'fast_jsonparser'
+gem 'fast_jsonparser', '~> 0.5.0'
gem 'down'
gem 'sourcemap'
gem 'rack-cors'
@@ -99,18 +102,6 @@ gem 'net-ssh'
gem 'ed25519'
gem 'bcrypt_pbkdf'
-group :themes do
- gem 'adhesiones-jekyll-theme', require: false
- gem 'editorial-autogestiva-jekyll-theme', require: false
- gem 'minima', require: false
- gem 'sutty-minima', require: false
- gem 'radios-comunitarias-jekyll-theme', require: false
- gem 'share-to-fediverse-jekyll-theme', require: false
- gem 'sutty-donaciones-jekyll-theme', require: false
- gem 'sutty-jekyll-theme', require: false
- gem 'recursero-jekyll-theme', require: false
-end
-
group :production do
gem 'lograge'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 87812726..67ce13e2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -88,15 +88,6 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
- adhesiones-jekyll-theme (0.2.1)
- jekyll (~> 4.0)
- jekyll-data (~> 1.1)
- jekyll-feed (~> 0.9)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-locales (~> 0.1)
- jekyll-relative-urls (~> 0.0)
- jekyll-seo-tag (~> 2.1)
ast (2.4.2)
autoprefixer-rails (10.3.3.0)
execjs (~> 2)
@@ -124,6 +115,7 @@ GEM
xpath (>= 2.0, < 4.0)
chartkick (4.1.2)
childprocess (4.1.0)
+ climate_control (1.2.0)
coderay (1.1.3)
colorator (1.1.0)
commonmarker (0.21.2-x86_64-linux-musl)
@@ -162,32 +154,46 @@ GEM
devise_invitable (2.0.5)
actionmailer (>= 5.0)
devise (>= 4.6)
+ distributed-press-api-client (0.2.2)
+ addressable (~> 2.3, >= 2.3.0)
+ climate_control
+ dry-schema
+ httparty (~> 0.18)
+ json (~> 2.1, >= 2.1.0)
+ jwt (~> 2.6.0)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.4)
addressable (~> 2.8)
+ dry-configurable (1.0.1)
+ dry-core (~> 1.0, < 2)
+ zeitwerk (~> 2.6)
+ dry-core (1.0.0)
+ concurrent-ruby (~> 1.0)
+ zeitwerk (~> 2.6)
+ dry-inflector (1.0.0)
+ dry-initializer (3.1.1)
+ dry-logic (1.5.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0, < 2)
+ zeitwerk (~> 2.6)
+ dry-schema (1.13.0)
+ concurrent-ruby (~> 1.0)
+ dry-configurable (~> 1.0, >= 1.0.1)
+ dry-core (~> 1.0, < 2)
+ dry-initializer (~> 3.0)
+ dry-logic (>= 1.5, < 2)
+ dry-types (>= 1.7, < 2)
+ zeitwerk (~> 2.6)
+ dry-types (1.7.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0, < 2)
+ dry-inflector (~> 1.0, < 2)
+ dry-logic (>= 1.4, < 2)
+ zeitwerk (~> 2.6)
ed25519 (1.2.4-x86_64-linux-musl)
- editorial-autogestiva-jekyll-theme (0.3.4)
- jekyll (~> 4)
- jekyll-commonmark (~> 1.3)
- jekyll-data (~> 1.1)
- jekyll-dotenv (>= 0.2)
- jekyll-feed (~> 0.15)
- jekyll-hardlinks (~> 0)
- jekyll-ignore-layouts (~> 0)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-linked-posts (~> 0)
- jekyll-locales (~> 0.1)
- jekyll-order (~> 0)
- jekyll-relative-urls (~> 0)
- jekyll-seo-tag (~> 2)
- jekyll-spree-client (~> 0)
- jekyll-unique-urls (~> 0)
- jekyll-write-and-commit-changes (~> 0)
- sutty-liquid (~> 0)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
@@ -244,8 +250,8 @@ GEM
thor
hiredis (0.6.3-x86_64-linux-musl)
http_parser.rb (0.8.0-x86_64-linux-musl)
- httparty (0.18.1)
- mime-types (~> 3.0)
+ httparty (0.21.0)
+ mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
@@ -289,7 +295,7 @@ GEM
jekyll (~> 4)
jekyll-ignore-layouts (0.1.2)
jekyll (~> 4)
- jekyll-images (0.3.0)
+ jekyll-images (0.3.2)
jekyll (~> 4)
ruby-filemagic (~> 0.7)
ruby-vips (~> 2)
@@ -320,6 +326,7 @@ GEM
jekyll-write-and-commit-changes (0.2.1)
jekyll (~> 4)
rugged (~> 1)
+ jwt (2.6.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@@ -369,10 +376,6 @@ GEM
mini_magick (4.11.0)
mini_mime (1.1.2)
mini_portile2 (2.6.1)
- minima (2.5.1)
- jekyll (>= 3.5, < 5.0)
- jekyll-feed (~> 0.9)
- jekyll-seo-tag (~> 2.1)
minitest (5.14.4)
mobility (1.2.4)
i18n (>= 0.6.10, < 2)
@@ -384,7 +387,11 @@ GEM
nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
+ njalla-api-client (0.2.0)
+ dry-schema
+ httparty (~> 0.18)
orm_adapter (0.5.0)
+ pairing_heap (3.0.0)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
@@ -415,17 +422,6 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
- radios-comunitarias-jekyll-theme (0.1.5)
- jekyll (~> 4.0)
- jekyll-data (~> 1.1)
- jekyll-feed (~> 0.9)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-linked-posts (~> 0)
- jekyll-locales (~> 0.1)
- jekyll-relative-urls (~> 0.0)
- jekyll-seo-tag (~> 2.1)
- jekyll-turbolinks (~> 0)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
@@ -462,24 +458,6 @@ GEM
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
- recursero-jekyll-theme (0.2.0)
- jekyll (~> 4)
- jekyll-commonmark (~> 1.3)
- jekyll-data (~> 1.1)
- jekyll-dotenv (>= 0.2)
- jekyll-feed (~> 0.15)
- jekyll-ignore-layouts (~> 0)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-linked-posts (~> 0)
- jekyll-locales (~> 0.1)
- jekyll-lunr (~> 0.1)
- jekyll-order (~> 0)
- jekyll-relative-urls (~> 0)
- jekyll-seo-tag (~> 2)
- jekyll-unique-urls (~> 0.1)
- sutty-archives (~> 2.2)
- sutty-liquid (~> 0)
redis (4.5.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
@@ -504,6 +482,10 @@ GEM
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
+ rgl (0.6.2)
+ pairing_heap (>= 0.3.0)
+ rexml (~> 3.2, >= 3.2.4)
+ stream (~> 0.5.3)
rouge (3.26.1)
rubocop (1.23.0)
parallel (~> 1.10)
@@ -552,14 +534,6 @@ GEM
rubyzip (>= 1.2.2)
semantic_range (3.0.0)
sexp_processor (4.16.0)
- share-to-fediverse-jekyll-theme (0.1.4)
- jekyll (~> 4.0)
- jekyll-data (~> 1.1)
- jekyll-feed (~> 0.9)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-relative-urls (~> 0.0)
- jekyll-seo-tag (~> 2.1)
simpleidn (0.2.1)
unf (~> 0.1.4)
sourcemap (0.1.1)
@@ -579,34 +553,14 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (1.4.2-x86_64-linux-musl)
stackprof (0.2.17-x86_64-linux-musl)
+ stream (0.5.5)
sucker_punch (3.0.1)
concurrent-ruby (~> 1.0)
sutty-archives (2.5.4)
jekyll (>= 3.6, < 5.0)
- sutty-donaciones-jekyll-theme (0.1.2)
- jekyll (~> 4.0)
- jekyll-data (~> 1.1)
- jekyll-feed (~> 0.9)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-locales (~> 0.1)
- jekyll-relative-urls (~> 0.0)
- jekyll-seo-tag (~> 2.1)
- sutty-archives (~> 2.2)
- sutty-jekyll-theme (0.1.2)
- jekyll (~> 4.0)
- jekyll-feed (~> 0.9)
- jekyll-images (~> 0.2)
- jekyll-include-cache (~> 0)
- jekyll-relative-urls (~> 0.0)
- jekyll-seo-tag (~> 2.1)
sutty-liquid (0.7.4)
fast_blank (~> 1.0)
jekyll (~> 4)
- sutty-minima (2.5.0)
- jekyll (>= 3.5, < 5.0)
- jekyll-feed (~> 0.9)
- jekyll-seo-tag (~> 2.1)
symbol-fstring (1.0.2-x86_64-linux-musl)
sysexits (1.2.0)
temple (0.8.2)
@@ -654,7 +608,6 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
- adhesiones-jekyll-theme
bcrypt (~> 3.1.7)
bcrypt_pbkdf
blazer
@@ -669,10 +622,10 @@ DEPENDENCIES
devise
devise-i18n
devise_invitable
+ distributed-press-api-client (~> 0.2.3)
dotenv-rails
down
ed25519
- editorial-autogestiva-jekyll-theme
email_address!
exception_notification
factory_bot_rails
@@ -691,7 +644,7 @@ DEPENDENCIES
jbuilder (~> 2.5)
jekyll (~> 4.2)
jekyll-commonmark
- jekyll-data!
+ jekyll-data
jekyll-images
jekyll-include-cache
kaminari
@@ -702,9 +655,9 @@ DEPENDENCIES
lograge
memory_profiler
mini_magick
- minima
mobility
net-ssh
+ njalla-api-client
nokogiri
pg
pg_search
@@ -714,31 +667,26 @@ DEPENDENCIES
pundit
rack-cors
rack-mini-profiler
- radios-comunitarias-jekyll-theme
rails (~> 6)
rails-i18n
rails_warden
- recursero-jekyll-theme
redis
redis-rails
+ rgl
rollups!
rubocop-rails
rubyzip
rugged
- safe_yaml!
+ safe_yaml
sassc-rails
selenium-webdriver
- share-to-fediverse-jekyll-theme
sourcemap
spring
spring-watcher-listen (~> 2.0.0)
sqlite3
stackprof
sucker_punch
- sutty-donaciones-jekyll-theme
- sutty-jekyll-theme
sutty-liquid (>= 0.7.3)
- sutty-minima
symbol-fstring
terminal-table
timecop
diff --git a/Procfile b/Procfile
index 8f6c7741..4cc6e5b3 100644
--- a/Procfile
+++ b/Procfile
@@ -5,4 +5,7 @@ blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
+distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
+cleanup: bundle exec rake cleanup:everything
stats: bundle exec rake stats:process_all
+distributed_press_renew_tokens: bundle exec rake distributed_press:tokens:renew
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index dc61b5d3..84e43593 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -25,6 +25,15 @@ $spacers: (
2-plus: 0.75rem
);
+$sizes: (
+ "70ch": 70ch,
+);
+
+.btn {
+ background-color: var(--foreground);
+ color: var(--background);
+}
+
@import "bootstrap";
@import "editor";
@@ -154,6 +163,12 @@ ol.breadcrumb {
transition: all 3s;
}
+fieldset {
+ legend {
+ font-size: 1rem;
+ }
+}
+
.mapable,
.taggable {
.input-map,
@@ -194,8 +209,6 @@ svg {
}
.btn {
- background-color: var(--foreground);
- color: var(--background);
border: none;
border-radius: 0;
margin-right: 0.3rem;
@@ -373,6 +386,9 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
}
}
+.word-break-all { word-break: break-all !important; }
+.hyphens { hyphens: auto; }
+
/*
* Modificadores de Bootstrap que no tienen versión responsive.
*/
@@ -395,6 +411,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
.text-#{$grid-breakpoint}-right { text-align: right !important; }
.text-#{$grid-breakpoint}-center { text-align: center !important; }
+ .word-break-#{$grid-breakpoint}-all { word-break: break-all !important; }
+
// posición
@each $position in $positions {
.position-#{$grid-breakpoint}-#{$position} { position: $position !important; }
@@ -404,6 +422,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
@each $prop, $abbrev in (width: w, height: h) {
@each $size, $length in $sizes {
.#{$abbrev}-#{$grid-breakpoint}-#{$size} { #{$prop}: $length !important; }
+ .min-#{$abbrev}-#{$grid-breakpoint}-#{$size} { min-#{$prop}: $length !important; }
+ .max-#{$abbrev}-#{$grid-breakpoint}-#{$size} { max-#{$prop}: $length !important; }
}
}
diff --git a/app/controllers/api/v1/contact_controller.rb b/app/controllers/api/v1/contact_controller.rb
index deacf4a7..d949dc30 100644
--- a/app/controllers/api/v1/contact_controller.rb
+++ b/app/controllers/api/v1/contact_controller.rb
@@ -18,7 +18,7 @@ module Api
# Si todo salió bien, enviar los correos y redirigir al sitio.
# El sitio nos dice a dónde tenemos que ir.
- ContactJob.perform_async site.id,
+ ContactJob.perform_later site.id,
params[:form],
contact_params.to_h.symbolize_keys,
params[:redirect]
diff --git a/app/controllers/api/v1/notices_controller.rb b/app/controllers/api/v1/notices_controller.rb
index cd44130c..436c78b5 100644
--- a/app/controllers/api/v1/notices_controller.rb
+++ b/app/controllers/api/v1/notices_controller.rb
@@ -15,7 +15,7 @@ module Api
params: airbrake_params.to_h
end
- render status: 201, json: { id: 1, url: root_url }
+ render status: 201, json: { id: 1, url: '' }
end
private
diff --git a/app/controllers/api/v1/sites_controller.rb b/app/controllers/api/v1/sites_controller.rb
index 6abff704..ae64cf74 100644
--- a/app/controllers/api/v1/sites_controller.rb
+++ b/app/controllers/api/v1/sites_controller.rb
@@ -9,44 +9,27 @@ module Api
# Lista de nombres de dominios a emitir certificados
def index
- render json: sites_names + alternative_names + api_names
- end
-
- # Sitios con hidden service de Tor
- #
- # @return [Array] lista de nombres de sitios sin onion aun
- def hidden_services
- render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name)
- end
-
- # Tor va a enviar el onion junto con el nombre del sitio y tenemos
- # que guardarlo en su deploy_hidden_service.
- #
- # @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'
- service = SiteService.new site: site, usuarie: usuarie,
- params: params
- service.add_onion
- end
-
- head :ok
+ render json: sites_names + alternative_names + api_names + www_names
end
private
+ def canonicalize(name)
+ name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
+ end
+
# Nombres de los sitios
def sites_names
- Site.all.order(:name).pluck(:name)
+ Site.all.order(:name).pluck(:name).map do |name|
+ canonicalize name
+ end
end
# Dominios alternativos
def alternative_names
- DeployAlternativeDomain.all.map(&:hostname)
+ (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
+ canonicalize name
+ end
end
# Obtener todos los sitios con API habilitada, es decir formulario
@@ -56,7 +39,16 @@ module Api
def api_names
Site.where(contact: true)
.or(Site.where(colaboracion_anonima: true))
- .select("'api.' || name as name").map(&:name)
+ .select("'api.' || name as name").map(&:name).map do |name|
+ canonicalize name
+ end
+ end
+
+ # Todos los dominios con WWW habilitado
+ def www_names
+ Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name|
+ canonicalize name
+ end
end
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d8498218..ee153394 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
before_action :prepare_exception_notifier
before_action :configure_permitted_parameters, if: :devise_controller?
+ before_action :notify_unconfirmed_email, unless: :devise_controller?
around_action :set_locale
rescue_from Pundit::NilPolicyError, with: :page_not_found
@@ -27,6 +28,15 @@ class ApplicationController < ActionController::Base
private
+ def notify_unconfirmed_email
+ return unless current_usuarie
+ return if current_usuarie.confirmed?
+
+ I18n.with_locale(current_usuarie.lang) do
+ flash[:notice] ||= I18n.t('devise.registrations.signed_up')
+ end
+ end
+
def uuid?(string)
/[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/ =~ string
end
@@ -46,17 +56,19 @@ class ApplicationController < ActionController::Base
# defecto.
#
# Esto se refiere al idioma de la interfaz, no de los artículos.
- def current_locale(include_params: true, site: nil)
- return params[:locale] if include_params && params[:locale].present?
+ #
+ # @return [String,Symbol]
+ def current_locale
+ session[:locale] = params[:change_locale_to] if params[:change_locale_to].present?
- current_usuarie&.lang || I18n.locale
+ session[:locale] || current_usuarie&.lang || I18n.locale
end
# El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer
# traducirlos.
def set_locale(&action)
- I18n.with_locale(current_locale(include_params: false), &action)
+ I18n.with_locale(current_locale, &action)
end
# Muestra una página 404
@@ -79,13 +91,26 @@ class ApplicationController < ActionController::Base
breadcrumb 'stats.index', root_path, match: :exact
end
+ def site
+ @site ||= find_site
+ end
+
protected
def configure_permitted_parameters
+ devise_parameter_sanitizer.permit(:sign_up, keys: Usuarie::CONSENT_FIELDS)
devise_parameter_sanitizer.permit(:account_update, keys: %i[lang])
end
def prepare_exception_notifier
request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie }
end
+
+ # Olvidar el idioma elegido antes de iniciar la sesión y reenviar a
+ # los sitios en el idioma de le usuarie.
+ def after_sign_in_path_for(resource)
+ session[:locale] = nil
+
+ sites_path
+ end
end
diff --git a/app/controllers/build_stats_controller.rb b/app/controllers/build_stats_controller.rb
new file mode 100644
index 00000000..31a4c5d6
--- /dev/null
+++ b/app/controllers/build_stats_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# La lista de estados de compilación, por ahora solo mostramos el último
+# estado.
+class BuildStatsController < ApplicationController
+ include ActionView::Helpers::NumberHelper
+ include ActionView::Helpers::DateHelper
+
+ before_action :authenticate_usuarie!
+
+ breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
+ breadcrumb 'sites.index', :sites_path, match: :exact
+ breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
+
+ def index
+ authorize SiteBuildStat.new(site)
+ breadcrumb I18n.t('build_stats.index.title'), ''
+
+ @headers = %w[type url seconds size].map do |header|
+ t("deploy_mailer.deployed.th.#{header}")
+ end
+
+ @table = site.deployment_list.map do |deploy|
+ type = deploy.class.name.underscore
+ urls = deploy.respond_to?(:urls) ? deploy.urls : [deploy.url].compact
+ urls = [nil] if urls.empty?
+ build_stat = deploy.build_stats.where(status: true).last
+ seconds = build_stat&.seconds || 0
+
+ {
+ title: t("deploy_mailer.deployed.#{type}.title"),
+ urls: urls,
+ seconds: {
+ human: distance_of_time_in_words(seconds),
+ machine: "PT#{seconds}S"
+ },
+ size: number_to_human_size(build_stat&.bytes || 0, precision: 2)
+ }
+ end
+ end
+end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index c5dc0f54..9720fe13 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -12,7 +12,7 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options
- { locale: current_locale }
+ { locale: locale }
end
def index
@@ -159,10 +159,6 @@ class PostsController < ApplicationController
end.transform_keys(&:to_sym)
end
- def site
- @site ||= find_site
- end
-
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end
diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb
index b4826226..63865e44 100644
--- a/app/controllers/sites_controller.rb
+++ b/app/controllers/sites_controller.rb
@@ -10,7 +10,7 @@ class SitesController < ApplicationController
# Ver un listado de sitios
def index
authorize Site
- @sites = current_usuarie.sites.order(:title)
+ @sites = current_usuarie.sites.order(updated_at: :desc)
fresh_when @sites
end
@@ -28,8 +28,6 @@ class SitesController < ApplicationController
@site = Site.new
authorize @site
-
- @site.deploys.build type: 'DeployLocal'
end
def create
@@ -68,9 +66,7 @@ class SitesController < ApplicationController
def enqueue
authorize site
- # XXX: Convertir en una máquina de estados?
- site.enqueue!
- DeployJob.perform_async site.id
+ SiteService.new(site: site).deploy
redirect_to site_posts_path(site, locale: site.default_locale)
end
diff --git a/app/controllers/usuaries_controller.rb b/app/controllers/usuaries_controller.rb
index 6d02a35a..6924c860 100644
--- a/app/controllers/usuaries_controller.rb
+++ b/app/controllers/usuaries_controller.rb
@@ -47,7 +47,7 @@ class UsuariesController < ApplicationController
@usuarie = Usuarie.find(params[:usuarie_id])
if @site.usuaries.count > 1
- @usuarie.rol_for_site(@site).update_attribute :rol, 'invitade'
+ @usuarie.rol_for_site(@site).update_attribute :rol, Rol::INVITADE
else
flash[:warning] = I18n.t('usuaries.index.demote.denied')
end
@@ -61,7 +61,7 @@ class UsuariesController < ApplicationController
authorize SiteUsuarie.new(@site, current_usuarie)
@usuarie = Usuarie.find(params[:usuarie_id])
- @usuarie.rol_for_site(@site).update_attribute :rol, 'usuarie'
+ @usuarie.rol_for_site(@site).update_attribute :rol, Rol::USUARIE
redirect_to site_usuaries_path
end
@@ -72,6 +72,8 @@ class UsuariesController < ApplicationController
site_usuarie = SiteUsuarie.new(@site, current_usuarie)
authorize site_usuarie
+ params[:invite_as] = invite_as
+
@policy = policy(site_usuarie)
end
@@ -81,27 +83,33 @@ class UsuariesController < ApplicationController
authorize SiteUsuarie.new(@site, current_usuarie)
# Enviar la invitación si es necesario y agregar al sitio
- invitaciones.each do |invitacion|
- # Si la cuenta no existe, envía una invitación por correo, sino,
- # no se envía nada
- #
- # TODO: Enviar invitación igual! Podemos no usar el Mailer de
- # DeviseInvitations y usar uno propio que contenga texto y se
- # envíe de todas formas.
- usuarie = Usuarie.invite! email: invitacion.address,
- skip_invitation: true
+ invitaciones.each do |address|
+ next if Usuarie.where(id: @site.roles.pluck(:usuarie_id)).find_by_email(address)
- # No invitar al sitio si ya estaba en la lista!
- #
- # XXX: En este caso no estamos enviando ninguna invitación
- next if usuarie.sites.exists? @site.id
+ Usuarie.transaction do
+ usuarie = Usuarie.find_by_email(address)
+ usuarie ||= Usuarie.invite!({ email: address, skip_invitation: true }).tap do |u|
+ u.send :generate_invitation_token!
+ end
- @site.roles << Rol.create(usuarie: usuarie, site: @site,
- temporal: true, rol: invited_as)
+ role = @site.roles.create(usuarie: usuarie, temporal: true, rol: invited_as)
- # Invitamos después de crear el rol para que el correo de
- # invitación pueda recibir el sitio.
- usuarie.deliver_invitation
+ # XXX: La invitación tiene que ser enviada luego de crear el rol
+ if role.persisted?
+ # Si es una cuenta manual que no está confirmada aun,
+ # aprovechar para reconfirmarla.
+ if !usuarie.confirmed? && !usuarie.created_by_invite?
+ usuarie.confirmation_token = nil
+ usuarie.send :generate_confirmation_token!
+ end
+
+ usuarie.deliver_invitation
+ else
+ raise ArgumentError, role.errors.full_messages
+ end
+ rescue ArgumentError => e
+ ExceptionNotifier.notify_exception(e, data: { site: @site.name, address: address })
+ end
end
redirect_to site_usuaries_path(@site)
@@ -142,6 +150,8 @@ class UsuariesController < ApplicationController
private
# Traer todas las invitaciones que al menos tengan usuarie y dominio
+ #
+ # @return [Array]
def invitaciones
# XXX: Podríamos usar EmailAddress pero hace chequeos más lentos
params[:invitaciones]&.tr("\r", '')&.split("\n")&.map do |m|
@@ -150,17 +160,19 @@ class UsuariesController < ApplicationController
nil
end.compact.select do |m|
m.local && m.domain
- end
+ end.map(&:address)
end
# El tipo de invitación que tenemos que enviar, si alguien mandó
# cualquier cosa, usamos el privilegio menor.
+ #
+ # @return [String]
def invited_as
- if Rol::ROLES.include?(params[:invited_as])
- params[:invited_as]
- else
- 'invitade'
- end
+ Rol.role?(params[:invited_as]) ? params[:invited_as] : Rol::INVITADE
+ end
+
+ def invite_as
+ Rol.role?(params[:invite_as]&.singularize) ? params[:invite_as] : Rol::INVITADE.pluralize
end
def site
diff --git a/app/javascript/controllers/non_geo_controller.js b/app/javascript/controllers/non_geo_controller.js
new file mode 100644
index 00000000..1c618fcb
--- /dev/null
+++ b/app/javascript/controllers/non_geo_controller.js
@@ -0,0 +1,81 @@
+import { Controller } from 'stimulus'
+
+require("leaflet/dist/leaflet.css")
+import L from 'leaflet'
+delete L.Icon.Default.prototype._getIconUrl
+
+L.Icon.Default.mergeOptions({
+ iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
+ iconUrl: require('leaflet/dist/images/marker-icon.png'),
+ shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
+})
+
+export default class extends Controller {
+ static targets = [ 'lat', 'lng', 'map', 'overlay' ]
+
+ async connect () {
+ this.marker()
+
+ this.latTarget.addEventListener('change', event => this.marker())
+ this.lngTarget.addEventListener('change', event => this.marker())
+ window.addEventListener('resize', event => this.map.invalidateSize())
+
+ this.map.on('click', event => {
+ this.latTarget.value = event.latlng.lat
+ this.lngTarget.value = event.latlng.lng
+
+ this.latTarget.dispatchEvent(new Event('change'))
+ })
+ }
+
+ marker () {
+ if (this._marker) this.map.removeLayer(this._marker)
+
+ this._marker = L.marker(this.coords).addTo(this.map)
+
+ return this._marker
+ }
+
+ get lat () {
+ const lat = parseFloat(this.latTarget.value)
+
+ return isNaN(lat) ? 0 : lat
+ }
+
+ get lng () {
+ const lng = parseFloat(this.lngTarget.value)
+
+ return isNaN(lng) ? 0 : lng
+ }
+
+ get coords () {
+ return [this.lat, this.lng]
+ }
+
+ get bounds () {
+ return [
+ [0, 0],
+ [
+ this.svgOverlay.viewBox.baseVal.height,
+ this.svgOverlay.viewBox.baseVal.width,
+ ]
+ ];
+ }
+
+ get map () {
+ if (!this._map) {
+ this._map = L.map(this.mapTarget, {
+ minZoom: 0,
+ maxZoom: 5
+ }).setView(this.coords, 0);
+
+ this._layer = L.tileLayer(`${this.element.dataset.site}public/map/{z}/{y}/{x}.png`, {
+ minNativeZoom: 0,
+ maxNativeZoom: 5,
+ noWrap: true
+ }).addTo(this._map);
+ }
+
+ return this._map
+ }
+}
diff --git a/app/javascript/controllers/reorder_controller.js b/app/javascript/controllers/reorder_controller.js
index dca6e166..2cba4163 100644
--- a/app/javascript/controllers/reorder_controller.js
+++ b/app/javascript/controllers/reorder_controller.js
@@ -103,11 +103,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
- if ("scrollIntoViewIfNeeded" in rows[0].row) {
- rows[0].row.scrollIntoViewIfNeeded()
- } else {
- rows[0].row.scrollIntoView()
- }
+ rows[0].row.scrollIntoView({ block: "center" });
}
counter () {
@@ -146,7 +142,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
- rows[0].row.scrollIntoViewIfNeeded()
+ rows[0].row.scrollIntoView({ block: "center" });
}
bottom (event) {
@@ -167,7 +163,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
- rows[0].row.scrollIntoViewIfNeeded()
+ rows[0].row.scrollIntoView({ block: "center" });
}
/*
diff --git a/app/jobs/deploy_job.rb b/app/jobs/deploy_job.rb
index 70997ce1..a5cda360 100644
--- a/app/jobs/deploy_job.rb
+++ b/app/jobs/deploy_job.rb
@@ -3,9 +3,26 @@
# Realiza el deploy de un sitio
class DeployJob < ApplicationJob
class DeployException < StandardError; end
+ class DeployTimedOutException < DeployException; end
+ class DeployAlreadyRunningException < DeployException; end
+
+ discard_on ActiveRecord::RecordNotFound
+
+ # Lanzar lo antes posible
+ self.priority = 10
+
+ def handle_error(error)
+ case error
+ when DeployAlreadyRunningException then retry_in 1.minute
+ when DeployTimedOutException then expire
+ else super
+ end
+ end
# rubocop:disable Metrics/MethodLength
- def perform(site, notify = true, time = Time.now)
+ def perform(site, notify: true, time: Time.now, output: false)
+ @output = output
+
ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site)
@@ -15,53 +32,96 @@ class DeployJob < ApplicationJob
# Como el trabajo actual se aplaza al siguiente, arrastrar la
# hora original para poder ir haciendo timeouts.
if @site.building?
+ notify = false
+
if 10.minutes.ago >= time
- @site.update status: 'waiting'
- raise DeployException,
+ raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
+ else
+ raise DeployAlreadyRunningException
+ end
+ end
+
+ @deployed = {}
+ @site.update status: 'building'
+ @site.deployment_list.each do |d|
+ begin
+ raise DeployException, 'Una dependencia falló' if failed_dependencies? d
+
+ status = d.deploy(output: @output)
+ seconds = d.build_stats.last.try(:seconds) || 0
+ size = d.size
+ urls = d.respond_to?(:urls) ? d.urls : [d.url].compact
+ rescue StandardError => e
+ status = false
+ seconds ||= 0
+ size ||= 0
+ # XXX: Hace que se vea la tabla
+ urls ||= [nil]
+
+ notify_exception e, d
end
- DeployJob.perform_in(60, site, notify, time)
- return
+ @deployed[d.type.underscore.to_sym] = {
+ status: status,
+ seconds: seconds,
+ size: size,
+ urls: urls
+ }
end
- @site.update status: 'building'
- # Asegurarse que DeployLocal sea el primero!
- @deployed = { deploy_local: deploy_locally }
+ return unless @output
- # No es opcional
- unless @deployed[:deploy_local]
+ puts (Terminal::Table.new do |t|
+ t << (%w[type] + @deployed.values.first.keys)
+ t.add_separator
+ @deployed.each do |type, row|
+ t << ([type.to_s] + row.values)
+ end
+ end)
+ ensure
+ if @site.present?
@site.update status: 'waiting'
+
notify_usuaries if notify
- # Hacer fallar la tarea
- raise DeployException, deploy_local.build_stats.last.log
+ puts "\a" if @output
end
-
- deploy_others
-
- # Volver a la espera
- @site.update status: 'waiting'
-
- notify_usuaries if notify
end
end
# rubocop:enable Metrics/MethodLength
private
- def deploy_local
- @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
+ # Detecta si un método de publicación tiene dependencias fallidas
+ #
+ # @param :deploy [Deploy]
+ # @return [Boolean]
+ def failed_dependencies?(deploy)
+ failed_dependencies(deploy).present?
end
- def deploy_locally
- deploy_local.deploy
+ # Obtiene las dependencias fallidas de un deploy
+ #
+ # @param :deploy [Deploy]
+ # @return [Array]
+ def failed_dependencies(deploy)
+ deploy.class::DEPENDENCIES & (@deployed.reject do |_, v|
+ v[:status]
+ end.keys)
end
- def deploy_others
- @site.deploys.where.not(type: 'DeployLocal').find_each do |d|
- @deployed[d.type.underscore.to_sym] = d.deploy
- end
+ # @param :exception [StandardError]
+ # @param :deploy [Deploy]
+ def notify_exception(exception, deploy = nil)
+ data = {
+ site: @site.id,
+ deploy: deploy&.type,
+ log: deploy&.build_stats&.last&.log,
+ failed_dependencies: (failed_dependencies(deploy) if deploy)
+ }
+
+ ExceptionNotifier.notify_exception(exception, data: data)
end
def notify_usuaries
diff --git a/app/jobs/gitlab_notifier_job.rb b/app/jobs/gitlab_notifier_job.rb
index 7218f68a..575d57d8 100644
--- a/app/jobs/gitlab_notifier_job.rb
+++ b/app/jobs/gitlab_notifier_job.rb
@@ -3,6 +3,8 @@
# Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob
+ class GitlabNotifierError < StandardError; end
+
include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego
@@ -14,26 +16,32 @@ class GitlabNotifierJob < ApplicationJob
# @param [Hash] opciones de ExceptionNotifier
def perform(exception, **options)
@exception = exception
- @options = options
+ @options = fix_options options
@issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía
@cached = false
+ @issue = {}
# Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do
- issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
+ @issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@cached = true
{
count: 1,
- issue: issue['iid'],
+ issue: @issue['iid'],
user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact,
urls: [url].compact
}
end
+ if @issue['iid'].blank? && issue_data[:issue].blank?
+ Rails.cache.delete(cache_key)
+ raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ')
+ end
+
# No seguimos actualizando si acabamos de generar el issue
return if cached
@@ -53,9 +61,9 @@ class GitlabNotifierJob < ApplicationJob
Rails.cache.write(cache_key, issue_data)
# Si este trabajo genera una excepción va a entrar en un loop, así que
# la notificamos por correo
- rescue Exception => e
- email_notification.call(e)
- email_notification.call(exception, options)
+ rescue StandardError => e
+ email_notification.call(e, data: @issue)
+ email_notification.call(exception, data: @options)
end
private
@@ -76,10 +84,15 @@ class GitlabNotifierJob < ApplicationJob
exception.class.name,
Digest::SHA1.hexdigest(exception.message),
Digest::SHA1.hexdigest(backtrace&.first.to_s),
- Digest::SHA1.hexdigest(options.dig(:data, :params, 'errors').to_s)
+ Digest::SHA1.hexdigest(errors.to_s)
].join('/')
end
+ # @return [Array]
+ def errors
+ options.dig(:data, :params, 'errors') || []
+ end
+
# Define si es una excepción de javascript o local
#
# @see BacktraceJob
@@ -104,6 +117,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def description
@description ||= ''.dup.tap do |d|
+ d << log_section
d << request_section
d << javascript_section
d << javascript_footer
@@ -117,6 +131,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def body
@body ||= ''.dup.tap do |b|
+ b << log_section
b << request_section
b << javascript_footer
b << data_section
@@ -151,6 +166,21 @@ class GitlabNotifierJob < ApplicationJob
@client ||= GitlabApiClient.new
end
+ # @return [String]
+ def log_section
+ return '' unless options.dig(:data, :log)
+
+ <<~LOG
+
+ # Build log
+
+ ```
+ #{options[:data].delete(:log)}
+ ```
+
+ LOG
+ end
+
# Muestra información de la petición
#
# @return [String]
@@ -235,8 +265,8 @@ class GitlabNotifierJob < ApplicationJob
## Data
- ```
- #{pp options[:data]}
+ ```yaml
+ #{options[:data].to_yaml}
```
DATA
@@ -257,4 +287,16 @@ class GitlabNotifierJob < ApplicationJob
def url
@url ||= request&.url || options.dig(:data, :params, 'context', 'url')
end
+
+ # Define llaves necesarias
+ #
+ # @param :options [Hash]
+ # @return [Hash]
+ def fix_options(options)
+ options = { data: options } unless options.is_a? Hash
+ options[:data] ||= {}
+ options[:data][:params] ||= {}
+
+ options
+ end
end
diff --git a/app/jobs/maintenance_job.rb b/app/jobs/maintenance_job.rb
index 4c411d0e..c7a962f9 100644
--- a/app/jobs/maintenance_job.rb
+++ b/app/jobs/maintenance_job.rb
@@ -10,7 +10,7 @@
# bundle exec rails c
# m = Maintenance.create message_en: 'reason', message_es: 'razón',
# estimated_from: Time.now, estimated_to: Time.now + 1.hour
-# MaintenanceJob.perform_async(maintenance_id: m.id)
+# MaintenanceJob.perform_later(maintenance_id: m.id)
#
# Lo mismo para salir de mantenimiento, agregando el atributo
# are_we_back: true al crear el Maintenance.
diff --git a/app/jobs/renew_distributed_press_tokens_job.rb b/app/jobs/renew_distributed_press_tokens_job.rb
new file mode 100644
index 00000000..86086ac7
--- /dev/null
+++ b/app/jobs/renew_distributed_press_tokens_job.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Renueva los tokens de Distributed Press antes que se venzan,
+# activando los callbacks que hacen que se refresque el token.
+class RenewDistributedPressTokensJob < ApplicationJob
+ # Renueva todos los tokens a punto de vencer o informa el error sin
+ # detener la tarea si algo pasa.
+ def perform
+ DistributedPressPublisher.with_about_to_expire_tokens.find_each do |publisher|
+ publisher.save
+ rescue DistributedPress::V1::Error => e
+ data = { instance: publisher.instance, expires_at: publisher.client.token.expires_at }
+
+ ExceptionNotifier.notify_exception(e, data: data)
+ end
+ end
+end
diff --git a/app/lib/active_storage/service/jekyll_service.rb b/app/lib/active_storage/service/jekyll_service.rb
index 92b26e0e..e6c5fda6 100644
--- a/app/lib/active_storage/service/jekyll_service.rb
+++ b/app/lib/active_storage/service/jekyll_service.rb
@@ -4,11 +4,6 @@ module ActiveStorage
class Service
# Sube los archivos a cada repositorio y los agrega al LFS de su
# repositorio git.
- #
- # @todo: Implementar LFS. No nos gusta mucho la idea porque duplica
- # el espacio en disco, pero es la única forma que tenemos (hasta que
- # implementemos IPFS) para poder transferir los archivos junto con el
- # sitio.
class JekyllService < Service::DiskService
# Genera un servicio para un sitio determinado
#
@@ -20,6 +15,21 @@ module ActiveStorage
end
end
+ # Solo copiamos el archivo si no existe
+ #
+ # @param :key [String]
+ # @param :io [IO]
+ # @param :checksum [String]
+ def upload(key, io, checksum: nil, **)
+ instrument :upload, key: key, checksum: checksum do
+ unless exist?(key)
+ IO.copy_stream(io, make_path_for(key))
+ LfsObjectService.new(site: site, blob: blob_for(key)).process
+ end
+ ensure_integrity_of(key, checksum) if checksum
+ end
+ end
+
# Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde
# corresponde.
@@ -67,7 +77,9 @@ module ActiveStorage
# @param :key [String]
# @return [String]
def filename_for(key)
- ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first
+ blob_for(key).filename.to_s.tap do |filename|
+ raise ArgumentError, "Filename for key #{key} is blank" if filename.blank?
+ end
end
# Crea una ruta para la llave con un nombre conocido.
@@ -77,6 +89,15 @@ module ActiveStorage
def path_for(key)
File.join root, folder_for(key), filename_for(key)
end
+
+ # @return [Site]
+ def site
+ @site ||= Site.find_by_name(name)
+ end
+
+ def blob_for(key)
+ ActiveStorage::Blob.find_by(key: key, service_name: name)
+ end
end
end
end
diff --git a/app/lib/devise/failure_app_decorator.rb b/app/lib/devise/failure_app_decorator.rb
new file mode 100644
index 00000000..f17cb482
--- /dev/null
+++ b/app/lib/devise/failure_app_decorator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Devise
+ module FailureAppDecorator
+ extend ActiveSupport::Concern
+
+ included do
+ include AbstractController::Callbacks
+
+ around_action :set_locale
+
+ private
+
+ def set_locale(&action)
+ I18n.with_locale(session[:locale] || I18n.locale, &action)
+ end
+ end
+ end
+end
+
+Devise::FailureApp.include Devise::FailureAppDecorator
diff --git a/app/lib/exception_notifier/gitlab_notifier.rb b/app/lib/exception_notifier/gitlab_notifier.rb
index 18bfc6d4..8152bb62 100644
--- a/app/lib/exception_notifier/gitlab_notifier.rb
+++ b/app/lib/exception_notifier/gitlab_notifier.rb
@@ -11,7 +11,12 @@ module ExceptionNotifier
# @param [Exception]
# @param [Hash]
def call(exception, **options)
- GitlabNotifierJob.perform_async(exception, **options)
+ case exception
+ when BacktraceJob::BacktraceException
+ GitlabNotifierJob.perform_later(exception, **options)
+ else
+ GitlabNotifierJob.perform_now(exception, **options)
+ end
end
end
end
diff --git a/app/lib/hidden_service_client.rb b/app/lib/hidden_service_client.rb
new file mode 100644
index 00000000..5715a869
--- /dev/null
+++ b/app/lib/hidden_service_client.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'httparty'
+
+class HiddenServiceClient
+ include HTTParty
+
+ base_uri ENV.fetch('HIDDEN_SERVICE', 'http://tor:3000')
+
+ def create(name)
+ self.class.get("/#{name}").body
+ end
+end
diff --git a/app/mailers/deploy_mailer.rb b/app/mailers/deploy_mailer.rb
index 1d0c7308..b7b464cb 100644
--- a/app/mailers/deploy_mailer.rb
+++ b/app/mailers/deploy_mailer.rb
@@ -8,21 +8,66 @@
# TODO: Agregar firma GPG y header Autocrypt
# TODO: Cifrar con GPG si le usuarie nos dio su llave
class DeployMailer < ApplicationMailer
+ include ActionView::Helpers::NumberHelper
+ include ActionView::Helpers::DateHelper
+
# rubocop:disable Metrics/AbcSize
- def deployed(which_ones)
- @usuarie = Usuarie.find(params[:usuarie])
- @site = @usuarie.sites.find(params[:site])
- @deploys = which_ones
- @deploy_local = @site.deploys.find_by(type: 'DeployLocal')
+ def deployed(deploys = {})
+ usuarie = Usuarie.find(params[:usuarie])
+ site = usuarie.sites.find(params[:site])
+ hostname = site.hostname
+ deploys ||= {}
# Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben
- I18n.with_locale(@usuarie.lang) do
- mail(to: @usuarie.email,
- reply_to: "sutty@#{Site.domain}",
- subject: I18n.t('deploy_mailer.deployed.subject',
- site: @site.name))
+ I18n.with_locale(usuarie.lang) do
+ subject = t('.subject', site: site.name)
+
+ @hi = t('.hi')
+ @explanation = t('.explanation', fqdn: hostname)
+ @help = t('.help')
+
+ @headers = %w[type status url seconds size].map do |header|
+ t(".th.#{header}")
+ end
+
+ @table = deploys.each_pair.map do |deploy, value|
+ {
+ title: t(".#{deploy}.title"),
+ status: t(".#{deploy}.#{value[:status] ? 'success' : 'error'}"),
+ urls: value[:urls],
+ seconds: {
+ human: distance_of_time_in_words(value[:seconds].seconds),
+ machine: "PT#{value[:seconds]}S"
+ },
+ size: number_to_human_size(value[:size], precision: 2)
+ }
+ end
+
+ @terminal_table = Terminal::Table.new do |t|
+ t << @headers
+ t.add_separator
+ @table.each do |row|
+ row[:urls].each do |url|
+ t << (row.map do |k, v|
+ case k
+ when :seconds then v[:human]
+ when :urls then url
+ else v
+ end
+ end)
+ end
+ end
+ end
+
+ mail(to: usuarie.email, reply_to: "sutty@#{Site.domain}", subject: subject)
end
end
# rubocop:enable Metrics/AbcSize
+
+ private
+
+ def t(key, **args)
+ I18n.t("deploy_mailer.deployed#{key}", **args)
+ end
end
diff --git a/app/models/code_of_conduct.rb b/app/models/code_of_conduct.rb
new file mode 100644
index 00000000..87c24c7f
--- /dev/null
+++ b/app/models/code_of_conduct.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Códigos de conducta
+class CodeOfConduct < ApplicationRecord
+ extend Mobility
+
+ translates :title, type: :string, locale_accessors: true
+ translates :description, type: :text, locale_accessors: true
+ translates :content, type: :text, locale_accessors: true
+
+ validates :title, presence: true, uniqueness: true
+ validates :description, presence: true
+ validates :content, presence: true
+end
diff --git a/app/models/concerns/usuarie/consent.rb b/app/models/concerns/usuarie/consent.rb
new file mode 100644
index 00000000..14e67fbc
--- /dev/null
+++ b/app/models/concerns/usuarie/consent.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Usuarie
+ # Gestiona los campos de consentimiento
+ module Consent
+ extend ActiveSupport::Concern
+
+ included do
+ CONSENT_FIELDS = %i[privacy_policy_accepted terms_of_service_accepted code_of_conduct_accepted available_for_feedback_accepted]
+
+ CONSENT_FIELDS.each do |field|
+ attribute field, :boolean
+ end
+
+ before_save :update_consent_fields!
+
+ private
+
+ def update_consent_fields!
+ CONSENT_FIELDS.each do |field|
+ send(:"#{field}_at=", Time.now) if send(field).present?
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/deploy.rb b/app/models/deploy.rb
index 3f034ad5..a92708c0 100644
--- a/app/models/deploy.rb
+++ b/app/models/deploy.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'open3'
+
# Este modelo implementa los distintos tipos de alojamiento que provee
# Sutty.
#
@@ -11,7 +12,14 @@ class Deploy < ApplicationRecord
belongs_to :site
has_many :build_stats, dependent: :destroy
- def deploy
+ DEPENDENCIES = []
+ SOFT_DEPENDENCIES = []
+
+ def deploy(**)
+ raise NotImplementedError
+ end
+
+ def url
raise NotImplementedError
end
@@ -23,6 +31,9 @@ class Deploy < ApplicationRecord
raise NotImplementedError
end
+ # Realizar tareas de limpieza.
+ def cleanup!; end
+
def time_start
@start = Time.now
end
@@ -39,6 +50,7 @@ class Deploy < ApplicationRecord
site.path
end
+ # XXX: Ver DeployLocal#bundle
def gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end
@@ -48,20 +60,26 @@ class Deploy < ApplicationRecord
#
# @param [String]
# @return [Boolean]
- def run(cmd)
+ def run(cmd, output: false)
r = nil
lines = []
time_start
Dir.chdir(site.path) do
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t|
- r = t.value
- # XXX: Tenemos que leer línea por línea porque en salidas largas
- # se cuelga la IO
# TODO: Enviar a un websocket para ver el proceso en vivo?
- o.each do |line|
- lines << line
+ Thread.new do
+ o.each do |line|
+ lines << line
+
+ puts line if output
+ end
+ rescue IOError => e
+ lines << e.message
+ puts e.message if output
end
+
+ r = t.value
end
end
time_stop
@@ -75,6 +93,20 @@ class Deploy < ApplicationRecord
r&.success?
end
+ # Variables de entorno
+ #
+ # @return [Hash]
+ def local_env
+ @local_env ||= {}
+ end
+
+ # Trae todas las dependencias
+ #
+ # @return [Array]
+ def self.all_dependencies
+ self::DEPENDENCIES | self::SOFT_DEPENDENCIES
+ end
+
private
# @param [String]
@@ -82,4 +114,12 @@ class Deploy < ApplicationRecord
def readable_cmd(cmd)
cmd.split(' -', 2).first.tr(' ', '_')
end
+
+ def deploy_local
+ @deploy_local ||= site.deploys.find_by(type: 'DeployLocal')
+ end
+
+ def non_local_deploys
+ @non_local_deploys ||= site.deploys.where.not(type: 'DeployLocal')
+ end
end
diff --git a/app/models/deploy_alternative_domain.rb b/app/models/deploy_alternative_domain.rb
index e4960e65..75b69180 100644
--- a/app/models/deploy_alternative_domain.rb
+++ b/app/models/deploy_alternative_domain.rb
@@ -4,8 +4,10 @@
class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON
+ DEPENDENCIES = %i[deploy_local]
+
# Generar un link simbólico del sitio principal al alternativo
- def deploy
+ def deploy(**)
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
@@ -18,6 +20,14 @@ class DeployAlternativeDomain < Deploy
end
def destination
- File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
+ @destination ||= File.join(Rails.root, '_deploy', fqdn)
+ end
+
+ def fqdn
+ hostname.gsub(/\.\z/, '')
+ end
+
+ def url
+ "https://#{File.basename destination}"
end
end
diff --git a/app/models/deploy_distributed_press.rb b/app/models/deploy_distributed_press.rb
new file mode 100644
index 00000000..889d8e34
--- /dev/null
+++ b/app/models/deploy_distributed_press.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require 'distributed_press/v1/client/site'
+require 'njalla/v1'
+
+# Soportar Distributed Press APIv1
+#
+# Usa tokens de publicación efímeros para todas las acciones.
+#
+# Al ser creado, genera el sitio en la instancia de Distributed Press
+# configurada y almacena el ID.
+#
+# Al ser publicado, envía los archivos en un tarball y actualiza la
+# información.
+class DeployDistributedPress < Deploy
+ store :values, accessors: %i[hostname remote_site_id remote_info], coder: JSON
+
+ before_create :create_remote_site!, :create_njalla_records!
+ before_destroy :delete_remote_site!, :delete_njalla_records!
+
+ DEPENDENCIES = %i[deploy_local]
+
+ # Actualiza la información y luego envía los cambios
+ #
+ # @param :output [Bool]
+ # @return [Bool]
+ def deploy
+ status = false
+ log = []
+
+ time_start
+
+ create_remote_site! if remote_site_id.blank?
+ create_njalla_records!
+ save
+
+ if remote_site_id.blank?
+ raise DeployJob::DeployException, 'El sitio no se creó en Distributed Press'
+ end
+
+ if create_njalla_records? && remote_info[:njalla].blank?
+ raise DeployJob::DeployException, 'No se pudieron crear los registros necesarios en Njalla'
+ end
+
+ site_client.tap do |c|
+ stdout = Thread.new(publisher.logger_out) do |io|
+ until io.eof?
+ line = io.gets
+
+ puts line if output
+ log << line
+ end
+ end
+
+ status = c.publish(publishing_site, deploy_local.destination)
+
+ if status
+ self.remote_info[:distributed_press] = c.show(publishing_site).to_h
+ save
+ end
+
+ publisher.logger.close
+ stdout.join
+ end
+
+ time_stop
+
+ create_stat! status, log.join
+
+ status
+ end
+
+ def limit; end
+
+ def size
+ deploy_local.size
+ end
+
+ def destination; end
+
+ # Devuelve las URLs de todos los protocolos
+ def urls
+ gateway_urls
+ end
+
+ private
+
+ # @return [Array]
+ def gateway_urls
+ remote_info.dig(:distributed_press, :links)&.values&.map do |protocol|
+ [ protocol[:link], protocol[:gateway] ]
+ end&.flatten&.compact&.select do |link|
+ link.include? '://'
+ end || []
+ end
+
+ # El cliente de la API
+ #
+ # TODO: cuando soportemos más, tiene que haber una relación entre
+ # DeployDistributedPress y DistributedPressPublisher.
+ #
+ # @return [DistributedPressPublisher]
+ def publisher
+ @publisher ||= DistributedPressPublisher.last
+ end
+
+ # El cliente para actualizar el sitio
+ #
+ # @return [DistributedPress::V1::Client::Site]
+ def site_client
+ DistributedPress::V1::Client::Site.new(publisher.client)
+ end
+
+ # Genera el esquema de datos para poder publicar el sitio
+ #
+ # @return [DistributedPress::V1::Schemas::PublishingSite]
+ def publishing_site
+ DistributedPress::V1::Schemas::PublishingSite.new.call(id: remote_site_id)
+ end
+
+ # Genera el esquema de datos para crear el sitio
+ #
+ # @return [DistributedPressPublisher::V1::Schemas::NewSite]
+ def create_site
+ DistributedPress::V1::Schemas::NewSite.new.call(domain: hostname, protocols: { http: true, ipfs: true, hyper: true })
+ end
+
+ # Crea el sitio en la instancia con el hostname especificado
+ #
+ # @return [nil]
+ def create_remote_site!
+ created_site = site_client.create(create_site)
+
+ self.remote_site_id = created_site[:id]
+ self.remote_info ||= {}
+ self.remote_info[:distributed_press] = created_site.to_h
+ nil
+ rescue DistributedPress::V1::Error => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name })
+ nil
+ end
+
+ # Crea los registros en Njalla
+ #
+ # XXX: Esto depende de nuestro DNS actual, cuando lo migremos hay
+ # que eliminarlo.
+ #
+ # @return [nil]
+ def create_njalla_records!
+ return unless create_njalla_records?
+
+ self.remote_info ||= {}
+ self.remote_info[:njalla] ||= {}
+ self.remote_info[:njalla][:a] ||= njalla.add_record(name: site.name, type: 'CNAME', content: "#{Site.domain}.").to_h
+ self.remote_info[:njalla][:cname] ||= njalla.add_record(name: "www.#{site.name}", type: 'CNAME', content: "#{Site.domain}.").to_h
+ self.remote_info[:njalla][:ns] ||= njalla.add_record(name: "_dnslink.#{site.name}", type: 'NS', content: "#{publisher.hostname}.").to_h
+
+ nil
+ rescue HTTParty::Error => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name })
+ self.remote_info.delete :njalla
+ ensure
+ nil
+ end
+
+ # Registra lo que sucedió
+ #
+ # @param status [Bool]
+ # @param log [String]
+ # @return [nil]
+ def create_stat!(status, log)
+ build_stats.create action: publisher.to_s,log: log, seconds: time_spent_in_seconds, bytes: size, status: status
+ nil
+ end
+
+ def delete_remote_site!
+ site_client.delete(publishing_site)
+ nil
+ rescue DistributedPress::V1::Error => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name })
+ nil
+ end
+
+ def delete_njalla_records!
+ return unless create_njalla_records?
+
+ %w[a ns cname].each do |type|
+ next if (id = remote_info.dig('njalla', type, 'id')).blank?
+
+ njalla.remove_record(id: id.to_i)
+ end
+ end
+
+ # Actualizar registros en Njalla
+ #
+ # @return [Njalla::V1::Domain]
+ def njalla
+ @njalla ||=
+ begin
+ client = Njalla::V1::Client.new(token: Rails.application.credentials.njalla)
+
+ Njalla::V1::Domain.new(domain: Site.domain, client: client)
+ end
+ end
+
+ # Detecta si tenemos que crear registros en Njalla
+ def create_njalla_records?
+ !site.name.end_with?('.')
+ end
+end
diff --git a/app/models/deploy_full_rsync.rb b/app/models/deploy_full_rsync.rb
new file mode 100644
index 00000000..b417470a
--- /dev/null
+++ b/app/models/deploy_full_rsync.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class DeployFullRsync < DeployRsync
+ SOFT_DEPENDENCIES = %i[
+ deploy_alternative_domain
+ deploy_localized_domain
+ deploy_hidden_service
+ deploy_www
+ ]
+
+ # Sincroniza las ubicaciones alternativas también, ignorando las que
+ # todavía no se generaron. Solo falla si ningún sitio fue
+ # sincronizado o si alguna sincronización falló.
+ #
+ # @param :output [Boolean]
+ # @return [Boolean]
+ def rsync(output: false)
+ result =
+ self.class.all_dependencies.map(&:to_s).map(&:classify).map do |dependency|
+ site.deploys.where(type: dependency).find_each.map do |deploy|
+ next unless File.exist? deploy.destination
+
+ run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape deploy.destination} #{Shellwords.escape destination}), output: output
+ rescue StandardError
+ end
+ end.flatten.compact
+
+ result.present? && result.all?
+ end
+end
diff --git a/app/models/deploy_hidden_service.rb b/app/models/deploy_hidden_service.rb
index d4d2b822..25c0c217 100644
--- a/app/models/deploy_hidden_service.rb
+++ b/app/models/deploy_hidden_service.rb
@@ -2,17 +2,36 @@
# Genera una versión onion
class DeployHiddenService < DeployWww
- def deploy
- return true if fqdn.blank?
+ store :values, accessors: %i[onion], coder: JSON
- super
- end
+ before_create :create_hidden_service!
+
+ ONION_RE = /\A[a-z0-9]{56}\.onion\z/.freeze
def fqdn
- values[:onion]
+ create_hidden_service! if onion.blank?
+
+ onion.tap do |onion|
+ raise ArgumentError, 'Aun no se generó la dirección .onion' if onion.blank?
+ end
end
def url
- 'http://' + fqdn
+ "http://#{fqdn}"
+ end
+
+ private
+
+ def create_hidden_service!
+ onion_address = HiddenServiceClient.new.create(site.name)
+
+ if ONION_RE =~ onion_address
+ self.onion = onion_address
+
+ usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
+ params = { onion: onion_address, deploy: self }
+
+ SiteService.new(site: site, usuarie: usuarie, params: params).add_onion
+ end
end
end
diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb
index 1b661059..75ea8b1c 100644
--- a/app/models/deploy_local.rb
+++ b/app/models/deploy_local.rb
@@ -12,12 +12,14 @@ class DeployLocal < Deploy
#
# Pasamos variables de entorno mínimas para no filtrar secretos de
# Sutty
- def deploy
+ def deploy(output: false)
return false unless mkdir
- return false unless yarn
- return false unless bundle
+ return false unless git_lfs(output: output)
+ return false unless yarn(output: output)
+ return false unless pnpm(output: output)
+ return false unless bundle(output: output)
- jekyll_build
+ jekyll_build(output: output)
end
# Sólo permitimos un deploy local
@@ -25,6 +27,10 @@ class DeployLocal < Deploy
1
end
+ def url
+ site.url
+ end
+
# Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :)
def size
@@ -45,6 +51,17 @@ class DeployLocal < Deploy
File.join(Rails.root, '_deploy', site.hostname)
end
+ # Libera espacio eliminando archivos temporales
+ #
+ # @return [nil]
+ def cleanup!
+ FileUtils.rm_rf(gems_dir)
+ FileUtils.rm_rf(yarn_cache_dir)
+ FileUtils.rm_rf(File.join(site.path, 'node_modules'))
+ FileUtils.rm_rf(File.join(site.path, '.sass-cache'))
+ FileUtils.rm_rf(File.join(site.path, '.jekyll-cache'))
+ end
+
private
def mkdir
@@ -52,27 +69,35 @@ class DeployLocal < Deploy
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']
+ paths = [File.dirname(`which bundle`), '/usr/local/bin', '/usr/bin', '/bin']
- {
- 'HOME' => home_dir,
- 'PATH' => paths.join(':'),
- 'SPREE_API_KEY' => site.tienda_api_key,
- 'SPREE_URL' => site.tienda_url,
- 'AIRBRAKE_PROJECT_ID' => site.id.to_s,
- 'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
- 'JEKYLL_ENV' => Rails.env,
- 'LANG' => ENV['LANG'],
- 'YARN_CACHE_FOLDER' => yarn_cache_dir
- }
+ # Las variables de entorno extra no pueden superponerse al local.
+ extra_env.merge({
+ 'HOME' => home_dir,
+ 'PATH' => paths.join(':'),
+ 'SPREE_API_KEY' => site.tienda_api_key,
+ 'SPREE_URL' => site.tienda_url,
+ 'AIRBRAKE_PROJECT_ID' => site.id.to_s,
+ 'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
+ 'JEKYLL_ENV' => Rails.env,
+ 'LANG' => ENV['LANG'],
+ 'YARN_CACHE_FOLDER' => yarn_cache_dir,
+ 'GEMS_SOURCE' => ENV['GEMS_SOURCE']
+ })
end
def yarn_cache_dir
Rails.root.join('_yarn_cache').to_s
end
+ def pnpm_cache_dir
+ Rails.root.join('_pnpm_cache').to_s
+ end
+
def yarn_lock
File.join(site.path, 'yarn.lock')
end
@@ -81,27 +106,43 @@ class DeployLocal < Deploy
File.exist? yarn_lock
end
- def gem
- run %(gem install bundler --no-document)
+ def pnpm_lock
+ File.join(site.path, 'pnpm-lock.yaml')
+ end
+
+ def pnpm_lock?
+ File.exist? pnpm_lock
+ end
+
+ def git_lfs(output: false)
+ run %(git lfs fetch), output: output
+ run %(git lfs checkout), output: output
+ end
+
+ def gem(output: false)
+ run %(gem install bundler --no-document), output: output
end
# Corre yarn dentro del repositorio
- def yarn
+ def yarn(output: false)
return true unless yarn_lock?
- run 'yarn install --production'
+ run 'yarn install --production', output: output
end
- def bundle
- if Rails.env.production?
- run %(bundle install --no-cache --path="#{gems_dir}")
- else
- run %(bundle install)
- end
+ def pnpm(output: false)
+ return true unless pnpm_lock?
+
+ run %(pnpm config set store-dir "#{pnpm_cache_dir}"), output: output
+ run 'pnpm install --production', output: output
end
- def jekyll_build
- run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}")
+ def bundle(output: false)
+ run %(bundle install --deployment --no-cache --path="#{gems_dir}" --clean --without test development), output: output
+ end
+
+ def jekyll_build(output: false)
+ run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}"), output: output
end
# no debería haber espacios ni caracteres especiales, pero por si
@@ -114,4 +155,18 @@ class DeployLocal < Deploy
def remove_destination!
FileUtils.rm_rf destination
end
+
+ # Consigue todas las variables de entorno configuradas por otros
+ # deploys.
+ #
+ # @deprecated Solo tenía sentido para Distributed Press v0
+ # @return [Hash]
+ def extra_env
+ @extra_env ||=
+ non_local_deploys.reduce({}) do |extra_env, deploy|
+ extra_env.tap do |e|
+ e.merge! deploy.local_env
+ end
+ end
+ end
end
diff --git a/app/models/deploy_localized_domain.rb b/app/models/deploy_localized_domain.rb
new file mode 100644
index 00000000..59e17dcd
--- /dev/null
+++ b/app/models/deploy_localized_domain.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Soportar dominios localizados
+class DeployLocalizedDomain < DeployAlternativeDomain
+ store :values, accessors: %i[hostname locale], coder: JSON
+
+ # Generar un link simbólico del sitio principal al alternativo
+ def deploy(**)
+ File.symlink?(destination) ||
+ File.symlink(File.join(site.hostname, locale), destination).zero?
+ end
+end
diff --git a/app/models/deploy_private.rb b/app/models/deploy_private.rb
index 3a6595f9..1fa42648 100644
--- a/app/models/deploy_private.rb
+++ b/app/models/deploy_private.rb
@@ -6,9 +6,11 @@
# XXX: La plantilla tiene que soportar esto con el plugin
# jekyll-private-data
class DeployPrivate < DeployLocal
+ DEPENDENCIES = %i[deploy_local]
+
# No es necesario volver a instalar dependencias
- def deploy
- jekyll_build
+ def deploy(output: false)
+ jekyll_build(output: output)
end
# Hacer el deploy a un directorio privado
@@ -16,6 +18,10 @@ class DeployPrivate < DeployLocal
File.join(Rails.root, '_private', site.name)
end
+ def url
+ "#{ENV['PANEL_URL']}/sites/private/#{site.name}"
+ end
+
# No usar recursos en compresión y habilitar los datos privados
def env
@env ||= super.merge({
diff --git a/app/models/deploy_reindex.rb b/app/models/deploy_reindex.rb
new file mode 100644
index 00000000..f3eb3d23
--- /dev/null
+++ b/app/models/deploy_reindex.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# Reindexa los artículos al terminar la compilación
+class DeployReindex < Deploy
+ def deploy(**)
+ time_start
+
+ site.reset
+
+ Site.transaction do
+ site.indexed_posts.destroy_all
+ site.index_posts!
+ end
+
+ time_stop
+
+ build_stats.create action: 'reindex',
+ log: 'Reindex',
+ seconds: time_spent_in_seconds,
+ bytes: size,
+ status: true
+ site.touch
+ end
+
+ def size
+ 0
+ end
+
+ def limit
+ 1
+ end
+
+ def hostname; end
+
+ def url; end
+
+ def destination; end
+end
diff --git a/app/models/deploy_rsync.rb b/app/models/deploy_rsync.rb
index 996f8cdd..fcc5a65d 100644
--- a/app/models/deploy_rsync.rb
+++ b/app/models/deploy_rsync.rb
@@ -3,10 +3,12 @@
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
# remoto tiene que tener rsync instalado.
class DeployRsync < Deploy
- store :values, accessors: %i[destination host_keys], coder: JSON
+ store :values, accessors: %i[hostname destination host_keys], coder: JSON
- def deploy
- ssh? && rsync
+ DEPENDENCIES = %i[deploy_local deploy_zip]
+
+ def deploy(output: false)
+ ssh? && rsync(output: output)
end
# El espacio remoto es el mismo que el local
@@ -23,6 +25,11 @@ class DeployRsync < Deploy
end
end
+ # @return [String]
+ def url
+ "https://#{hostname}/"
+ end
+
private
# Verificar la conexión SSH implementando Trust On First Use
@@ -31,6 +38,7 @@ class DeployRsync < Deploy
#
# @return [Boolean]
def ssh?
+ return true if destination.start_with? 'rsync://'
user, host = user_host
ssh_available = false
@@ -83,8 +91,8 @@ class DeployRsync < Deploy
# Sincroniza hacia el directorio remoto
#
# @return [Boolean]
- def rsync
- run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/)
+ def rsync(output: false)
+ run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
end
# El origen es el destino de la compilación
diff --git a/app/models/deploy_www.rb b/app/models/deploy_www.rb
index 5602b0fc..bb25cc64 100644
--- a/app/models/deploy_www.rb
+++ b/app/models/deploy_www.rb
@@ -4,9 +4,13 @@
class DeployWww < Deploy
store :values, accessors: %i[], coder: JSON
+ DEPENDENCIES = %i[deploy_local]
+
before_destroy :remove_destination!
- def deploy
+ def deploy(output: false)
+ puts "Creando symlink #{site.hostname} => #{destination}" if output
+
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
@@ -27,6 +31,10 @@ class DeployWww < Deploy
"www.#{site.hostname}"
end
+ def url
+ "https://#{fqdn}/"
+ end
+
private
def remove_destination!
diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb
index ec8973d1..85005470 100644
--- a/app/models/deploy_zip.rb
+++ b/app/models/deploy_zip.rb
@@ -8,28 +8,49 @@ require 'zip'
class DeployZip < Deploy
store :values, accessors: %i[], coder: JSON
+ DEPENDENCIES = %i[deploy_local]
+
# Una vez que el sitio está generado, tomar todos los archivos y
# y generar un zip accesible públicamente.
#
# rubocop:disable Metrics/MethodLength
- def deploy
+ def deploy(output: false)
FileUtils.rm_f path
-
time_start
- Dir.chdir(destination) do
- Zip::File.open(path, Zip::File::CREATE) do |z|
- Dir.glob('./**/**').each do |f|
- File.directory?(f) ? z.mkdir(f) : z.add(f, f)
+ Zip::File.open(path, Zip::File::CREATE) do |zip|
+ Dir.glob(File.join(destination, '**', '**')).each do |file|
+ entry = Pathname.new(file).relative_path_from(destination).to_s
+
+ if File.directory? file
+ log "Creando directorio #{entry}", output
+
+ zip.mkdir(entry)
+ else
+ log "Comprimiendo #{entry}", output
+ zip.add(entry, file)
end
end
end
+
time_stop
- build_stats.create action: 'zip',
- seconds: time_spent_in_seconds,
- bytes: size
+ File.exist?(path).tap do |status|
+ build_stats.create action: 'zip',
+ seconds: time_spent_in_seconds,
+ bytes: size,
+ log: @log.join("\n"),
+ status: status
+ end
+ rescue Zip::Error => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name })
- File.exist? path
+ build_stats.create action: 'zip',
+ seconds: 0,
+ bytes: 0,
+ log: @log.join("\n"),
+ status: false
+
+ false
end
# rubocop:enable Metrics/MethodLength
@@ -41,15 +62,33 @@ class DeployZip < Deploy
File.size path
end
+ # @return [String]
def destination
- File.join(Rails.root, '_deploy', site.hostname)
+ Rails.root.join('_deploy', site.hostname).realpath.to_s
+ rescue Errno::ENOENT
+ Rails.root.join('_deploy', site.hostname).to_s
end
def file
"#{site.hostname}.zip"
end
+ def url
+ "#{site.url}#{file}"
+ end
+
def path
File.join(destination, file)
end
+
+ private
+
+ # @param :line [String]
+ # @param :output [Boolean]
+ def log(line, output)
+ @log ||= []
+ @log << line
+
+ puts line if output
+ end
end
diff --git a/app/models/distributed_press_publisher.rb b/app/models/distributed_press_publisher.rb
new file mode 100644
index 00000000..6139db93
--- /dev/null
+++ b/app/models/distributed_press_publisher.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'distributed_press/v1'
+
+# Almacena el token de autenticación y la URL, por ahora solo vamos
+# a tener uno, pero queda abierta la posibilidad de agregar más.
+class DistributedPressPublisher < ApplicationRecord
+ # Cifrar la información del token en la base de datos
+ has_encrypted :token
+
+ # La salida del log
+ #
+ # @return [IO]
+ attr_reader :logger_out
+
+ # La instancia es única
+ validates_uniqueness_of :instance
+
+ # El token es necesario
+ validates_presence_of :token
+
+ # Mantener la fecha de vencimiento actualizada
+ before_save :update_expires_at_from_token!, :update_token_from_client!
+
+ # Devuelve todos los tokens que vencen en una hora
+ scope :with_about_to_expire_tokens, lambda {
+ where('expires_at > ? and expires_at < ?', Time.now, Time.now + 1.hour)
+ }
+
+ # Instancia un cliente de Distributed Press a partir del token. Al
+ # cargar un token a punto de vencer se renueva automáticamente.
+ #
+ # @return [DistributedPress::V1::Client]
+ def client
+ @client ||= DistributedPress::V1::Client.new(url: instance, token: token, logger: logger)
+ end
+
+ # @return [String]
+ def to_s
+ "Distributed Press <#{instance}>"
+ end
+
+ # Devuelve el hostname de la instancia
+ #
+ # @return [String]
+ def hostname
+ @hostname ||= URI.parse(instance).hostname
+ end
+
+ # @return [Logger]
+ def logger
+ @logger ||=
+ begin
+ @logger_out, @logger_in = IO.pipe
+ ::Logger.new @logger_in, formatter: formatter
+ end
+ end
+
+ private
+
+ def formatter
+ @formatter ||= lambda do |_, _, _, msg|
+ "#{msg}\n"
+ end
+ end
+
+ # Actualiza o desactiva la fecha de vencimiento a partir de la
+ # información del token.
+ #
+ # @return [nil]
+ def update_expires_at_from_token!
+ self.expires_at = client.token.forever? ? nil : client.token.expires_at
+ nil
+ end
+
+ # Actualiza el token a partir del cliente, que ya actualiza el token
+ # automáticamente.
+ #
+ # @return [nil]
+ def update_token_from_client!
+ self.token = client.token.to_s
+ nil
+ end
+end
diff --git a/app/models/layout.rb b/app/models/layout.rb
index c70829fa..efca66ee 100644
--- a/app/models/layout.rb
+++ b/app/models/layout.rb
@@ -9,6 +9,13 @@ Layout = Struct.new(:site, :name, :meta, :metadata, keyword_init: true) do
name.to_s
end
+ # Obtiene todos los layouts (schemas) dependientes de este.
+ #
+ # @return [Array]
+ def schemas
+ @schemas ||= site.layouts.to_h.slice(*site.schema_organization[name]).values
+ end
+
def attributes
@attributes ||= metadata.keys.map(&:to_sym)
end
diff --git a/app/models/licencia.rb b/app/models/licencia.rb
index c0eb1c80..65009f46 100644
--- a/app/models/licencia.rb
+++ b/app/models/licencia.rb
@@ -7,6 +7,7 @@ class Licencia < ApplicationRecord
translates :name, type: :string, locale_accessors: true
translates :url, type: :string, locale_accessors: true
translates :description, type: :text, locale_accessors: true
+ translates :short_description, type: :string, locale_accessors: true
translates :deed, type: :text, locale_accessors: true
has_many :sites
@@ -14,5 +15,10 @@ class Licencia < ApplicationRecord
validates :name, presence: true, uniqueness: true
validates :url, presence: true
validates :description, presence: true
+ validates :short_description, presence: true
validates :deed, presence: true
+
+ def custom?
+ icons == 'custom'
+ end
end
diff --git a/app/models/log_entry.rb b/app/models/log_entry.rb
index 1824da55..9685e0d0 100644
--- a/app/models/log_entry.rb
+++ b/app/models/log_entry.rb
@@ -11,7 +11,7 @@ class LogEntry < ApplicationRecord
def resend
return if sent
- ContactJob.perform_async site_id, params[:form], params
+ ContactJob.perform_later site_id, params[:form], params
end
def params
diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb
index 71d3f049..3ac89c9b 100644
--- a/app/models/metadata_file.rb
+++ b/app/models/metadata_file.rb
@@ -23,7 +23,6 @@ class MetadataFile < MetadataTemplate
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
- errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact!
@@ -41,14 +40,14 @@ class MetadataFile < MetadataTemplate
end
# Asociar la imagen subida al sitio y obtener la ruta
- #
- # XXX: Si evitamos guardar cambios con changed? no tenemos forma de
- # saber que un archivo subido manualmente se convirtió en
- # un Attachment y cada vez que lo editemos vamos a subir una imagen
- # repetida.
+ # @return [Boolean]
def save
- value['description'] = sanitize value['description']
- value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil
+ if value['path'].blank?
+ self[:value] = default_value
+ else
+ value['description'] = sanitize value['description']
+ value['path'] = relative_destination_path_with_filename.to_s if static_file
+ end
true
end
@@ -62,9 +61,6 @@ class MetadataFile < MetadataTemplate
# * El archivo es una ruta que apunta a un archivo asociado al sitio
# * El archivo es una ruta a un archivo dentro del repositorio
#
- # XXX: La última opción provoca archivos duplicados, pero es lo mejor
- # que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213
- #
# @todo encontrar una forma de obtener el attachment sin tener que
# recurrir al último subido.
#
@@ -75,13 +71,7 @@ class MetadataFile < MetadataTemplate
when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path'])
when String
- if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first)
- site.static_files.find_by(blob_id: blob_id)
- elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename)
- site.static_files.last.tap do |s|
- s.blob.update(key: key_from_path)
- end
- end
+ site.static_files.find_by(blob_id: blob_id) || migrate_static_file!
end
end
@@ -98,7 +88,7 @@ class MetadataFile < MetadataTemplate
#
# @return [String]
def key_from_path
- pathname.dirname.basename.to_s
+ @key_from_path ||= pathname.dirname.basename.to_s
end
def path?
@@ -127,13 +117,22 @@ class MetadataFile < MetadataTemplate
# devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno.
rescue Errno::ENOENT => e
- ExceptionNotifier.notify_exception(e)
+ ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
- value['path']
+ Pathname.new(File.join(site.path, value['path']))
end
+ # Obtener la ruta relativa al sitio.
+ #
+ # Si algo falla, devolver la ruta original para no romper el archivo.
+ #
+ # @return [String, nil]
def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath)
+ rescue ArgumentError => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
+
+ value['path']
end
def static_file_path
@@ -145,8 +144,31 @@ class MetadataFile < MetadataTemplate
end
end
- # No hay archivo pero se lo describió
- def no_file_for_description?
- !path? && description?
+ # Obtiene el id del blob asociado
+ #
+ # @return [Integer,nil]
+ def blob_id
+ @blob_id ||= ActiveStorage::Blob.where(key: key_from_path, service_name: site.name).pluck(:id).first
+ end
+
+ # Genera el blob para un archivo que ya se encuentra en el
+ # repositorio y lo agrega a la base de datos.
+ #
+ # @return [ActiveStorage::Attachment]
+ def migrate_static_file!
+ raise ArgumentError, 'El archivo no existe' unless path? && pathname.exist?
+
+ Site.transaction do
+ blob =
+ ActiveStorage::Blob.create_after_unfurling!(key: key_from_path,
+ io: pathname.open,
+ filename: pathname.basename,
+ service_name: site.name)
+
+ ActiveStorage::Attachment.create!(name: 'static_files', record: site, blob: blob)
+ end
+ rescue ArgumentError => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
+ nil
end
end
diff --git a/app/models/metadata_locales.rb b/app/models/metadata_locales.rb
index 4d540efc..37b50286 100644
--- a/app/models/metadata_locales.rb
+++ b/app/models/metadata_locales.rb
@@ -1,22 +1,49 @@
# frozen_string_literal: true
# Los valores de este metadato son artículos en otros idiomas
-class MetadataLocales < MetadataTemplate
- def default_value
- super || []
- end
-
+class MetadataLocales < MetadataHasAndBelongsToMany
# Todos los valores posibles para cada idioma disponible
#
- # TODO: Optimizar?
- # TODO: Mantener sincronizados
- #
# @return { lang: { title: uuid } }
def values
@values ||= site.locales.map do |locale|
- [locale, site.posts(lang: locale).map do |post|
- [post.title.value, post.uuid.value]
+ [locale, posts.where(lang: locale).map do |post|
+ [title(post), post.uuid.value]
end.to_h]
end.to_h
end
+
+ # Siempre hay una relación inversa
+ #
+ # @return [True]
+ def inverse?
+ true
+ end
+
+ # El campo inverso se llama igual en el otro post
+ #
+ # @return [Symbol]
+ def inverse
+ :locales
+ end
+
+ private
+
+ # Obtiene todos los locales distintos a este post
+ #
+ # @return [Array]
+ def other_locales
+ site.locales.reject do |locale|
+ locale == post.lang.value.to_sym
+ end
+ end
+
+ # Obtiene todos los posts de los otros locales con el mismo layout
+ #
+ # @return [PostRelation]
+ def posts
+ other_locales.map do |locale|
+ site.posts(lang: locale).where(layout: post.layout.value)
+ end.reduce(&:concat) || PostRelation.new(site: site, lang: 'any')
+ end
end
diff --git a/app/models/metadata_non_geo.rb b/app/models/metadata_non_geo.rb
new file mode 100644
index 00000000..6aec8461
--- /dev/null
+++ b/app/models/metadata_non_geo.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+class MetadataNonGeo < MetadataGeo; end
diff --git a/app/models/metadata_password.rb b/app/models/metadata_password.rb
new file mode 100644
index 00000000..1e0e2698
--- /dev/null
+++ b/app/models/metadata_password.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# Almacena una contraseña
+class MetadataPassword < MetadataString
+ # Las contraseñas no son indexables
+ #
+ # @return [boolean]
+ def indexable?
+ false
+ end
+
+ private
+
+ alias_method :original_sanitize, :sanitize
+
+ # Sanitizar la string y generar un hash Bcrypt
+ #
+ # @param :string [String]
+ # @return [String]
+ def sanitize(string)
+ string = original_sanitize string
+
+ ::BCrypt::Password.create(string).to_s
+ end
+end
diff --git a/app/models/metadata_permalink.rb b/app/models/metadata_permalink.rb
index 30ad32cc..895b7439 100644
--- a/app/models/metadata_permalink.rb
+++ b/app/models/metadata_permalink.rb
@@ -2,12 +2,6 @@
# Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString
- # El valor por defecto una vez creado es la URL que le asigne Jekyll,
- # de forma que nunca cambia aunque se cambie el título.
- def default_value
- document.url.sub(%r{\A/}, '') unless post.new?
- end
-
# Los permalinks nunca pueden ser privados
def private?
false
diff --git a/app/models/metadata_slug.rb b/app/models/metadata_slug.rb
index 09da23f9..b0fe8cec 100644
--- a/app/models/metadata_slug.rb
+++ b/app/models/metadata_slug.rb
@@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar
def default_value
- title ? Jekyll::Utils.slugify(title) : SecureRandom.uuid
+ title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid
end
def value
@@ -39,6 +39,6 @@ class MetadataSlug < MetadataTemplate
return if post.title&.private?
return if post.title&.value&.blank?
- post.title&.value&.to_s
+ post.title&.value&.to_s&.unicode_normalize
end
end
diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb
index ca5b48e3..3e974b18 100644
--- a/app/models/metadata_template.rb
+++ b/app/models/metadata_template.rb
@@ -134,7 +134,11 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# En caso de que algún campo necesite realizar acciones antes de ser
# guardado
def save
- return true unless changed?
+ if !changed?
+ self[:value] = document_value if private?
+
+ return true
+ end
self[:value] = sanitize value
self[:value] = encrypt(value) if private?
diff --git a/app/models/post.rb b/app/models/post.rb
index cab7665f..5cc1c5ea 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -29,7 +29,7 @@ class Post
# TODO: Reemplazar cuando leamos el contenido del Document
# a demanda?
def find_layout(path)
- IO.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
+ File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
end
end
@@ -90,16 +90,21 @@ class Post
'page' => document.to_liquid
}
+ # No tener errores de Liquid
+ site.jekyll.config['liquid']['strict_filters'] = false
+ site.jekyll.config['liquid']['strict_variables'] = false
+
# Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document
- # Las imágenes se cargan directamente desde el repositorio, porque
+ # Los archivos se cargan directamente desde el repositorio, porque
# no son públicas hasta que se publica el artículo.
- html.css('img').each do |img|
- next if %r{\Ahttps?://} =~ img.attributes['src']
+ html.css('img,audio,video,iframe').each do |element|
+ src = element.attributes['src']
- img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site,
- file: img.attributes['src'].value)
+ next unless src&.value&.start_with? 'public/'
+
+ src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: src.value)
end
# Notificar a les usuaries que están viendo una previsualización
@@ -108,12 +113,16 @@ class Post
# Cacofonía
html.to_html.html_safe
+ rescue Liquid::Error => e
+ ExceptionNotifier.notify(e, data: { site: site.name, post: post.id })
+
+ ''
end
end
# Devuelve una llave para poder guardar el post en una cache
def cache_key
- 'posts/' + uuid.value
+ "posts/#{uuid.value}"
end
def cache_version
@@ -123,7 +132,7 @@ class Post
# Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration
def cache_key_with_version
- cache_key + '-' + cache_version
+ "#{cache_key}-#{cache_version}"
end
# TODO: Convertir a UUID?
diff --git a/app/models/post/indexable.rb b/app/models/post/indexable.rb
index 7757e7f7..4e46d7b2 100644
--- a/app/models/post/indexable.rb
+++ b/app/models/post/indexable.rb
@@ -14,9 +14,8 @@ class Post
#
# @return [IndexedPost]
def to_index
- IndexedPost.find_or_create_by(id: uuid.value).tap do |indexed_post|
+ IndexedPost.find_or_initialize_by(post_id: uuid.value, site_id: site.id).tap do |indexed_post|
indexed_post.layout = layout.name
- indexed_post.site_id = site.id
indexed_post.path = path.basename
indexed_post.locale = locale.value
indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value)
@@ -28,8 +27,6 @@ class Post
end
end
- private
-
# Indexa o reindexa el Post
#
# @return [Boolean]
@@ -41,6 +38,8 @@ class Post
to_index.destroy.destroyed?
end
+ private
+
# Los metadatos que se almacenan como objetos JSON. Empezamos con
# las categorías porque se usan para filtrar en el listado de
# artículos.
diff --git a/app/models/privacy_policy.rb b/app/models/privacy_policy.rb
new file mode 100644
index 00000000..8805daa9
--- /dev/null
+++ b/app/models/privacy_policy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Políticas de privacidad
+class PrivacyPolicy < ApplicationRecord
+ extend Mobility
+
+ translates :title, type: :string, locale_accessors: true
+ translates :description, type: :text, locale_accessors: true
+ translates :content, type: :text, locale_accessors: true
+
+ validates :title, presence: true, uniqueness: true
+ validates :description, presence: true
+ validates :content, presence: true
+end
diff --git a/app/models/rol.rb b/app/models/rol.rb
index 5879d666..fcd07037 100644
--- a/app/models/rol.rb
+++ b/app/models/rol.rb
@@ -21,4 +21,8 @@ class Rol < ApplicationRecord
def usuarie?
rol == USUARIE
end
+
+ def self.role?(rol)
+ ROLES.include? rol
+ end
end
diff --git a/app/models/site.rb b/app/models/site.rb
index fd318995..24644b9c 100644
--- a/app/models/site.rb
+++ b/app/models/site.rb
@@ -7,6 +7,9 @@ class Site < ApplicationRecord
include Site::Forms
include Site::FindAndReplace
include Site::Api
+ include Site::DeployDependencies
+ include Site::BuildStats
+ include Site::LayoutOrdering
include Tienda
# Cifrar la llave privada que cifra y decifra campos ocultos. Sutty
@@ -17,7 +20,7 @@ class Site < ApplicationRecord
# 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
+ DEPLOYS = %i[local private www zip hidden_service distributed_press].freeze
validates :name, uniqueness: true, hostname: {
allow_root_label: true
@@ -54,10 +57,6 @@ class Site < ApplicationRecord
before_create :clone_skel!
# Elimina el directorio al destruir un sitio
before_destroy :remove_directories!
- # Carga el sitio Jekyll una vez que se inicializa el modelo o después
- # de crearlo
- after_initialize :load_jekyll
- after_create :load_jekyll
# Cambiar el nombre del directorio
before_update :update_name!
before_save :add_private_key_if_missing!
@@ -183,10 +182,20 @@ class Site < ApplicationRecord
# Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios.
+ #
+ # @return [Array]
def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
end
+ # Modificar los locales disponibles
+ #
+ # @param :new_locales [Array]
+ # @return [Array]
+ def locales=(new_locales)
+ @locales = new_locales.map(&:to_sym).uniq
+ end
+
# Similar a site.i18n en jekyll-locales
#
# @return [Hash]
@@ -254,6 +263,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang)
+ rescue TypeError => e
+ ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
end
@posts[lang]
@@ -355,10 +366,19 @@ class Site < ApplicationRecord
status == 'building'
end
+ def jekyll?
+ File.directory? path
+ end
+
def jekyll
- run_in_path do
- @jekyll ||= Jekyll::Site.new(configuration)
- end
+ @jekyll ||=
+ begin
+ install_gems
+
+ Jekyll::Site.new(configuration).tap do |site|
+ site.reader = JekyllData::Reader.new(site) if site.theme
+ end
+ end
end
# Cargar el sitio Jekyll
@@ -404,9 +424,6 @@ class Site < ApplicationRecord
@configuration[unneeded] = [] if @configuration.key? unneeded
end
- # Eliminar el theme si no es una gema válida
- @configuration.delete('theme') unless theme_available?
-
# Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones"
locales.map(&:to_s).each do |i|
@@ -416,20 +433,6 @@ class Site < ApplicationRecord
@configuration
end
- # Lista los nombres de las plantillas disponibles como gemas,
- # tomándolas dinámicamente de las que agreguemos en el grupo :themes
- # del Gemfile.
- def available_themes
- @available_themes ||= Bundler.load.current_dependencies.select do |gem|
- gem.groups.include? :themes
- end.map(&:name)
- end
-
- # Detecta si el tema actual es una gema
- def theme_available?
- available_themes.include? design&.gem
- end
-
# Devuelve el dominio actual
def self.domain
ENV.fetch('SUTTY', 'sutty.nl')
@@ -437,7 +440,7 @@ class Site < ApplicationRecord
# El directorio donde se almacenan los sitios
def self.site_path
- @site_path ||= ENV.fetch('SITE_PATH', Rails.root.join('_sites'))
+ @site_path ||= File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites')))
end
def self.default
@@ -468,7 +471,7 @@ class Site < ApplicationRecord
# Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
# si el sitio ya existe
def clone_skel!
- return if File.directory? path
+ return if jekyll?
Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
end
@@ -496,6 +499,7 @@ class Site < ApplicationRecord
config.title = title
config.url = url(slash: false)
config.hostname = hostname
+ config.locales = locales.map(&:to_s)
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada
@@ -551,4 +555,36 @@ class Site < ApplicationRecord
def run_in_path(&block)
Dir.chdir path, &block
end
+
+ # Instala las gemas cuando es necesario:
+ #
+ # * El sitio existe
+ # * No están instaladas
+ # * El archivo Gemfile se modificó
+ # * El archivo Gemfile.lock se modificó
+ def install_gems
+ return unless persisted?
+
+ deploys.find_by_type('DeployLocal').send(:git_lfs)
+
+ if !gem_dir? || gemfile_updated? || gemfile_lock_updated?
+ deploys.find_by_type('DeployLocal').send(:bundle)
+ touch
+ end
+ end
+
+ # Detecta si el repositorio de gemas existe
+ def gem_dir?
+ Rails.root.join('_storage', 'gems', name).directory?
+ end
+
+ # Detecta si el Gemfile fue modificado
+ def gemfile_updated?
+ updated_at < File.mtime(File.join(path, 'Gemfile'))
+ end
+
+ # Detecta si el Gemfile.lock fue modificado
+ def gemfile_lock_updated?
+ updated_at < File.mtime(File.join(path, 'Gemfile.lock'))
+ end
end
diff --git a/app/models/site/build_stats.rb b/app/models/site/build_stats.rb
new file mode 100644
index 00000000..071b1eab
--- /dev/null
+++ b/app/models/site/build_stats.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+class Site
+ module BuildStats
+ extend ActiveSupport::Concern
+
+ included do
+ # Devuelve el tiempo promedio de publicación para este sitio
+ #
+ # @return [Integer]
+ def average_publication_time
+ build_stats.group(:action).average(:seconds).values.reduce(:+).round
+ end
+
+ # Devuelve el tiempo promedio de compilación para sitios similares
+ # a este.
+ #
+ # @return [Integer]
+ def average_publication_time_for_similar_sites
+ similar_deploys = Deploy.where(type: deploys.pluck(:type)).pluck(:id)
+
+ BuildStat.where(deploy_id: similar_deploys).group(:action).average(:seconds).values.reduce(:+).round
+ end
+
+ # Define si podemos calcular el tiempo promedio de publicación
+ # para este sitio
+ #
+ # @return [Boolean]
+ def average_publication_time_calculable?
+ build_stats.jekyll.where(status: true).count > 1
+ end
+
+ def similar_sites?
+ !design.no_theme?
+ end
+
+ # Detecta si el sitio todavía no ha sido publicado
+ #
+ # @return [Boolean]
+ def not_published_yet?
+ build_stats.jekyll.where(status: true).count.zero?
+ end
+
+ # Cambios posibles luego de la última publicación exitosa:
+ #
+ # * Artículos modificados
+ # * Configuración modificada
+ # * Métodos de publicación añadidos
+ #
+ # @return [Boolean]
+ def awaiting_publication?
+ waiting? && (post_pending? || deploy_pending? || configuration_pending?)
+ end
+
+ # Se modificaron artículos después de publicar el sitio por última
+ # vez
+ #
+ # @return [Boolean]
+ def post_pending?
+ last_indexed_post_time > last_publication_time
+ end
+
+ # Se modificó el sitio después de publicarlo por última vez
+ #
+ # @return [Boolean]
+ def deploy_pending?
+ last_deploy_time > last_publication_time
+ end
+
+ # Se modificó la configuración del sitio
+ #
+ # @return [Boolean]
+ def configuration_pending?
+ last_configuration_time > last_publication_time
+ end
+
+ private
+
+ # Encuentra la fecha del último artículo modificado. Si no hay
+ # ninguno, devuelve la fecha de modificación del sitio.
+ #
+ # @return [Time]
+ def last_indexed_post_time
+ indexed_posts.order(updated_at: :desc).select(:updated_at).first&.updated_at || updated_at
+ end
+
+ # Encuentra la fecha de última modificación de los métodos de
+ # publicación.
+ #
+ # @return [Time]
+ def last_deploy_time
+ deploys.order(created_at: :desc).select(:created_at).first&.created_at || updated_at
+ end
+
+ # Encuentra la fecha de última publicación exitosa, si no hay
+ # ninguno, devuelve la fecha de modificación del sitio.
+ #
+ # @return [Time]
+ def last_publication_time
+ build_stats.jekyll.where(status: true).order(created_at: :desc).select(:created_at).first&.created_at || updated_at
+ end
+
+ # Fecha de última modificación de la configuración
+ #
+ # @return [Time]
+ def last_configuration_time
+ File.mtime(config.path)
+ end
+ end
+ end
+end
diff --git a/app/models/site/config.rb b/app/models/site/config.rb
index 3215277e..fb9175c1 100644
--- a/app/models/site/config.rb
+++ b/app/models/site/config.rb
@@ -31,12 +31,12 @@ class Site
# Escribe los cambios en el repositorio
def write
- return if persisted?
+ return true if persisted?
- @saved = Site::Writer.new(site: site, file: path,
- content: content.to_yaml).save
- # Actualizar el hash para no escribir dos veces
- @hash = content.hash
+ @saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result|
+ # Actualizar el hash para no escribir dos veces
+ @hash = content.hash
+ end
end
alias save write
diff --git a/app/models/site/deploy_dependencies.rb b/app/models/site/deploy_dependencies.rb
new file mode 100644
index 00000000..ed80a8af
--- /dev/null
+++ b/app/models/site/deploy_dependencies.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rgl/adjacency'
+require 'rgl/topsort'
+
+class Site
+ module DeployDependencies
+ extend ActiveSupport::Concern
+
+ included do
+ # Genera un grafo dirigido de todos los métodos de publicación
+ #
+ # @return [RGL::DirectedAdjacencyGraph]
+ def deployment_graph
+ @deployment_graph ||= RGL::DirectedAdjacencyGraph.new.tap do |graph|
+ deploys.each do |deploy|
+ graph.add_vertex deploy
+ end
+
+ deploys.each do |deploy|
+ deploy.class.all_dependencies.each do |dependency|
+ deploys.where(type: dependency.to_s.classify).each do |deploy_dependency|
+ graph.add_edge deploy_dependency, deploy
+ end
+ end
+ end
+ end
+ end
+
+ # Devuelve una lista ordenada de todos los métodos de publicación
+ #
+ # @return [Array]
+ def deployment_list
+ @deployment_list ||= deployment_graph.topsort_iterator.to_a
+ end
+ end
+ end
+end
diff --git a/app/models/site/index.rb b/app/models/site/index.rb
index e10fa523..e11095e3 100644
--- a/app/models/site/index.rb
+++ b/app/models/site/index.rb
@@ -14,9 +14,7 @@ class Site
def index_posts!
Site.transaction do
- docs.each do |post|
- post.to_index.save
- end
+ docs.each(&:index!)
end
end
end
diff --git a/app/models/site/layout_ordering.rb b/app/models/site/layout_ordering.rb
new file mode 100644
index 00000000..9fecbf21
--- /dev/null
+++ b/app/models/site/layout_ordering.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class Site
+ # Obtiene un listado de layouts (schemas)
+ module LayoutOrdering
+ extend ActiveSupport::Concern
+
+ included do
+
+ # Obtiene o genera un listado de layouts (schemas) con sus
+ # dependencias, para poder generar un árbol.
+ #
+ # Por defecto, si el sitio no lo soporta, se obtienen los layouts
+ # ordenados alfabéticamente por traducción.
+ #
+ # @return [Hash]
+ def schema_organization
+ @schema_organization ||=
+ begin
+ schema_organization = data.dig('schema', 'organization')
+ schema_organization&.symbolize_keys!
+ schema_organization&.transform_values! do |ary|
+ ary.map(&:to_sym)
+ end
+
+ schema_organization ||
+ begin
+ layouts = self.layouts.sort_by(&:humanized_name).map(&:name)
+ Hash[layouts.zip([].fill([], 0, layouts.size))]
+ end
+ end
+ end
+
+ # TODO: Deprecar cuando renombremos layouts a schemas
+ alias layout_organization schema_organization
+ end
+ end
+end
diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb
index 74db2549..62e4c45e 100644
--- a/app/models/site/repository.rb
+++ b/app/models/site/repository.rb
@@ -117,6 +117,9 @@ class Site
def commit(file:, usuarie:, message:, remove: false)
file = [file] unless file.respond_to? :each
+ # Cargar el árbol actual
+ rugged.index.read_tree rugged.head.target.tree
+
file.each do |f|
remove ? rm(f) : add(f)
end
@@ -147,6 +150,23 @@ class Site
rugged.index.remove(relativize(file))
end
+ # Garbage collection
+ #
+ # @return [Boolean]
+ def gc
+ env = { 'PATH' => '/usr/bin', 'LANG' => ENV['LANG'], 'HOME' => path }
+ cmd = 'git gc'
+
+ r = nil
+ Dir.chdir(path) do
+ Open3.popen2e(env, cmd, unsetenv_others: true) do |_, _, t|
+ r = t.value
+ end
+ end
+
+ r&.success?
+ end
+
private
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las
diff --git a/app/models/site_build_stat.rb b/app/models/site_build_stat.rb
new file mode 100644
index 00000000..1a63a0bb
--- /dev/null
+++ b/app/models/site_build_stat.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+SiteBuildStat = Struct.new(:site)
diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb
index c88dcc68..2bc7a1b5 100644
--- a/app/models/usuarie.rb
+++ b/app/models/usuarie.rb
@@ -2,6 +2,8 @@
# Usuarie de la plataforma
class Usuarie < ApplicationRecord
+ include Usuarie::Consent
+
devise :invitable, :database_authenticatable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :registerable
@@ -9,6 +11,10 @@ class Usuarie < ApplicationRecord
validates_uniqueness_of :email
validates_with EmailAddress::ActiveRecordValidator, field: :email
+ before_create :lang_from_locale!
+ before_update :remove_confirmation_invitation_inconsistencies!
+ before_update :accept_invitation_after_confirmation!
+
has_many :roles
has_many :sites, through: :roles
has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit'
@@ -38,4 +44,38 @@ class Usuarie < ApplicationRecord
increment_failed_attempts
lock_access! if attempts_exceeded? && !access_locked?
end
+
+ def send_devise_notification(notification, *args)
+ I18n.with_locale(lang) do
+ devise_mailer.send(notification, self, *args).deliver_later
+ end
+ end
+
+ # Les usuaries necesitan link de invitación si no tenían cuenta
+ # y todavía no aceptaron la invitación anterior.
+ def needs_invitation_link?
+ created_by_invite? && !invitation_accepted?
+ end
+
+ private
+
+ def lang_from_locale!
+ self.lang = I18n.locale.to_s
+ end
+
+ # El invitation_token solo es necesario cuando fue creade por otre
+ # usuarie. De lo contrario lo que queremos es un proceso de
+ # confirmación.
+ def remove_confirmation_invitation_inconsistencies!
+ self.invitation_token = nil unless created_by_invite?
+ end
+
+ # Si le usuarie (re)confirma su cuenta con una invitación pendiente,
+ # considerarla aceptada también.
+ def accept_invitation_after_confirmation!
+ if confirmed?
+ self.invitation_token = nil
+ self.invitation_accepted_at ||= Time.now.utc
+ end
+ end
end
diff --git a/app/policies/site_build_stat_policy.rb b/app/policies/site_build_stat_policy.rb
new file mode 100644
index 00000000..03f09d21
--- /dev/null
+++ b/app/policies/site_build_stat_policy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Quiénes pueden ver estados de compilación de un sitio
+class SiteBuildStatPolicy
+ attr_reader :site_build_stat, :usuarie
+
+ def initialize(usuarie, site_build_stat)
+ @usuarie = usuarie
+ @site_build_stat = site_build_stat
+ end
+
+ # Todes les usuaries e invitades de este sitio
+ def index?
+ site_build_stat.site.usuarie?(usuarie) || site_build_stat.site.invitade?(usuarie)
+ end
+end
diff --git a/app/services/cleanup_service.rb b/app/services/cleanup_service.rb
new file mode 100644
index 00000000..ad87cf9a
--- /dev/null
+++ b/app/services/cleanup_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# Realiza tareas de limpieza en todos los sitios, para optimizar y
+# liberar espacio.
+class CleanupService
+ # Días de antigüedad de los sitios
+ attr_reader :before
+
+ # @param :before [ActiveSupport::TimeWithZone] Cuánto tiempo lleva sin usarse un sitio.
+ def initialize(before: 30.days.ago)
+ @before = before
+ end
+
+ # Limpieza general
+ #
+ # @return [nil]
+ def cleanup_everything!
+ cleanup_older_sites!
+ cleanup_newer_sites!
+ end
+
+ # Encuentra todos los sitios sin actualizar y realiza limpieza.
+ #
+ # @return [nil]
+ def cleanup_older_sites!
+ Site.where('updated_at < ?', before).find_each do |site|
+ next unless File.directory? site.path
+
+ site.deploys.find_each(&:cleanup!)
+
+ site.repository.gc
+ site.touch
+ end
+ end
+
+ # Tareas para los sitios en uso
+ #
+ # @return [nil]
+ def cleanup_newer_sites!
+ Site.where('updated_at >= ?', before).find_each do |site|
+ next unless File.directory? site.path
+
+ site.repository.gc
+ site.touch
+ end
+ end
+end
diff --git a/app/services/lfs_object_service.rb b/app/services/lfs_object_service.rb
new file mode 100644
index 00000000..bb62301d
--- /dev/null
+++ b/app/services/lfs_object_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# Representa un objeto git LFS
+class LfsObjectService
+ attr_reader :site, :blob
+
+ # @param :site [Site]
+ # @param :blob [ActiveStorage::Blob]
+ def initialize(site:, blob:)
+ @site = site
+ @blob = blob
+ end
+
+ def process
+ # Crear el directorio
+ FileUtils.mkdir_p(File.dirname(object_path))
+
+ # Mover el archivo
+ FileUtils.mv(path, object_path) unless File.exist? object_path
+
+ # Crear el pointer
+ Site::Writer.new(site: site, file: path, content: pointer).save
+
+ # Commitear el pointer
+ site.repository.commit(file: path, usuarie: author, message: File.basename(path))
+
+ # Eliminar el pointer
+ FileUtils.rm(path)
+
+ # Hacer link duro del objeto al archivo
+ FileUtils.ln(object_path, path)
+ end
+
+ # @return [String]
+ def path
+ @path ||= blob.service.path_for(blob.key)
+ end
+
+ # @return [String]
+ def digest
+ @digest ||= Digest::SHA256.file(path).hexdigest
+ end
+
+ # @return [String]
+ def object_path
+ @object_path ||= File.join(site.path, '.git', 'lfs', 'objects', digest[0..1], digest[2..3], digest)
+ end
+
+ # @return [Integer]
+ def size
+ @size ||= File.size(File.exist?(object_path) ? object_path : path)
+ end
+
+ # @return [String]
+ def pointer
+ @pointer ||=
+ <<~POINTER
+ version https://git-lfs.github.com/spec/v1
+ oid sha256:#{digest}
+ size #{size}
+ POINTER
+ end
+
+ def author
+ @author ||= GitAuthor.new email: "disk_service@#{Site.domain}", name: 'DiskService'
+ end
+end
diff --git a/app/services/post_service.rb b/app/services/post_service.rb
index e448bb4c..7b31867d 100644
--- a/app/services/post_service.rb
+++ b/app/services/post_service.rb
@@ -12,8 +12,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
+ params.require(:post).permit(:slug).tap do |p|
+ post.slug.value = p[:slug] if p[:slug].present?
+ end
+
commit(action: :created, file: update_related_posts) if post.update(post_params)
+ update_site_license!
+
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
@@ -40,6 +46,8 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# relacionados.
commit(action: :updated, file: update_related_posts) if post.update(post_params)
+ update_site_license!
+
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
@@ -133,4 +141,12 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
p.path.absolute if p.save(validate: false)
end.compact << post.path.absolute
end
+
+ # Si les usuaries modifican o crean una licencia, considerarla
+ # personalizada en el panel.
+ def update_site_license!
+ if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
+ site.update licencia: Licencia.find_by_icons('custom')
+ end
+ end
end
diff --git a/app/services/site_service.rb b/app/services/site_service.rb
index 22423bb8..2c29538c 100644
--- a/app/services/site_service.rb
+++ b/app/services/site_service.rb
@@ -3,22 +3,39 @@
# Se encargar de guardar cambios en sitios
# TODO: Implementar rollback en la configuración
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
+ def deploy
+ site.enqueue!
+ DeployJob.perform_later site.id
+ end
+
# Crea un sitio, agrega un rol nuevo y guarda los cambios a la
# configuración en el repositorio git
def create
self.site = Site.new params
add_role temporal: false, rol: 'usuarie'
- sync_nodes
+ site.deploys.build type: 'DeployLocal'
+ # Los sitios de testing no se sincronizan
+ sync_nodes unless site.name.end_with? '.testing'
+
+ I18n.with_locale(usuarie.lang.to_sym || I18n.default_locale) do
+ # No se puede llamar a site.config antes de save porque el sitio
+ # todavía no existe.
+ #
+ # TODO: hacer que el repositorio se cree cuando es necesario, para
+ # que no haya estados intermedios.
+ site.locales = [usuarie.lang] + I18n.available_locales
- I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.save &&
site.config.write &&
- commit_config(action: :create)
+ commit_config(action: :create) &&
+ site.reset.nil? &&
+ add_licencias &&
+ add_code_of_conduct &&
+ add_privacy_policy &&
+ deploy
end
- add_licencias
-
site
end
@@ -27,11 +44,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.update(params) &&
site.config.write &&
- commit_config(action: :update)
+ commit_config(action: :update) &&
+ site.reset.nil? &&
+ change_licencias
end
- change_licencias
-
site
end
@@ -48,14 +65,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# Agregar una dirección oculta de Tor al DeployHiddenService y a la
# configuración del Site.
def add_onion
- onion = params[:onion].strip
- deploy = DeployHiddenService.find_by(site: site)
+ onion = params[:onion]
+ deploy = params[:deploy]
return false unless !onion.blank? && deploy
- deploy.values[:onion] = onion
- deploy.save
-
site.config['onion-location'] = onion
site.config.write
@@ -91,24 +105,28 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
end
# Crea la licencia del sitio para cada locale disponible en el sitio
+ #
+ # @return [Boolean]
def add_licencias
- site.locales.each do |locale|
- next unless I18n.available_locales.include? locale
+ return true unless site.layout? :license
+ return true if site.licencia.custom?
- Mobility.with_locale(locale) do
- add_licencia lang: locale
- end
- end
+ with_all_locales do |locale|
+ add_licencia lang: locale
+ end.compact.map(&:valid?).all?
end
+ # Crea una licencia
+ #
+ # @return [Post]
def add_licencia(lang:)
params = ActionController::Parameters.new(
post: {
+ layout: 'license',
+ slug: Jekyll::Utils.slugify(I18n.t('activerecord.models.licencia')),
lang: lang,
title: site.licencia.name,
- description: I18n.t('sites.form.licencia.title'),
- author: %w[Sutty],
- permalink: "#{I18n.t('activerecord.models.licencia').downcase}/",
+ description: site.licencia.short_description,
content: CommonMarker.render_html(site.licencia.deed)
}
)
@@ -119,25 +137,27 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# Encuentra la licencia a partir de su enlace permanente y le cambia
# el contenido
#
- # TODO: Crear un layout específico para licencias así es más certera
- # la búsqueda.
+ # @return [Boolean]
def change_licencias
- site.locales.each do |locale|
- next unless I18n.available_locales.include? locale
+ return true unless site.layout? :license
+ return true if site.licencia.custom?
- Mobility.with_locale(locale) do
- permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
- post = site.posts(lang: locale).find_by(permalink: permalink)
+ with_all_locales do |locale|
+ post = site.posts(lang: locale).find_by(layout: 'license')
- post ? change_licencia(post: post) : add_licencia(lang: locale)
- end
- end
+ change_licencia(post: post) if post
+ end.compact.map(&:valid?).all?
end
+ # Cambia una licencia
+ #
+ # @param :post [Post]
+ # @return [Post]
def change_licencia(post:)
params = ActionController::Parameters.new(
post: {
title: site.licencia.name,
+ description: site.licencia.short_description,
content: CommonMarker.render_html(site.licencia.deed)
}
)
@@ -146,10 +166,69 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
params: params).update
end
+ # Agrega un código de conducta
+ #
+ # @return [Boolean]
+ def add_code_of_conduct
+ return true unless site.layout?(:code_of_conduct) || site.layout?(:page)
+
+ # TODO: soportar más códigos de conducta
+ coc = CodeOfConduct.first
+
+ with_all_locales do |locale|
+ params = ActionController::Parameters.new(
+ post: {
+ layout: site.layout?(:code_of_conduct) ? 'code_of_conduct' : 'page',
+ lang: locale.to_s,
+ title: coc.title,
+ description: coc.description,
+ content: CommonMarker.render_html(coc.content)
+ }
+ )
+
+ PostService.new(site: site, usuarie: usuarie, params: params).create
+ end.compact.map(&:valid?).all?
+ end
+
+ # Agrega política de privacidad
+ #
+ # @return [Boolean]
+ def add_privacy_policy
+ return true unless site.layout?(:privacy_policy) || site.layout?(:page)
+
+ pp = PrivacyPolicy.first
+
+ with_all_locales do |locale|
+ params = ActionController::Parameters.new(
+ post: {
+ layout: site.layout?(:privacy_policy) ? 'privacy_policy' : 'page',
+ lang: locale.to_s,
+ title: pp.title,
+ description: pp.description,
+ content: CommonMarker.render_html(pp.content)
+ }
+ )
+
+ PostService.new(site: site, usuarie: usuarie, params: params).create
+ end.compact.map(&:valid?).all?
+ end
+
# Crea los deploys necesarios para sincronizar a otros nodos de Sutty
def sync_nodes
Rails.application.nodes.each do |node|
- site.deploys.build(type: 'DeployRsync', destination: "sutty@#{node}:#{site.hostname}")
+ site.deploys.build(type: 'DeployFullRsync', destination: "rsync://rsyncd.#{node}/deploys/", hostname: node)
+ end
+ end
+
+ private
+
+ def with_all_locales(&block)
+ site.locales.map do |locale|
+ next unless I18n.available_locales.include? locale
+
+ Mobility.with_locale(locale) do
+ yield locale
+ end
end
end
end
diff --git a/app/views/bootstrap/_alert.haml b/app/views/bootstrap/_alert.haml
new file mode 100644
index 00000000..85bcbe84
--- /dev/null
+++ b/app/views/bootstrap/_alert.haml
@@ -0,0 +1,2 @@
+.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] }
+ = yield
diff --git a/app/views/bootstrap/_custom_checkbox.haml b/app/views/bootstrap/_custom_checkbox.haml
new file mode 100644
index 00000000..0c3ff3a6
--- /dev/null
+++ b/app/views/bootstrap/_custom_checkbox.haml
@@ -0,0 +1,6 @@
+- help_id = "#{id}_help"
+
+.custom-control.custom-checkbox
+ %input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
+ %label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
+ %small.form-text.text-muted{ id: help_id }= yield
diff --git a/app/views/build_stats/index.haml b/app/views/build_stats/index.haml
new file mode 100644
index 00000000..27c063f9
--- /dev/null
+++ b/app/views/build_stats/index.haml
@@ -0,0 +1,20 @@
+%main.row
+ %aside.menu.col-md-3
+ = render 'sites/header', site: @site
+ .col
+ %h1= t('.title')
+
+ %table.table
+ %thead
+ %tr
+ - @headers.each do |header|
+ %th{ scope: 'col' }= header
+ %tbody
+ - @table.each do |row|
+ - row[:urls].each do |url|
+ %tr
+ %th{ scope: 'row' }= row[:title]
+ %td= link_to_if url.present?, url, url, class: 'word-break-all'
+ %td
+ %time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
+ %td= row[:size]
diff --git a/app/views/collaborations/collaborate.haml b/app/views/collaborations/collaborate.haml
index 50cad809..4d43ad7e 100644
--- a/app/views/collaborations/collaborate.haml
+++ b/app/views/collaborations/collaborate.haml
@@ -11,7 +11,6 @@
url: site_collaborate_path(@site),
method: :post) do |f|
- unless current_usuarie
- = render 'layouts/flash'
.form-group
= f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email',
diff --git a/app/views/deploy_mailer/deployed.html.haml b/app/views/deploy_mailer/deployed.html.haml
index e8b2e7af..f5afe5de 100644
--- a/app/views/deploy_mailer/deployed.html.haml
+++ b/app/views/deploy_mailer/deployed.html.haml
@@ -1,17 +1,21 @@
-%h1= t('.hi')
+%h1= @hi
-= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname),
- tags: %w[p a strong em]
+= sanitize_markdown @explanation, tags: %w[p a strong em]
%table
%thead
%tr
- %th= t('.th.type')
- %th= t('.th.status')
+ - @headers.each do |header|
+ %th= header
%tbody
- - @deploys.each do |deploy, value|
- %tr
- %td= t(".#{deploy}.title")
- %td= value ? t(".#{deploy}.success") : t(".#{deploy}.error")
+ - @table.each do |row|
+ - row[:urls].each do |url|
+ %tr
+ %td= row[:title]
+ %td= row[:status]
+ %td= link_to_if url.present?, url, url
+ %td
+ %time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
+ %td= row[:size]
-= sanitize_markdown t('.help'), tags: %w[p a strong em]
+= sanitize_markdown @help, tags: %w[p a strong em]
diff --git a/app/views/deploy_mailer/deployed.text.haml b/app/views/deploy_mailer/deployed.text.haml
index 53a9b008..b2d0416f 100644
--- a/app/views/deploy_mailer/deployed.text.haml
+++ b/app/views/deploy_mailer/deployed.text.haml
@@ -1,12 +1,7 @@
-= '# ' + t('.hi')
+= "# #{@hi}"
\
-= t('.explanation', fqdn: @deploy_local.site.hostname)
+= @explanation
\
-= Terminal::Table.new do |table|
- - table << [t('.th.type'), t('.th.status')]
- - table.add_separator
- - @deploys.each do |deploy, value|
- - table << [t(".#{deploy}.title"),
- value ? t(".#{deploy}.success") : t(".#{deploy}.error")]
+= @terminal_table
\
-= t('.help')
+= @help
diff --git a/app/views/deploys/_deploy_distributed_press.haml b/app/views/deploys/_deploy_distributed_press.haml
new file mode 100644
index 00000000..d7d54db0
--- /dev/null
+++ b/app/views/deploys/_deploy_distributed_press.haml
@@ -0,0 +1,21 @@
+-# Publicar a la web distribuida
+
+.row
+ .col
+ = deploy.hidden_field :id
+ = deploy.hidden_field :type
+ .custom-control.custom-switch
+ -#
+ El checkbox invierte la lógica de destrucción porque queremos
+ crear el deploy si está activado y destruirlo si está
+ desactivado.
+ = deploy.check_box :_destroy,
+ { checked: deploy.object.persisted?, class: 'custom-control-input' },
+ '0', '1'
+ = deploy.label :_destroy, class: 'custom-control-label' do
+ %h3= t('.title')
+ = sanitize_markdown t('.help', public_url: deploy.object.site.url),
+ tags: %w[p strong em a]
+
+
+%hr/
diff --git a/app/views/deploys/_deploy_full_rsync.haml b/app/views/deploys/_deploy_full_rsync.haml
new file mode 100644
index 00000000..0aab9802
--- /dev/null
+++ b/app/views/deploys/_deploy_full_rsync.haml
@@ -0,0 +1 @@
+-# nada
diff --git a/app/views/deploys/_deploy_hidden_service.haml b/app/views/deploys/_deploy_hidden_service.haml
index d6388123..9ebda012 100644
--- a/app/views/deploys/_deploy_hidden_service.haml
+++ b/app/views/deploys/_deploy_hidden_service.haml
@@ -17,7 +17,8 @@
= sanitize_markdown t('.help', public_url: deploy.object.site.url),
tags: %w[p strong em a]
- - if deploy.object.fqdn
+ - begin
= sanitize_markdown t('.help_2', url: deploy.object.url),
tags: %w[p strong em a]
+ - rescue ArgumentError
%hr/
diff --git a/app/views/deploys/_deploy_localized_domain.haml b/app/views/deploys/_deploy_localized_domain.haml
new file mode 100644
index 00000000..0aab9802
--- /dev/null
+++ b/app/views/deploys/_deploy_localized_domain.haml
@@ -0,0 +1 @@
+-# nada
diff --git a/app/views/deploys/_deploy_reindex.haml b/app/views/deploys/_deploy_reindex.haml
new file mode 100644
index 00000000..af058968
--- /dev/null
+++ b/app/views/deploys/_deploy_reindex.haml
@@ -0,0 +1 @@
+-# NADA
diff --git a/app/views/devise/confirmations/new.haml b/app/views/devise/confirmations/new.haml
index 59568cb7..bc2f77bb 100644
--- a/app/views/devise/confirmations/new.haml
+++ b/app/views/devise/confirmations/new.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-4.align-self-center
.sr-only
@@ -11,8 +13,6 @@
url: confirmation_path(resource_name),
html: { method: :post }) do |f|
- = render 'devise/shared/error_messages', resource: resource
-
:ruby
value = if resource.pending_reconfirmation?
resource.unconfirmed_email
diff --git a/app/views/devise/invitations/edit.haml b/app/views/devise/invitations/edit.haml
index 565429a8..ed4980ef 100644
--- a/app/views/devise/invitations/edit.haml
+++ b/app/views/devise/invitations/edit.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
%h2= t 'devise.invitations.edit.header'
@@ -8,7 +10,6 @@
as: resource_name,
url: invitation_path(resource_name),
html: { method: :put }) do |f|
- = render 'devise/shared/error_messages', resource: resource
= f.hidden_field :invitation_token, readonly: true
- if f.object.class.require_password_on_accepting
.form-group
diff --git a/app/views/devise/invitations/new.haml b/app/views/devise/invitations/new.haml
index 44ceec2e..4ebb8fa7 100644
--- a/app/views/devise/invitations/new.haml
+++ b/app/views/devise/invitations/new.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
%h2= t 'devise.invitations.new.header'
@@ -8,7 +10,6 @@
as: resource_name,
url: invitation_path(resource_name),
html: { method: :post }) do |f|
- = render 'devise/shared/error_messages', resource: resource
- resource.class.invite_key_fields.each do |field|
.form-group
= f.label field
diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml
index 46706c40..76b10d7f 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.haml
+++ b/app/views/devise/mailer/confirmation_instructions.html.haml
@@ -1,3 +1,3 @@
%p= t('.greeting', recipient: @email)
%p= t('.instruction')
-%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token)
+%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang)
diff --git a/app/views/devise/mailer/confirmation_instructions.text.haml b/app/views/devise/mailer/confirmation_instructions.text.haml
index 38e4c548..7123a738 100644
--- a/app/views/devise/mailer/confirmation_instructions.text.haml
+++ b/app/views/devise/mailer/confirmation_instructions.text.haml
@@ -2,4 +2,4 @@
\
= t('.instruction')
\
-= confirmation_url(@resource, confirmation_token: @token)
+= confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang)
diff --git a/app/views/devise/mailer/invitation_instructions.html.haml b/app/views/devise/mailer/invitation_instructions.html.haml
index 74193878..e87d99d9 100644
--- a/app/views/devise/mailer/invitation_instructions.html.haml
+++ b/app/views/devise/mailer/invitation_instructions.html.haml
@@ -1,4 +1,4 @@
-- site = @resource.sites.last
+- site = @resource.roles.where(temporal: true).last&.site
%p= t('devise.mailer.invitation_instructions.hello',
email: @resource.email)
@@ -8,12 +8,17 @@
%h1= site.title
%p= site.description
-%p= link_to t('devise.mailer.invitation_instructions.accept'),
- accept_invitation_url(@resource, invitation_token: @token)
+- if @resource.needs_invitation_link?
+ %p= link_to t('devise.mailer.invitation_instructions.accept'),
+ accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
-- if @resource.invitation_due_at
- %p= t('devise.mailer.invitation_instructions.accept_until',
- due_date: l(@resource.invitation_due_at,
- format: :'devise.mailer.invitation_instructions.accept_until_format'))
+ - if @resource.invitation_due_at
+ %p= t('devise.mailer.invitation_instructions.accept_until',
+ due_date: l(@resource.invitation_due_at,
+ format: :'devise.mailer.invitation_instructions.accept_until_format'))
-%p= t('devise.mailer.invitation_instructions.ignore')
+ %p= t('devise.mailer.invitation_instructions.ignore')
+- elsif !@resource.confirmed? && @resource.confirmation_token
+ = confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
+- else
+ %p= link_to t('devise.mailer.invitation_instructions.sign_in'), root_url
diff --git a/app/views/devise/mailer/invitation_instructions.text.haml b/app/views/devise/mailer/invitation_instructions.text.haml
index 16a9f0a8..5cb007de 100644
--- a/app/views/devise/mailer/invitation_instructions.text.haml
+++ b/app/views/devise/mailer/invitation_instructions.text.haml
@@ -1,4 +1,4 @@
-- site = @resource.sites.last
+- site = @resource.roles.where(temporal: true).last&.site
= t('devise.mailer.invitation_instructions.hello', email: @resource.email)
\
@@ -9,11 +9,17 @@
\
= site.description
\
-= accept_invitation_url(@resource, invitation_token: @token)
-\
-- if @resource.invitation_due_at
- = t('devise.mailer.invitation_instructions.accept_until',
- due_date: l(@resource.invitation_due_at,
- format: :'devise.mailer.invitation_instructions.accept_until_format'))
-\
-= t('devise.mailer.invitation_instructions.ignore')
+- if @resource.needs_invitation_link?
+ = accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
+ \
+ - if @resource.invitation_due_at
+ = t('devise.mailer.invitation_instructions.accept_until',
+ due_date: l(@resource.invitation_due_at,
+ format: :'devise.mailer.invitation_instructions.accept_until_format'))
+ \
+ = t('devise.mailer.invitation_instructions.ignore')
+- elsif !@resource.confirmed? && @resource.confirmation_token
+ = confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
+- else
+ = root_url(change_locale_to: @resource.lang)
+ = t('devise.mailer.invitation_instructions.sign_in')
diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml
index ccc4aa55..8d8f5919 100644
--- a/app/views/devise/mailer/reset_password_instructions.html.haml
+++ b/app/views/devise/mailer/reset_password_instructions.html.haml
@@ -1,5 +1,5 @@
%p= t('.greeting', recipient: @resource.email)
%p= t('.instruction')
-%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token)
+%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang)
%p= t('.instruction_2')
%p= t('.instruction_3')
diff --git a/app/views/devise/mailer/reset_password_instructions.text.haml b/app/views/devise/mailer/reset_password_instructions.text.haml
index 3d0fe64d..923c2a0c 100644
--- a/app/views/devise/mailer/reset_password_instructions.text.haml
+++ b/app/views/devise/mailer/reset_password_instructions.text.haml
@@ -2,7 +2,7 @@
\
= t('.instruction')
\
-= edit_password_url(@resource, reset_password_token: @token)
+= edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang)
\
= t('.instruction_2')
\
diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml
index d68bf7c7..9f8cd492 100644
--- a/app/views/devise/mailer/unlock_instructions.html.haml
+++ b/app/views/devise/mailer/unlock_instructions.html.haml
@@ -1,4 +1,4 @@
%p= t('.greeting', recipient: @resource.email)
%p= t('.message')
%p= t('.instruction')
-%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token)
+%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang)
diff --git a/app/views/devise/mailer/unlock_instructions.text.haml b/app/views/devise/mailer/unlock_instructions.text.haml
index cf06927b..950e04b7 100644
--- a/app/views/devise/mailer/unlock_instructions.text.haml
+++ b/app/views/devise/mailer/unlock_instructions.text.haml
@@ -4,4 +4,4 @@
\
= t('.instruction')
\
-= unlock_url(@resource, unlock_token: @token)
+= unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang)
diff --git a/app/views/devise/passwords/edit.haml b/app/views/devise/passwords/edit.haml
index 7f7b16fb..3a8843c0 100644
--- a/app/views/devise/passwords/edit.haml
+++ b/app/views/devise/passwords/edit.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.sr-only
@@ -10,7 +12,6 @@
= form_for(resource, as: resource_name,
url: password_path(resource_name),
html: { method: :put }) do |f|
- = render 'devise/shared/error_messages', resource: resource
= f.hidden_field :reset_password_token
diff --git a/app/views/devise/passwords/new.haml b/app/views/devise/passwords/new.haml
index 3c80b8a0..08dd8d2e 100644
--- a/app/views/devise/passwords/new.haml
+++ b/app/views/devise/passwords/new.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.sr-only
@@ -11,7 +13,6 @@
as: resource_name,
url: password_path(resource_name),
html: { method: :post }) do |f|
- = render 'devise/shared/error_messages', resource: resource
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml
index 6a25da65..92699ab8 100644
--- a/app/views/devise/registrations/edit.haml
+++ b/app/views/devise/registrations/edit.haml
@@ -3,6 +3,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center
%h2= t('.title')
@@ -11,8 +13,6 @@
url: registration_path(resource_name),
html: { method: :put }) do |f|
- = render 'devise/shared/error_messages', resource: resource
-
.form-group
= f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email',
diff --git a/app/views/devise/registrations/new.haml b/app/views/devise/registrations/new.haml
index cb6ff0d1..26fc8e18 100644
--- a/app/views/devise/registrations/new.haml
+++ b/app/views/devise/registrations/new.haml
@@ -1,16 +1,16 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-md-6.align-self-center
%h2= t('.sign_up')
%p= t('.help')
= form_for(resource,
as: resource_name,
- url: registration_path(resource_name)) do |f|
-
- = render 'devise/shared/error_messages', resource: resource
+ url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
.form-group
= f.label :email, class: 'sr-only'
@@ -39,6 +39,21 @@
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t("#{password}_confirmation")
+
+ .form-group
+ - Usuarie::CONSENT_FIELDS.each do |field|
+ - required = t(".#{field}.required", default: '').present?
+ - id = "usuarie_#{field}"
+ - name = "usuarie[#{field}]"
+ - content = t(".#{field}.label")
+ - href = t(".#{field}.href", default: '')
+ - help_content = t(".#{field}.help")
+ = render 'bootstrap/custom_checkbox', id: id, name: name, content: content, required: required, value: "1" do
+ - if href.present?
+ = link_to help_content, href, target: '_blank', rel: 'noopener'
+ - else
+ = help_content
+
.actions
= f.submit t('.sign_up'),
class: 'btn btn-lg btn-block'
diff --git a/app/views/devise/sessions/new.haml b/app/views/devise/sessions/new.haml
index b5223e5f..9b396187 100644
--- a/app/views/devise/sessions/new.haml
+++ b/app/views/devise/sessions/new.haml
@@ -3,8 +3,6 @@
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
- = render 'layouts/flash'
-
.sr-only
%h2= t('.sign_in')
%p= t('.help')
diff --git a/app/views/devise/shared/_error_messages.haml b/app/views/devise/shared/_error_messages.haml
index a921fd61..64340e4f 100644
--- a/app/views/devise/shared/_error_messages.haml
+++ b/app/views/devise/shared/_error_messages.haml
@@ -1,9 +1,4 @@
- if resource.errors.any?
- #error_explanation
- %h2
- = I18n.t("errors.messages.not_saved", |
- count: resource.errors.count, |
- resource: resource.class.model_name.human.downcase) |
- %ul
- - resource.errors.full_messages.each do |message|
- %li= message
+ = render 'bootstrap/alert' do
+ - resource.errors.full_messages.each do |message|
+ %p= message
diff --git a/app/views/devise/shared/_links.haml b/app/views/devise/shared/_links.haml
index c182d323..b4b89175 100644
--- a/app/views/devise/shared/_links.haml
+++ b/app/views/devise/shared/_links.haml
@@ -1,35 +1,38 @@
%hr/
+- locale = params.permit(:locale)
+
- if controller_name != 'sessions'
- = link_to t('.sign_in'), new_session_path(resource_name)
+ = link_to t('.sign_in'), new_session_path(resource_name, params: locale),
+ class: 'btn btn-lg btn-block btn-success'
%br/
- if devise_mapping.registerable? && controller_name != 'registrations'
- = link_to t('.sign_up'), new_registration_path(resource_name),
+ = link_to t('.sign_up'), new_registration_path(resource_name, params: locale),
class: 'btn btn-lg btn-block btn-success'
%br/
- if devise_mapping.recoverable?
- unless %w[passwords registrations].include?(controller_name)
= link_to t('.forgot_your_password'),
- new_password_path(resource_name)
+ new_password_path(resource_name, params: locale)
%br/
- if devise_mapping.confirmable? && controller_name != 'confirmations'
= link_to t('.didn_t_receive_confirmation_instructions'),
- new_confirmation_path(resource_name)
+ new_confirmation_path(resource_name, params: locale)
%br/
- if devise_mapping.lockable?
- if resource_class.unlock_strategy_enabled?(:email)
- if controller_name != 'unlocks'
= link_to t('.didn_t_receive_unlock_instructions'),
- new_unlock_path(resource_name)
+ new_unlock_path(resource_name, params: locale)
%br/
- if devise_mapping.omniauthable?
- resource_class.omniauth_providers.each do |provider|
= link_to t('.sign_in_with_provider',
provider: OmniAuth::Utils.camelize(provider)),
- omniauth_authorize_path(resource_name, provider)
+ omniauth_authorize_path(resource_name, provider, params: locale)
%br/
diff --git a/app/views/devise/unlocks/new.haml b/app/views/devise/unlocks/new.haml
index ac511115..09468a52 100644
--- a/app/views/devise/unlocks/new.haml
+++ b/app/views/devise/unlocks/new.haml
@@ -1,6 +1,8 @@
= content_for :body do
- 'black-bg'
+= render 'devise/shared/error_messages', resource: resource
+
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.sr-only
@@ -11,7 +13,6 @@
as: resource_name,
url: unlock_path(resource_name),
html: { method: :post }) do |f|
- = render 'devise/shared/error_messages', resource: resource
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
diff --git a/app/views/invitadxs/show.haml b/app/views/invitadxs/show.haml
index e1d47288..0c23522b 100644
--- a/app/views/invitadxs/show.haml
+++ b/app/views/invitadxs/show.haml
@@ -1,4 +1,4 @@
.row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center
- .alert{role: 'alert', class: "alert-success"}
+ = render 'bootstrap/alert' do
= t('.confirmation_sent')
diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml
index dc0e3158..11f7f005 100644
--- a/app/views/layouts/_breadcrumb.haml
+++ b/app/views/layouts/_breadcrumb.haml
@@ -19,6 +19,15 @@
= link_to t('.tienda'), @site.tienda_url,
role: 'button', class: 'btn'
+ %li.nav-item
+ = link_to t('.contact_us'), t('.contact_us_href'),
+ class: 'btn', rel: 'me', target: '_blank'
+
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn'
+ - else
+ - params.permit!
+ - I18n.available_locales.each do |locale|
+ - next if locale == I18n.locale
+ = link_to t("switch_locale.#{locale}"), params.to_h.merge(change_locale_to: locale)
diff --git a/app/views/layouts/_flash.haml b/app/views/layouts/_flash.haml
index 149f946f..7bd7ec0b 100644
--- a/app/views/layouts/_flash.haml
+++ b/app/views/layouts/_flash.haml
@@ -1,3 +1,4 @@
- flash.each do |type, message|
- unless type == 'js'
- .alert{ role: 'alert', class: "alert-#{type}" }= message
+ = render 'bootstrap/alert' do
+ = message
diff --git a/app/views/layouts/_help.haml b/app/views/layouts/_help.haml
index 7a821e2d..6800b524 100644
--- a/app/views/layouts/_help.haml
+++ b/app/views/layouts/_help.haml
@@ -1,3 +1,4 @@
+-# DEPRECADO
.alert.alert-info.alert-dismissible.fade.show{role: 'alert'}
- if help.respond_to? :each
%ul
diff --git a/app/views/layouts/_link_rel_alternate.haml b/app/views/layouts/_link_rel_alternate.haml
new file mode 100644
index 00000000..64a70977
--- /dev/null
+++ b/app/views/layouts/_link_rel_alternate.haml
@@ -0,0 +1,7 @@
+- unless current_usuarie
+ - params.permit!
+ - I18n.available_locales.each do |locale|
+ - url = url_for(**params.to_h.merge(change_locale_to: locale), only_path: false)
+ - if locale == I18n.default_locale
+ %link{ rel: 'alternate', hreflang: 'x-default', href: url }
+ %link{ rel: 'alternate', hreflang: locale, href: url }
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 85d5ab22..d2113398 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -4,7 +4,7 @@
%meta{ charset: 'UTF-8' }/
%meta{ content: 'text/html; charset=UTF-8',
'http-equiv': 'Content-Type' }/
- %meta{ name: 'color-scheme', content: 'light dark' }/
+ %meta{ name: 'color-scheme', content: 'light' }/
%meta{ name: 'viewport',
content: 'width=device-width, initial-scale=1.0' }/
%meta{ name: 'referrer', content: 'same-origin' }/
@@ -17,10 +17,14 @@
= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'
= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload'
= favicon_link_tag 'sutty_cuadrada.png', rel: 'apple-touch-icon', type: 'image/png'
+ = render 'layouts/link_rel_alternate'
%body{ class: yield(:body) }
.container-fluid#sutty
= render 'layouts/breadcrumb'
+ = render 'layouts/flash'
+
= yield
+
- if flash[:js]
.js-flash.d-none{ data: flash[:js] }
diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml
index e46b2eda..7de0ea79 100644
--- a/app/views/posts/_form.haml
+++ b/app/views/posts/_form.haml
@@ -1,7 +1,9 @@
- unless post.errors.empty?
- .alert.alert-danger
- %h4= t('.errors.title')
- %p= t('.errors.help')
+ - title = t('.errors.title')
+ - help = t('.errors.help')
+ = render 'bootstrap/alert' do
+ %h4= title
+ %p= help
%ul
- post.errors.each do |attribute, errors|
diff --git a/app/views/posts/_submit.haml b/app/views/posts/_submit.haml
index b21b5ff2..944694c1 100644
--- a/app/views/posts/_submit.haml
+++ b/app/views/posts/_submit.haml
@@ -1,6 +1,8 @@
+- invalid_help = site.config.fetch('invalid_help', t('.invalid_help'))
+- sending_help = site.config.fetch('sending_help', t('.sending_help'))
.form-group
= submit_tag t('.save'), class: 'btn submit-post'
- .invalid-help.alert.alert-danger.d-none
- = site.config.fetch('invalid_help', t('.invalid_help'))
- .sending-help.alert.alert-success.d-none
- = site.config.fetch('sending_help', t('.sending_help'))
+ = render 'bootstrap/alert', class: 'invalid-help d-none' do
+ = invalid_help
+ = render 'bootstrap/alert', class: 'sending-help d-none' do
+ = sending_help
diff --git a/app/views/posts/attribute_ro/_locales.haml b/app/views/posts/attribute_ro/_locales.haml
index 3ac22933..16ecb532 100644
--- a/app/views/posts/attribute_ro/_locales.haml
+++ b/app/views/posts/attribute_ro/_locales.haml
@@ -1,9 +1,10 @@
-%tr{ id: attribute }
- %th= post_label_t(attribute, post: post)
- %td
- %ul
- - metadata.value.each do |uuid|
- - p = site.docs.find(uuid, uuid: true)
- %li{ dir: t("locales.#{p.lang.value}.dir"), lang: p.lang.value }
- = link_to p.title.value,
- site_post_path(site, p.id, locale: p.lang.value)
+- if site.locales.count > 1
+ %tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ %ul
+ - metadata.value.each do |uuid|
+ - p = site.docs.find(uuid, uuid: true)
+ %li{ dir: t("locales.#{p.lang.value}.dir"), lang: p.lang.value }
+ = link_to p.title.value,
+ site_post_path(site, p.id, locale: p.lang.value)
diff --git a/app/views/posts/attribute_ro/_non_geo.haml b/app/views/posts/attribute_ro/_non_geo.haml
new file mode 100644
index 00000000..75f8d2ef
--- /dev/null
+++ b/app/views/posts/attribute_ro/_non_geo.haml
@@ -0,0 +1,6 @@
+- lat = metadata.value['lat']
+- lng = metadata.value['lng']
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ = "#{lat},#{lng}"
diff --git a/app/views/posts/attribute_ro/_password.haml b/app/views/posts/attribute_ro/_password.haml
new file mode 100644
index 00000000..e55b021f
--- /dev/null
+++ b/app/views/posts/attribute_ro/_password.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }
+ = metadata.value
+ %br/
+ %small= t('.safety')
diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml
index 9a5069c9..03867941 100644
--- a/app/views/posts/attributes/_content.haml
+++ b/app/views/posts/attributes/_content.haml
@@ -6,7 +6,7 @@
.old.editor{ id: attribute, data: { editor: '' } }
-# Esto es para luego decirle al navegador que se olvide estas cosas.
= hidden_field_tag 'storage_keys[]', "#{request.original_url}##{attribute}", data: { target: 'storage-key' }
- .alert.alert-info
+ = render 'bootstrap/alert' do
:markdown
#{t('editor.alert')}
= text_area_tag "#{base}[#{attribute}]", '',
@@ -123,7 +123,7 @@
%label{ for: 'link-url' }= t('editor.url')
%input.form-control{ type: 'url', id: 'link-url', name: 'link-url' }/
- .editor-aviso-word.alert.alert-info
+ = render 'bootstrap/alert', class: 'editor-aviso-word' do
%p= t('editor.word')
.editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' }
diff --git a/app/views/posts/attributes/_geo.haml b/app/views/posts/attributes/_geo.haml
index d048565e..dee4707e 100644
--- a/app/views/posts/attributes/_geo.haml
+++ b/app/views/posts/attributes/_geo.haml
@@ -1,4 +1,8 @@
.row{ data: { controller: 'geo' } }
+ .col-12.mb-3
+ %p.mb-0= post_label_t(attribute, post: post)
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
.col
.form-group
= label_tag "#{base}_#{attribute}_lat",
diff --git a/app/views/posts/attributes/_image.haml b/app/views/posts/attributes/_image.haml
index f4d9bb3d..84fe56fd 100644
--- a/app/views/posts/attributes/_image.haml
+++ b/app/views/posts/attributes/_image.haml
@@ -22,7 +22,7 @@
= file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
- accept: 'image/*', data: { preview: "#{attribute}-preview" })
+ accept: ActiveStorage.web_image_content_types.join(','), data: { preview: "#{attribute}-preview" })
= label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback',
diff --git a/app/views/posts/attributes/_locales.haml b/app/views/posts/attributes/_locales.haml
index 8dd7adf6..4978f6b4 100644
--- a/app/views/posts/attributes/_locales.haml
+++ b/app/views/posts/attributes/_locales.haml
@@ -1,39 +1,19 @@
--#
-
- Crea un input-map para cada idioma por separado. Podríamos hacer uno
- solo que tenga todos los idiomas pero puede ser una interfaz confusa.
-
- TODO: Esto permite seleccionar más de una traducción por idioma...
-
-- site.locales.each do |locale|
- -# Ignorar el idioma actual
- - next if post.lang.value == locale
- - locale_t = t("locales.#{locale}.name")
- - values = metadata.value.select do |x|
- - metadata.values[locale].values.include? x
-
- .form-group
- = label_tag "#{base}_#{attribute}_#{locale}", locale_t
-
- .mapable{ dir: t("locales.#{locale}.dir"), lang: locale,
- data: { values: values.to_json,
- 'default-values': metadata.values[locale].to_json,
- name: "#{base}[#{attribute}][]",
- list: id_for_datalist(attribute, locale),
- button: t('posts.attributes.add'),
- remove: 'false', legend: locale_t,
- described: id_for_help(attribute, locale) } }
-
- = text_field(*field_name_for(base, attribute, '[]'),
- value: values.join(', '),
- dir: t("locales.#{locale}.dir"), lang: locale,
- **field_options(attribute, metadata))
+- if site.locales.count > 1
+ %fieldset
+ %legend= post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
- post: post,
- attribute: [attribute, 'mapable'].flatten,
- metadata: metadata
+ post: post, attribute: attribute, metadata: metadata
- %datalist{ id: id_for_datalist(attribute, locale) }
- - metadata.values[locale].keys.each do |value|
- %option{ value: value }
+ - site.locales.each do |locale|
+ - next if post.lang.value == locale
+ - locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize)
+ - value = metadata.value.find do |v|
+ - metadata.values[locale].values.include? v
+
+ .form-group
+ = label_tag "#{base}_#{attribute}_#{locale}", locale_t
+
+ = select_tag("#{plain_field_name_for(base, attribute)}[]",
+ options_for_select(metadata.values[locale], value),
+ **field_options(attribute, metadata), include_blank: t('.empty'))
diff --git a/app/views/posts/attributes/_non_geo.haml b/app/views/posts/attributes/_non_geo.haml
new file mode 100644
index 00000000..3f6a75a6
--- /dev/null
+++ b/app/views/posts/attributes/_non_geo.haml
@@ -0,0 +1,29 @@
+.row{ data: { controller: 'non-geo', site: site.url } }
+ .d-none{ hidden: true, data: { target: 'non-geo.overlay' }}
+ .col-12.mb-3
+ %p.mb-0= post_label_t(attribute, post: post)
+ %p= post_label_t(attribute, post: post)
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
+ .col
+ .form-group
+ = label_tag "#{base}_#{attribute}_lat",
+ post_label_t(attribute, :lat, post: post)
+ = text_field(*field_name_for(base, attribute, :lat),
+ value: metadata.value['lat'],
+ **field_options(attribute, metadata),
+ data: { target: 'non-geo.lat' })
+ = render 'posts/attribute_feedback',
+ post: post, attribute: [attribute, :lat], metadata: metadata
+ .col
+ .form-group
+ = label_tag "#{base}_#{attribute}_lng",
+ post_label_t(attribute, :lng, post: post)
+ = text_field(*field_name_for(base, attribute, :lng),
+ value: metadata.value['lng'],
+ **field_options(attribute, metadata),
+ data: { target: 'non-geo.lng' })
+ = render 'posts/attribute_feedback',
+ post: post, attribute: [attribute, :lng], metadata: metadata
+ .col-12.mb-3
+ %div{ data: { target: 'non-geo.map' }, style: 'height: 250px' }
diff --git a/app/views/posts/attributes/_password.haml b/app/views/posts/attributes/_password.haml
new file mode 100644
index 00000000..0aace30f
--- /dev/null
+++ b/app/views/posts/attributes/_password.haml
@@ -0,0 +1,7 @@
+.form-group
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
+ = password_field base, attribute, value: metadata.value,
+ dir: dir, lang: locale,
+ **field_options(attribute, metadata)
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml
index bc5c826c..e8c3dea7 100644
--- a/app/views/posts/index.haml
+++ b/app/views/posts/index.haml
@@ -1,19 +1,18 @@
%main.row
%aside.menu.col-md-3
- %h1= link_to @site.title, @site.url
- %p.lead= @site.description
+ = render 'sites/header', site: @site
+
+ = render 'sites/status', site: @site
+
+ = render 'sites/build', site: @site, class: 'btn-block'
%h3= t('posts.new')
- %table.mb-3
- - @site.layouts.each do |layout|
- - next if layout.hidden?
- %tr
- %th= layout.humanized_name
- %td.pl-3= link_to t('posts.add'), new_site_post_path(@site, layout: layout.value), class: 'btn btn-secondary btn-sm'
- - if @filter_params[:layout] == layout.name.to_s
- %td= link_to t('posts.remove_filter'), site_posts_path(@site, **@filter_params.merge(layout: nil)), class: 'btn btn-primary btn-sm'
- - else
- %td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
+ %table.table.table-sm.mb-3
+ %tbody
+ - @site.schema_organization.each do |schema, _|
+ - schema = @site.layouts[schema]
+ - next if schema.hidden?
+ = render 'schemas/row', site: @site, schema: schema, filter: @filter_params
- if policy(@site_stat).index?
= link_to t('stats.index.title'), site_stats_path(@site), class: 'btn'
@@ -31,24 +30,22 @@
type: 'info',
link: site_usuaries_path(@site)
- = render 'sites/build', site: @site
-
- if @site.design.credits
- .alert.alert-primary{ role: 'alert' }
+ = render 'bootstrap/alert' do
= sanitize_markdown @site.design.credits
= link_to t('sites.donations.text'), t('sites.donations.url'), class: 'btn'
- if @site.design.designer_url
= link_to t('sites.designer_url'), @site.design.designer_url, class: 'btn'
%section.col
- = render 'layouts/flash'
.d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2
%form{ action: site_posts_path }
- @filter_params.each do |param, value|
- next if param == 'q'
%input{ type: 'hidden', name: param, value: value }
.form-group.flex-grow-0.m-0
- %input.form-control.border.border-magenta{ type: 'search', placeholder: 'Buscar', name: 'q', value: @filter_params[:q] }
+ %label.sr-only{for: 'q'}= t('.search')
+ %input#q.form-control.border.border-magenta{ type: 'search', placeholder: t('.search'), name: 'q', value: @filter_params[:q] }
%input.sr-only{ type: 'submit' }
- if @site.locales.size > 1
@@ -89,22 +86,22 @@
%div
%tbody
- - dir = t("locales.#{@locale}.dir")
+ - dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size
- @posts.each_with_index do |post, i|
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
- cache_if @usuarie, [post, I18n.locale] do
- - checkbox_id = "checkbox-#{post.id}"
- %tr{ id: post.id, data: { target: 'reorder.row' } }
+ - checkbox_id = "checkbox-#{post.post_id}"
+ %tr{ id: post.post_id, data: { target: 'reorder.row' } }
%td
.custom-control.custom-checkbox
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
%label.custom-control-label{ for: checkbox_id }
%span.sr-only= t('posts.reorder.select')
-# Orden más alto es mayor prioridad
- = hidden_field 'post[reorder]', post.id,
+ = hidden_field 'post[reorder]', post.post_id,
value: size - i,
data: { reorder: true }
%td.w-100{ class: dir }
diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml
index c88905dc..068a6adf 100644
--- a/app/views/posts/show.haml
+++ b/app/views/posts/show.haml
@@ -1,4 +1,4 @@
-- dir = t("locales.#{@locale}.dir")
+- dir = @site.data.dig(params[:locale], 'dir')
.row.justify-content-center
.col-md-8
%article.content.table-responsive-md
@@ -6,13 +6,6 @@
edit_site_post_path(@site, @post.id),
class: 'btn btn-block'
- - unless @post.layout.ignored?
- = link_to t('posts.preview.btn'),
- site_post_preview_path(@site, @post.id),
- class: 'btn btn-block',
- target: '_blank',
- rel: 'noopener'
-
%table.table.table-condensed
%thead
%tr
diff --git a/app/views/schemas/_add.haml b/app/views/schemas/_add.haml
new file mode 100644
index 00000000..0131a6bb
--- /dev/null
+++ b/app/views/schemas/_add.haml
@@ -0,0 +1 @@
+= link_to t('.add'), new_site_post_path(site, layout: schema.value), class: 'btn btn-secondary btn-sm m-0'
diff --git a/app/views/schemas/_filter.haml b/app/views/schemas/_filter.haml
new file mode 100644
index 00000000..c422c5b8
--- /dev/null
+++ b/app/views/schemas/_filter.haml
@@ -0,0 +1,4 @@
+- if filter[:layout] == schema.name.to_s
+ = link_to t('.remove'), site_posts_path(site, **filter.merge(layout: nil)), class: 'btn btn-primary btn-sm m-0'
+- else
+ = link_to t('.filter'), site_posts_path(site, **filter.merge(layout: schema.value)), class: 'btn btn-secondary btn-sm m-0'
diff --git a/app/views/schemas/_row.haml b/app/views/schemas/_row.haml
new file mode 100644
index 00000000..1d1fca87
--- /dev/null
+++ b/app/views/schemas/_row.haml
@@ -0,0 +1,13 @@
+%tr
+ %th.w-100{ scope: 'row' }
+ - if local_assigns[:parent_schema]
+ %span.text-muted —
+ = schema.humanized_name
+ %td.px-0.text-nowrap
+ = render 'schemas/add', **local_assigns
+ = render 'schemas/filter', **local_assigns
+
+-# XXX: Solo un nivel de recursividad
+- unless local_assigns[:parent_schema]
+ - schema.schemas.each do |s|
+ = render 'schemas/row', schema: s, site: site, filter: filter, parent_schema: schema
diff --git a/app/views/sites/_build.haml b/app/views/sites/_build.haml
index 6bc4d11b..5911e908 100644
--- a/app/views/sites/_build.haml
+++ b/app/views/sites/_build.haml
@@ -3,7 +3,7 @@
method: :post,
class: 'form-inline inline' do
= submit_tag site.enqueued? ? t('sites.enqueued') : t('sites.enqueue'),
- class: 'btn no-border-radius',
+ class: "btn no-border-radius #{local_assigns[:class]}",
title: site.enqueued? ? t('help.sites.enqueued') : t('help.sites.enqueue'),
data: { disable_with: t('sites.enqueued') },
disabled: site.enqueued?
diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml
index 6f15d570..69997ffa 100644
--- a/app/views/sites/_form.haml
+++ b/app/views/sites/_form.haml
@@ -1,7 +1,9 @@
- unless site.errors.empty?
- .alert.alert-info
- %h4= t('.errors.title')
- %p.lead= t('.errors.help')
+ - title = t('.errors.title')
+ - help = t('.errors.help')
+ = render 'bootstrap/alert' do
+ %h4= title
+ %p.lead= help
%ul
- site.errors.messages.each_pair do |attr, error|
- attr = attr.to_s
@@ -48,13 +50,13 @@
%h2= t('.design.title')
%p.lead= t('.help.design')
- if invalid? site, :design_id
- .alert.alert-info
+ = render 'bootstrap/alert' do
= t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help',
layouts: site.incompatible_layouts.to_sentence)
- .row.designs
+ .row.row-cols-1.row-cols-md-2.designs
-# Demasiado complejo para un f.collection_radio_buttons
- - Design.all.find_each do |design|
- .design.col-md-4.d-flex.flex-column
+ - Design.all.order(priority: :desc).each do |design|
+ .design.col.d-flex.flex-column
.custom-control.custom-radio
= f.radio_button :design_id, design.id,
checked: design.id == site.design_id,
@@ -79,10 +81,12 @@
%h2= t('.licencia.title')
%p.lead= t('.help.licencia')
- Licencia.all.find_each do |licencia|
+ - next if licencia.custom? && site.licencia != licencia
.row.license
.col
.media.mt-1
- = image_tag licencia.icons, alt: licencia.name, class: 'mr-3 mt-4'
+ - unless licencia.custom?
+ = image_tag licencia.icons, alt: licencia.name, class: 'mr-3 mt-4'
.media-body
.custom-control.custom-radio
= f.radio_button :licencia_id, licencia.id,
@@ -93,8 +97,8 @@
= sanitize_markdown licencia.description,
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
- = link_to t('.licencia.url'), licencia.url,
- target: '_blank', class: 'btn'
+ - unless licencia.custom?
+ = link_to t('.licencia.url'), licencia.url, target: '_blank', class: 'btn', rel: 'noopener'
%hr/
@@ -104,27 +108,27 @@
%hr/
- .form-group#tienda
- %h2= t('.tienda.title')
- %p.lead
- - if site.tienda?
- = t('.tienda.help')
- - else
- = t('.tienda.first_time_html')
-
- .row
- .col
- .form-group
- = f.label :tienda_url
- = f.url_field :tienda_url, class: 'form-control'
- .col
- .form-group
- = f.label :tienda_api_key
- = f.text_field :tienda_api_key, class: 'form-control'
-
- %hr/
-
- if site.persisted?
+ .form-group#tienda
+ %h2= t('.tienda.title')
+ %p.lead
+ - if site.tienda?
+ = t('.tienda.help')
+ - else
+ = t('.tienda.first_time_html')
+
+ .row
+ .col
+ .form-group
+ = f.label :tienda_url
+ = f.url_field :tienda_url, class: 'form-control'
+ .col
+ .form-group
+ = f.label :tienda_api_key
+ = f.text_field :tienda_api_key, class: 'form-control'
+
+ %hr/
+
.form-group#contact
%h2= t('.contact.title')
%p.lead= t('.contact.help')
@@ -156,9 +160,6 @@
= f.fields_for :deploys do |deploy|
= render "deploys/#{deploy.object.type.underscore}",
deploy: deploy, site: site
- - else
- = f.fields_for :deploys do |deploy|
- = deploy.hidden_field :type
.form-group
= f.submit submit, class: 'btn btn-lg btn-block'
diff --git a/app/views/sites/_header.haml b/app/views/sites/_header.haml
new file mode 100644
index 00000000..c8931041
--- /dev/null
+++ b/app/views/sites/_header.haml
@@ -0,0 +1,3 @@
+.hyphens{ lang: site.default_locale }
+ %h1= site.title
+ %p.lead= site.description
diff --git a/app/views/sites/_status.haml b/app/views/sites/_status.haml
new file mode 100644
index 00000000..6a610e73
--- /dev/null
+++ b/app/views/sites/_status.haml
@@ -0,0 +1,21 @@
+- link = nil
+- if site.not_published_yet?
+ - message = t('.not_published_yet')
+- elsif site.awaiting_publication?
+ - message = t('.awaiting_publication')
+- elsif site.building?
+ - if site.average_publication_time_calculable?
+ - average_building_time = site.average_publication_time
+ - elsif !site.similar_sites?
+ - average_building_time = 60
+ - else
+ - average_building_time = site.average_publication_time_for_similar_sites
+
+ - average_publication_time_human = distance_of_time_in_words average_building_time
+ - message = t('.building', average_time: average_publication_time_human, seconds: average_building_time)
+- else
+ - message = t('.available')
+ - link = true
+
+= render 'bootstrap/alert' do
+ = link_to_if link, message.html_safe, site_build_stats_path(site), class: 'alert-link'
diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml
index d69dbeac..56178775 100644
--- a/app/views/sites/index.haml
+++ b/app/views/sites/index.haml
@@ -14,7 +14,7 @@
%table.table.table-condensed
%tbody
- @sites.each do |site|
- - next unless site.jekyll
+ - next unless site.jekyll?
- rol = current_usuarie.rol_for_site(site)
-#
TODO: Solo les usuaries cachean porque tenemos que separar
diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml
index 49fbd023..88e86aa3 100644
--- a/app/views/stats/index.haml
+++ b/app/views/stats/index.haml
@@ -27,6 +27,8 @@
%p.lead= t('.urls.description')
%form{ method: 'get', action: '#custom-urls' }
%input{ type: 'hidden', name: 'interval', value: @interval }
+ %input{ type: 'hidden', name: 'period_start', value: params[:period_start] }
+ %input{ type: 'hidden', name: 'period_end', value: params[:period_end] }
.form-group
%label{ for: 'urls' }= t('.urls.label')
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size + 1, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
diff --git a/config/application.rb b/config/application.rb
index 97ab244c..941caa68 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -37,6 +37,7 @@ module Sutty
.rescue_responses['Pundit::NotAuthorizedError'] = :forbidden
config.active_storage.variant_processor = :vips
+ config.active_storage.web_image_content_types << 'image/webp'
config.to_prepare do
# Load application's model / class decorators
diff --git a/config/credentials.yml.enc.ci b/config/credentials.yml.enc.ci
new file mode 100644
index 00000000..4add450d
--- /dev/null
+++ b/config/credentials.yml.enc.ci
@@ -0,0 +1 @@
+1jEfzfldP9tT4+HWfhP48I9hw31gYCnnxHWpYjPrcTm/pgkFdiG+mDa6y31EOxzs50w6FEw2GO127BnyBSUIPIxuWY0cR96xL5pVrS3vjyzM84QN4lJF9ER0Tz1AQ9S7NJ54CelSkMfFt/rf+O4YM8cLtdSVsVC/HlGbp16p3D1pm4MFo5cQb0hEmlyyYlzEn4oJtsp/MCIwI4+z8oFhxKdMIxdbiw+KS/7PBRfMm1h5rdGORCnD69iVmnXseMvVtZn9A7N7uR6+gFlhxlD5yyEW0pwTj3tbu9NeIOVbtmYOL5ZhLW9REXtGTqR5Op/LN+ukIXbDNEScKltJXUdWfa9Pd/QjVT8IMURZ04POEMDgs1cw363yz4f+WQForhSco9oYLDOd5hTGRXoZ9fnjnfJSTjINM62hkfDY3w3+s844nNbjbj+lPTJHU/QjRhcuNqBDDxWUfwTmRIqm5zrelnHnZnuFmFwCNet6NChC6EFUAFjrals6kTSQllyMt4xImqA+HL7DnjWj6VURSH+nGQTA4tQvDdfbDwTzg/PvRkJcsy2dRd135RQdmRZ+8KXBviLabwdR256vaCqSO1j+jyeUPGLll35ghyLxncyBkkAKt1zaDRPDWgVafg0gJ3v7hVV5TYgToPzlv4w88KPCY7cBhkb1qGoXAhtO6iAuZYK9eyZd1gNQJKyqbcLqA5aTTX/ylfdbptWhaZ8ibB8KBgVyn2RmrOHEhB38rDSMHHNfK3Xs4/hhqMFIGHGTGCUYVmjCzhVFd15yRurU32d3YtP8W4L77H7qkFsF1gnvsZx+R084LcJqknwY94dmjtUE4x2u+Qh3ElFj--lr8JoUq1WH9xXNsB--mE8hxHADL7SbDWabAPY1+Q==
\ No newline at end of file
diff --git a/config/environments/production.rb b/config/environments/production.rb
index d121bdbd..653ca8aa 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -147,7 +147,7 @@ Rails.application.configure do
}
config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', "noreply@sutty.nl") }
- config.middleware.use ExceptionNotification::Rack, gitlab: {}
+ config.middleware.use ExceptionNotification::Rack, gitlab: {}, ignore_exceptions: (['DeployJob::DeployAlreadyRunningException'] + ExceptionNotifier.ignored_exceptions)
Rails.application.routes.default_url_options[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}"
Rails.application.routes.default_url_options[:protocol] = 'https'
diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb
index 66d2c92b..1516a43a 100644
--- a/config/initializers/core_extensions.rb
+++ b/config/initializers/core_extensions.rb
@@ -37,6 +37,13 @@ end
#
# TODO: Aplicar monkey patches en otro lado...
module Jekyll
+ Site.class_eval do
+ def configure_theme
+ self.theme = nil
+ self.theme = Jekyll::Theme.new(config['theme'], self) unless config['theme'].nil?
+ end
+ end
+
Reader.class_eval do
# No necesitamos otros posts
def retrieve_posts(_); end
@@ -69,6 +76,46 @@ module Jekyll
end
end
+ Theme.class_eval do
+ attr_reader :site
+
+ def initialize(name, site)
+ @name = name.downcase.strip
+ @site = site
+ end
+
+ def root
+ @root ||= begin
+ lockfile = Bundler::LockfileParser.new(File.read(site.in_source_dir('Gemfile.lock')))
+ spec = lockfile.specs.find do |spec|
+ spec.name == name
+ end
+
+ ruby_version = Gem::Version.new(RUBY_VERSION)
+ ruby_version.canonical_segments[2] = 0
+ base_path = Rails.root.join('_storage', 'gems', File.basename(site.source), 'ruby',
+ ruby_version.canonical_segments.join('.'))
+
+ File.realpath(
+ case spec.source
+ when Bundler::Source::Git
+ File.join(base_path, 'bundler', 'gems', spec.source.extension_dir_name)
+ when Bundler::Source::Rubygems
+ File.join(base_path, 'gems', spec.full_name)
+ end
+ )
+ end
+ end
+
+ def runtime_dependencies
+ []
+ end
+
+ private
+
+ def gemspec; end
+ end
+
# No necesitamos los archivos de la plantilla
ThemeAssetsReader.class_eval do
def read; end
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index 0e18b987..6002ee65 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -13,6 +13,10 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.singular 'roles', 'rol'
inflect.plural 'rollup', 'rollups'
inflect.singular 'rollups', 'rollup'
+ inflect.plural 'code_of_conduct', 'codes_of_conduct'
+ inflect.singular 'codes_of_conduct', 'code_of_conduct'
+ inflect.plural 'privacy_policy', 'privacy_policies'
+ inflect.singular 'privacy_policies', 'privacy_policy'
end
ActiveSupport::Inflector.inflections(:es) do |inflect|
@@ -28,4 +32,8 @@ ActiveSupport::Inflector.inflections(:es) do |inflect|
inflect.singular 'licencias', 'licencia'
inflect.plural 'rollup', 'rollups'
inflect.singular 'rollups', 'rollup'
+ inflect.plural 'code_of_conduct', 'codes_of_conduct'
+ inflect.singular 'codes_of_conduct', 'code_of_conduct'
+ inflect.plural 'privacy_policy', 'privacy_policies'
+ inflect.singular 'privacy_policies', 'privacy_policy'
end
diff --git a/config/initializers/que.rb b/config/initializers/que.rb
new file mode 100644
index 00000000..d7abfeb5
--- /dev/null
+++ b/config/initializers/que.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+ActiveJob::Serializers.add_serializers ActiveJob::Serializers::ExceptionSerializer
+
+# Notificar los errores
+Que.error_notifier = proc do |error, job|
+ ExceptionNotifier.notify_exception(error, data: (job || {}))
+end
diff --git a/config/initializers/sucker_punch.rb b/config/initializers/sucker_punch.rb
index 865af32d..21997139 100644
--- a/config/initializers/sucker_punch.rb
+++ b/config/initializers/sucker_punch.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# Enviar una notificación cuando falla una tarea
-SuckerPunch.exception_handler = lambda { |ex, _klass, _args|
- ExceptionNotifier.notify_exception(ex)
+SuckerPunch.exception_handler = lambda { |ex, _, args|
+ ExceptionNotifier.notify_exception(ex, data: args.last)
}
diff --git a/config/locales/devise.views.en.yml b/config/locales/devise.views.en.yml
index fd041b33..a524cf7c 100644
--- a/config/locales/devise.views.en.yml
+++ b/config/locales/devise.views.en.yml
@@ -104,7 +104,26 @@ en:
new:
sign_up: Sign up
help: We only ask for an e-mail address and a password. The password is safely stored, no one else besides you knows it! You'll also receive an e-mail to confirm your account.
- signed_up: Welcome! You have signed up successfully.
+ privacy_policy_accepted:
+ label: "I understand and accept the privacy policy"
+ help: "Read privacy policy"
+ href: "https://sutty.nl/en/privacy-policy/"
+ required: true
+ terms_of_service_accepted:
+ label: "My sites won't promote hate speech"
+ help: "Read terms of service"
+ href: "https://sutty.nl/en/terms-of-service/"
+ required: true
+ code_of_conduct_accepted:
+ label: "I want a more inclusive Internet"
+ help: "Read codes for sharing"
+ href: "https://sutty.nl/en/code-of-conduct/"
+ required: true
+ available_for_feedback_accepted:
+ label: "I'm available to provide feedback"
+ help: "We may contact you occasionaly"
+ required: false
+ signed_up: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated.
signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked.
signed_up_but_unconfirmed: A message with a confirmation link has been sent to your email address. Please follow the link to activate your account.
@@ -138,7 +157,7 @@ en:
errors:
messages:
already_confirmed: was already confirmed, please try signing in
- confirmation_period_expired: needs to be confirmed within %{period}, please request a new one
+ confirmation_period_expired: "wasn't confirmed within %{period}. Please request a new confirmation link by using the \"Resend confirmation instructions\" button below and find it in your inbox."
expired: has expired, please request a new one
not_found: not found
not_locked: was not locked
diff --git a/config/locales/devise.views.es.yml b/config/locales/devise.views.es.yml
index 73166afc..4575c628 100644
--- a/config/locales/devise.views.es.yml
+++ b/config/locales/devise.views.es.yml
@@ -104,7 +104,25 @@ es:
new:
sign_up: Registrarme
help: Para registrarte solo pedimos una dirección de correo y una contraseña. La contraseña se almacena de forma segura, ¡nadie más que vos la sabe! Recibirás un correo de confirmación de cuenta.
- signed_up: Bienvenide. Tu cuenta fue creada.
+ privacy_policy_accepted:
+ label: "Comprendo y acepto la política de privacidad"
+ help: "Leer política de privacidad"
+ href: "https://sutty.nl/politica-de-privacidad/"
+ required: "true"
+ terms_of_service_accepted:
+ label: "Mis sitios no promueven el discurso de odio"
+ help: "Leer términos de servicio"
+ href: "https://sutty.nl/terminos-de-servicio/"
+ required: "true"
+ code_of_conduct_accepted:
+ label: "Quiero una Internet más inclusiva"
+ help: "Leer códigos para compartir"
+ href: "https://sutty.nl/codigo-de-convivencia/"
+ required: "true"
+ available_for_feedback_accepted:
+ label: "Estoy disponible para ofrecer retroalimentación"
+ help: "Te contactaremos ocasionalmente"
+ signed_up: "Hemos enviado un mensaje con un enlace de confirmación a tu correo electrónico. Por favor, abrí el enlace para terminar de activar tu cuenta."
signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque tu cuenta aún no está activada.
signed_up_but_locked: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque que tu cuenta está bloqueada.
signed_up_but_unconfirmed: Para recibir actualizaciones, se ha enviado un mensaje con un enlace de confirmación a tu correo electrónico. Abre el enlace para activar tu cuenta.
@@ -138,7 +156,7 @@ es:
errors:
messages:
already_confirmed: ya ha sido confirmada, por favor intenta iniciar sesión
- confirmation_period_expired: necesita confirmarse dentro de %{period}, por favor solicita una nueva
+ confirmation_period_expired: "quedó sin confirmar luego de %{period}. Por favor, usa el botón \"Reenviar instrucciones de confirmación\" y busca el nuevo link en tu casilla."
expired: ha expirado, por favor solicita una nueva
not_found: no se ha encontrado
not_locked: no estaba bloqueada
diff --git a/config/locales/devise_invitable.en.yml b/config/locales/devise_invitable.en.yml
index f6bfee40..39238140 100644
--- a/config/locales/devise_invitable.en.yml
+++ b/config/locales/devise_invitable.en.yml
@@ -23,6 +23,7 @@ en:
accept: "Accept invitation"
accept_until: "This invitation will be due in %{due_date}."
ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password."
+ sign_in: "Sign in to your account to accept or decline the invitation."
time:
formats:
devise:
diff --git a/config/locales/devise_invitable.es.yml b/config/locales/devise_invitable.es.yml
index 144d6df6..860ee4f8 100644
--- a/config/locales/devise_invitable.es.yml
+++ b/config/locales/devise_invitable.es.yml
@@ -22,7 +22,8 @@ es:
someone_invited_you: "Alguien te ha invitado a colaborar en %{url}, podés aceptar la invitación con el enlace a continuación."
accept: "Aceptar la invitación"
accept_until: "La invitación vencerá el %{due_date}."
- ignore: "Si no querés aceptar la invitación, por favor ignora este correo. Tu cuenta no será creada hasta que aceptes la invitación y configures una contraseña."
+ ignore: "Si no querés aceptar la invitación, por favor ignorá este correo. Tu cuenta no será creada hasta que aceptes la invitación y configures una contraseña."
+ sign_in: "Iniciá sesión con tu cuenta para aceptar o rechazar la invitación."
time:
formats:
devise:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 10a4793b..e2c8323a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1,8 +1,10 @@
en:
dir: ltr
en: English
- es: Castellano
- es-AR: Castellano rioplatense
+ es: Castellano
+ es-AR: Castellano rioplatense
+ switch_locale:
+ es: "Cambiar a castellano"
locales:
es:
name: Castillian Spanish
@@ -13,6 +15,18 @@ en:
ar:
name: Arabic
dir: rtl
+ ur:
+ name: Urdu
+ dir: rtl
+ zh:
+ name: Chinese
+ dir: ltr
+ de:
+ name: German
+ dir: ltr
+ fr:
+ name: French
+ dir: ltr
login:
email: E-mail address
password: Password
@@ -38,7 +52,7 @@ en:
cant_be_empty: 'This field cannot be empty'
image:
site_invalid: 'The image cannot be stored if the site configuration is not valid'
- not_an_image: 'Not an image'
+ not_an_image: 'Not a web image. Accepted formats: PNG, JPEG, GIF, WEBP'
path_required: 'Missing image for upload'
no_file_for_description: "Description with no associated image"
attachment_missing: "I couldn't save the image :("
@@ -78,6 +92,9 @@ en:
th:
type: Type
status: Status
+ seconds: Duration
+ size: Space used
+ url: Address
deploy_local:
title: Build the site
success: Success!
@@ -102,10 +119,30 @@ en:
title: Alternative domain name
success: Success!
error: Error
+ deploy_distributed_press:
+ title: Distributed Web
+ success: Success!
+ error: Error
+ deploy_reindex:
+ title: Reindex
+ success: Success!
+ error: Error
+ deploy_localized_domain:
+ title: Domain name by language
+ success: Success!
+ error: Error
deploy_rsync:
title: Synchronize to backup server
success: Success!
error: Error
+ deploy_full_rsync:
+ title: Synchronize to another Sutty node
+ success: Success!
+ error: Error
+ deploy_distributed_press:
+ title: Distributed Web
+ success: Success!
+ error: Error
help: You can contact us by replying to this e-mail
maintenance_mailer:
notice:
@@ -171,6 +208,8 @@ en:
title: 'Your location in Sutty'
logout: Log out
mutual_aid: Mutual aid
+ contact_us: "Contact us"
+ contact_us_href: "https://sutty.nl/en/#contact"
collaborations:
collaborate:
submit: Register
@@ -252,9 +291,26 @@ en:
Only accessible through [Tor
Browser](https://www.torproject.org/download/)
+ deploy_distributed_press:
+ title: 'Publish to the distributed Web'
+ help: |
+ Make your site available through peer-to-peer protocols,
+ Inter-Planetary File System (IPFS), Hypercore, and via
+ BitTorrent, so your site is more resilient and can be available
+ offline, including in community mesh networks.
+
+ **Important:** Only use this option if you would like your data
+ to be permanently available. If you decide to undo this
+ selection, a cleared version of the site will be shared in its
+ place. However, it is possible that nodes on the distributed
+ storage network may continue retaining copies of the data
+ indefinitely.
+
+ [Learn more](https://sutty.nl/learn-more-about-publish-to-dweb-functionality/)
stats:
index:
title: Statistics
+ filter: "Filter"
help: |
These statistics show information about how your site is generated and
how many resources it uses.
@@ -309,6 +365,11 @@ en:
designer_url: 'Support the designer'
static_file_migration: 'File migration'
find_and_replace: 'Search and replace'
+ status:
+ building: "Your site is building, refresh this page in ."
+ not_published_yet: "Your site is being published for the first time, please wait up to 1 minute..."
+ available: "Your site is available! Click here to find all the different ways to visit it."
+ awaiting_publication: "There are unpublished changes. Click the button below and wait a moment to find them on your site."
index:
title: 'My Sites'
pull: 'Upgrade'
@@ -374,7 +435,7 @@ en:
title: 'Design'
actions: 'Information about this design'
url: 'Demo'
- licencia: 'License'
+ license: 'License'
licencia:
title: 'License for the site and everything published on it'
url: 'Read the license'
@@ -430,6 +491,8 @@ en:
attribute_ro:
file:
download: Download file
+ password:
+ safety: Passwords are stored safely
show:
front_matter: Post metadata
submit:
@@ -458,7 +521,7 @@ en:
file:
destroy: Remove file
image:
- label: Imagen
+ label: Image
destroy: Remove image
belongs_to:
empty: "(Empty)"
@@ -481,17 +544,15 @@ en:
order: 'Order'
content: 'Text'
new: 'Post types'
- add: 'Add'
- filter: 'Filter'
- remove_filter: 'Back'
remove_filter_help: 'Remove the filter: %{filter}'
categories: 'Everything'
- index: 'Posts'
+ index:
+ search: 'Search'
edit: 'Edit'
preview:
btn: 'Preliminary version'
alert: 'Not every article type has a preliminary version'
- message: 'This is a preliminary version, use the Publish changes button back on the panel to publish the article onto your site.'
+ message: 'This is a preview of your post with some contextual elements from your site.'
open: 'Tip: You can add new options by typing them and pressing Enter'
private: '🔒 The values of this field will remain private'
select:
@@ -639,3 +700,12 @@ en:
queries:
show:
empty: '(empty)'
+ schemas:
+ add:
+ add: 'Add'
+ filter:
+ filter: 'Filter'
+ remove: 'Back'
+ build_stats:
+ index:
+ title: "Publications"
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 02973de5..2fc77c5f 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -3,6 +3,8 @@ es:
en: English
es-AR: Castellano Rioplatense
dir: ltr
+ switch_locale:
+ en: "Switch to English"
locales:
es:
name: Castellano
@@ -13,6 +15,18 @@ es:
ar:
name: Árabe
dir: rtl
+ ur:
+ name: Urdu
+ dir: rtl
+ zh:
+ name: Chino
+ dir: ltr
+ de:
+ name: Alemán
+ dir: ltr
+ fr:
+ name: Francés
+ dir: ltr
login:
email: Correo electrónico
password: Contraseña
@@ -38,7 +52,7 @@ es:
cant_be_empty: 'El campo no puede estar vacío'
image:
site_invalid: 'La imagen no se puede almacenar si la configuración del sitio no es válida'
- not_an_image: 'No es una imagen'
+ not_an_image: 'No es una imagen en formato web. Formatos aceptados: PNG, JPEG, GIF, WEBP'
path_required: 'Se necesita una imagen'
no_file_for_description: 'Se envió una descripción sin imagen asociada'
attachment_missing: 'no pude guardar el archivo :('
@@ -78,6 +92,9 @@ es:
th:
type: Tipo
status: Estado
+ seconds: Duración
+ size: Espacio ocupado
+ url: Dirección
deploy_local:
title: Generar el sitio
success: ¡Éxito!
@@ -102,10 +119,30 @@ es:
title: Dominio alternativo
success: ¡Éxito!
error: Hubo un error
+ deploy_distributed_press:
+ title: Web distribuida
+ success: ¡Éxito!
+ error: Hubo un error
+ deploy_reindex:
+ title: Reindexación
+ success: ¡Éxito!
+ error: Hubo un error
+ deploy_localized_domain:
+ title: Dominio según idioma
+ success: ¡Éxito!
+ error: Hubo un error
deploy_rsync:
title: Sincronizar al servidor alternativo
success: ¡Éxito!
error: Hubo un error
+ deploy_full_rsync:
+ title: Sincronizar a otro nodo de Sutty
+ success: ¡Éxito!
+ error: Hubo un error
+ deploy_distributed_press:
+ title: Web distribuida
+ success: ¡Éxito!
+ error: Hubo un error
help: Por cualquier duda, responde este correo para contactarte con nosotres.
maintenance_mailer:
notice:
@@ -171,6 +208,8 @@ es:
title: 'Tu ubicación en Sutty'
logout: Cerrar sesión
mutual_aid: Ayuda mutua
+ contact_us: "Contacto"
+ contact_us_href: "https://sutty.nl/#contacto"
collaborations:
collaborate:
submit: Registrarme
@@ -257,9 +296,26 @@ es:
Sólo será accesible a través del [Navegador
Tor](https://www.torproject.org/es/download/).
+ deploy_distributed_press:
+ title: 'Publicar a la Web distribuida'
+ help: |
+ Utiliza protocolos de pares, Inter-Planetary File System (IPFS),
+ Hypercore y torrents, para que tu sitio sea más resiliente y
+ esté disponible _offline_, inclusive en redes _mesh_
+ comunitarias.
+
+ **Importante:** Sólo usa esta opción si te parece correcto que
+ tu contenido esté disponible permanentemente. Cuando elijas
+ des-hacer esta acción, una versión "vacía" del sitio será
+ compartida en su lugar. Sin embargo, es posible que algunos
+ nodos en la red de almacenamiento distribuida puedan retener
+ copias de tu contenido indefinidamente.
+
+ [Saber más](https://sutty.nl/saber-mas-sobre-publicar-a-la-web-distribuida/)
stats:
index:
title: Estadísticas
+ filter: "Filtrar"
help: |
Las estadísticas visibilizan información sobre cómo se genera y
cuántos recursos utiliza tu sitio.
@@ -314,6 +370,11 @@ es:
designer_url: 'Apoyá a le(s) diseñadore(s)'
static_file_migration: 'Migración de archivos'
find_and_replace: 'Búsqueda y reemplazo'
+ status:
+ building: "Tu sitio se está publicando, recargá esta página en ."
+ not_published_yet: "Tu sitio se está publicando por primera vez, por favor espera hasta un minuto..."
+ available: "¡Tu sitio está disponible! Cliqueá aquí para encontrar todas las formas en que podés visitarlo."
+ awaiting_publication: "Hay cambios sin publicar, cliqueá el botón debajo y espera un momento para encontrarlos en tu sitio."
index:
title: 'Mis sitios'
pull: 'Actualizar'
@@ -438,6 +499,8 @@ es:
attribute_ro:
file:
download: Descargar archivo
+ password:
+ safety: Las contraseñas se almacenan de forma segura
show:
front_matter: Metadatos del artículo
submit:
@@ -490,16 +553,14 @@ es:
content: 'Cuerpo del artículo'
categories: 'Todos'
new: 'Tipos de artículos'
- add: 'Agregar'
- filter: 'Filtrar'
- remove_filter: 'Volver'
remove_filter_help: 'Quitar este filtro: %{filter}'
- index: 'Artículos'
+ index:
+ search: 'Buscar'
edit: 'Editar'
preview:
btn: 'Versión preliminar'
alert: 'No todos los tipos de artículos poseen vista preliminar :)'
- message: 'Esta es una versión preliminar, para que el artículo aparezca en tu sitio utiliza el botón Publicar cambios en el panel'
+ message: 'Esta es la vista previa de tu artículo, con algunos elementos contextuales del sitio'
open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar'
private: '🔒 Los valores de este campo serán privados'
select:
@@ -647,3 +708,12 @@ es:
queries:
show:
empty: '(vacío)'
+ schemas:
+ add:
+ add: 'Agregar'
+ filter:
+ filter: 'Filtrar'
+ remove: 'Volver'
+ build_stats:
+ index:
+ title: "Publicaciones"
diff --git a/config/routes.rb b/config/routes.rb
index 8bab18af..3828915c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,8 +11,6 @@ Rails.application.routes.draw do
namespace :v1 do
resources :csp_reports, only: %i[create]
- get :'sites/hidden_services', to: 'sites#hidden_services'
- post :'sites/add_onion', to: 'sites#add_onion'
resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-.]+/, id: /[a-z0-9\-.]+/ } do
get :'invitades/cookie', to: 'invitades#cookie'
post :'posts/:layout', to: 'posts#create', as: :posts
@@ -55,7 +53,7 @@ Rails.application.routes.draw do
# Gestionar artículos según idioma
nested do
- scope '/(:locale)', constraint: /[a-z]{2}/ do
+ scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do
post :'posts/reorder', to: 'posts#reorder'
resources :posts do
get 'p/:page', action: :index, on: :collection
@@ -77,5 +75,7 @@ Rails.application.routes.draw do
get :'stats/host', to: 'stats#host'
get :'stats/uris', to: 'stats#uris'
get :'stats/resources', to: 'stats#resources'
+
+ resources :build_stats, only: %i[index]
end
end
diff --git a/db/migrate/20220428135113_add_slugify_mode_to_sites.rb b/db/migrate/20220428135113_add_slugify_mode_to_sites.rb
new file mode 100644
index 00000000..fd887886
--- /dev/null
+++ b/db/migrate/20220428135113_add_slugify_mode_to_sites.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Permite a los sitios elegir el método de slugificación
+class AddSlugifyModeToSites < ActiveRecord::Migration[6.1]
+ def change
+ add_column :sites, :slugify_mode, :string, default: 'default'
+ end
+end
diff --git a/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb b/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb
new file mode 100644
index 00000000..00bae7ea
--- /dev/null
+++ b/db/migrate/20220712135053_change_blob_key_uniqueness_to_include_service_name.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Cambia el índice único para incluir el nombre del servicio, de forma
+# que podamos tener varias copias del mismo sitio (por ejemplo para
+# test) sin que falle la creación de archivos.
+class ChangeBlobKeyUniquenessToIncludeServiceName < ActiveRecord::Migration[6.1]
+ def change
+ remove_index :active_storage_blobs, %i[key], unique: true
+ add_index :active_storage_blobs, %i[key service_name], unique: true
+ end
+end
diff --git a/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb b/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb
new file mode 100644
index 00000000..e6572ffb
--- /dev/null
+++ b/db/migrate/20220802153308_indexed_posts_by_uuid_and_site_id.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# No podemos compartir el uuid entre indexed_posts y posts porque
+# podemos tener sitios duplicados. Al menos hasta que los sitios de
+# testeo estén integrados en el panel vamos a tener que generar otros
+# UUID.
+class IndexedPostsByUuidAndSiteId < ActiveRecord::Migration[6.1]
+ def up
+ add_column :indexed_posts, :post_id, :uuid, index: true
+
+ IndexedPost.transaction do
+ ActiveRecord::Base.connection.execute('update indexed_posts set post_id = id where post_id is null')
+ end
+ end
+
+ def down
+ remove_column :indexed_posts, :post_id
+ end
+end
diff --git a/db/migrate/20230119165420_create_distributed_press_publisher.rb b/db/migrate/20230119165420_create_distributed_press_publisher.rb
new file mode 100644
index 00000000..8d8de37a
--- /dev/null
+++ b/db/migrate/20230119165420_create_distributed_press_publisher.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Crea la tabla de publishers de Distributed Press que contiene las
+# instancias y tokens
+class CreateDistributedPressPublisher < ActiveRecord::Migration[6.1]
+ def change
+ create_table :distributed_press_publishers do |t|
+ t.timestamps
+ t.string :instance, unique: true
+ t.text :token_ciphertext, null: false
+ t.datetime :expires_at, null: true
+ end
+ end
+end
diff --git a/db/migrate/20230318183722_rename_deploy_rsync_to_deploy_full_rsync.rb b/db/migrate/20230318183722_rename_deploy_rsync_to_deploy_full_rsync.rb
new file mode 100644
index 00000000..689dc559
--- /dev/null
+++ b/db/migrate/20230318183722_rename_deploy_rsync_to_deploy_full_rsync.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# Cambia todos los DeployRsync propios de Sutty a DeployFullRsync que se
+# encarga de sincronizar todo.
+class RenameDeployRsyncToDeployFullRsync < ActiveRecord::Migration[6.1]
+ def up
+ DeployRsync.all.find_each do |deploy|
+ dest = deploy.destination.split(':', 2).first
+
+ next unless nodes.include? dest
+
+ deploy.destination = "#{dest}:"
+ deploy.type = 'DeployFullRsync'
+
+ deploy.save
+ end
+ end
+
+ def down
+ DeployFullRsync.all.find_each do |deploy|
+ next unless nodes.include? deploy.destination.split(':', 2).first
+
+ deploy.destination = "#{deploy.destination}#{deploy.site.hostname}"
+ deploy.type = 'DeployRsync'
+
+ deploy.save
+ end
+ end
+
+ private
+
+ def nodes
+ @nodes ||= Rails.application.nodes.map do |node|
+ "sutty@#{node}"
+ end
+ end
+end
diff --git a/db/migrate/20230322214924_add_code_of_conduct.rb b/db/migrate/20230322214924_add_code_of_conduct.rb
new file mode 100644
index 00000000..f859b08c
--- /dev/null
+++ b/db/migrate/20230322214924_add_code_of_conduct.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Crea códigos de conducta
+class AddCodeOfConduct < ActiveRecord::Migration[6.1]
+ def up
+ create_table :codes_of_conduct do |t|
+ t.timestamps
+ t.string :title
+ t.text :description
+ t.text :content
+ end
+
+ # XXX: En lugar de ponerlo en las seeds
+ YAML.safe_load(File.read('db/seeds/codes_of_conduct.yml')).each do |coc|
+ CodeOfConduct.new(**coc).save!
+ end
+ end
+
+ def down
+ drop_table :codes_of_conduct
+ end
+end
diff --git a/db/migrate/20230322231344_add_privacy_policy.rb b/db/migrate/20230322231344_add_privacy_policy.rb
new file mode 100644
index 00000000..e0d7ae59
--- /dev/null
+++ b/db/migrate/20230322231344_add_privacy_policy.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Agrega políticas de privacidad
+class AddPrivacyPolicy < ActiveRecord::Migration[6.1]
+ def up
+ create_table :privacy_policies do |t|
+ t.timestamps
+ t.string :title
+ t.text :description
+ t.text :content
+ end
+
+ # XXX: En lugar de ponerlo en las seeds
+ YAML.safe_load(File.read('db/seeds/privacy_policies.yml')).each do |pp|
+ PrivacyPolicy.new(**pp).save!
+ end
+ end
+
+ def down
+ drop_table :privacy_policies
+ end
+end
diff --git a/db/migrate/20230325163802_add_short_description_to_licencias.rb b/db/migrate/20230325163802_add_short_description_to_licencias.rb
new file mode 100644
index 00000000..efcc01e4
--- /dev/null
+++ b/db/migrate/20230325163802_add_short_description_to_licencias.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Agrega descripciones cortas a las licencias
+class AddShortDescriptionToLicencias < ActiveRecord::Migration[6.1]
+ def up
+ add_column :licencias, :short_description, :string
+
+ YAML.safe_load_file('db/seeds/licencias.yml').each do |licencia|
+ Licencia.find_by_icons(licencia['icons']).update licencia
+ end
+ end
+
+ def down
+ remove_column :licencias, :short_description
+ end
+end
diff --git a/db/migrate/20230328200129_add_consent_to_usuaries.rb b/db/migrate/20230328200129_add_consent_to_usuaries.rb
new file mode 100644
index 00000000..1e85864d
--- /dev/null
+++ b/db/migrate/20230328200129_add_consent_to_usuaries.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Agrega consentimientos a les usuaries. No usamos un loop de
+# Usuarie::CONSENT_FIELDS porque quizás agreguemos campos luego.
+class AddConsentToUsuaries < ActiveRecord::Migration[6.1]
+ def change
+ add_column :usuaries, :privacy_policy_accepted_at, :datetime
+ add_column :usuaries, :terms_of_service_accepted_at, :datetime
+ add_column :usuaries, :code_of_conduct_accepted_at, :datetime
+ add_column :usuaries, :available_for_feedback_accepted_at, :datetime
+ end
+end
diff --git a/db/migrate/20230328213242_remove_acepta_politicas_de_privacidad_from_usuaries.rb b/db/migrate/20230328213242_remove_acepta_politicas_de_privacidad_from_usuaries.rb
new file mode 100644
index 00000000..7ca562bf
--- /dev/null
+++ b/db/migrate/20230328213242_remove_acepta_politicas_de_privacidad_from_usuaries.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Elimina un campo que nunca se usó
+class RemoveAceptaPoliticasDePrivacidadFromUsuaries < ActiveRecord::Migration[6.1]
+ def change
+ remove_column :usuaries, :acepta_politicas_de_privacidad, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20230411185406_add_sustainability_to_access_logs.rb b/db/migrate/20230411185406_add_sustainability_to_access_logs.rb
new file mode 100644
index 00000000..80f16fb5
--- /dev/null
+++ b/db/migrate/20230411185406_add_sustainability_to_access_logs.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Agrega las columnas de calculo de emisiones de CO2
+class AddSustainabilityToAccessLogs < ActiveRecord::Migration[6.1]
+ def change
+ %i[datacenter_co2 network_co2 consumer_device_co2 production_co2 total_co2].each do |column|
+ add_column :access_logs, column, :decimal, limit: 53
+ end
+ end
+end
diff --git a/db/migrate/20230415153231_add_priority_to_designs.rb b/db/migrate/20230415153231_add_priority_to_designs.rb
new file mode 100644
index 00000000..7fc45558
--- /dev/null
+++ b/db/migrate/20230415153231_add_priority_to_designs.rb
@@ -0,0 +1,5 @@
+class AddPriorityToDesigns < ActiveRecord::Migration[6.1]
+ def change
+ add_column :designs, :priority, :integer
+ end
+end
diff --git a/db/migrate/20230421182627_change_full_rsync_destination.rb b/db/migrate/20230421182627_change_full_rsync_destination.rb
new file mode 100644
index 00000000..3a22aea6
--- /dev/null
+++ b/db/migrate/20230421182627_change_full_rsync_destination.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Envía los cambios a través de rsyncd
+class ChangeFullRsyncDestination < ActiveRecord::Migration[6.1]
+ def up
+ DeployFullRsync.find_each do |deploy|
+ Rails.application.nodes.each do |node|
+ deploy.destination = "rsync://rsyncd.#{node}/deploys/"
+ deploy.save
+ end
+ end
+ end
+
+ def down
+ DeployFullRsync.find_each do |deploy|
+ Rails.application.nodes.each do |node|
+ deploy.destination = "sutty@#{node}:"
+ deploy.save
+ end
+ end
+ end
+end
diff --git a/db/migrate/20230424174544_add_node_to_access_logs.rb b/db/migrate/20230424174544_add_node_to_access_logs.rb
new file mode 100644
index 00000000..805fbc27
--- /dev/null
+++ b/db/migrate/20230424174544_add_node_to_access_logs.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Agrega la columna de nodo a los logs
+class AddNodeToAccessLogs < ActiveRecord::Migration[6.1]
+ def change
+ add_column :access_logs, :node, :string, index: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a395329d..fd82d447 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2021_10_22_225449) do
+ActiveRecord::Schema.define(version: 2023_04_15_153231) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -217,6 +217,7 @@ ActiveRecord::Schema.define(version: 2021_10_22_225449) do
t.boolean "disabled", default: false
t.text "credits"
t.string "designer_url"
+ t.integer "priority"
end
create_table "indexed_posts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -380,6 +381,7 @@ ActiveRecord::Schema.define(version: 2021_10_22_225449) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+
create_trigger("indexed_posts_before_insert_update_row_tr", :compatibility => 1).
on("indexed_posts").
before(:insert, :update) do
diff --git a/db/seeds/codes_of_conduct.yml b/db/seeds/codes_of_conduct.yml
new file mode 100644
index 00000000..64582072
--- /dev/null
+++ b/db/seeds/codes_of_conduct.yml
@@ -0,0 +1,613 @@
+---
+- title_en: "Codes for sharing"
+ title_es: "Códigos para compartir"
+ description_en: "Codes of conduct allow inclusive communities."
+ description_es: "Los códigos de convivencia nos permiten alojar comunidades inclusivas."
+ content_en: |
+ # Code for sharing
+
+ > This code of conduct is based in "[Códigos para compartir, hackear,
+ > piratear en
+ > libertad](https://utopia.partidopirata.com.ar/zines/codigos_para_compartir.html)"
+ > published by [Partido Interdimensional
+ > Pirata](https://partidopirata.com.ar/).
+
+ > We use gender neutral pronouns to include all peoples. In this sense,
+ > we encourage different forms, strategies and tools used to embody
+ > practices that aren't anthropocentric, sexist, cis-sexist in our
+ > language.
+
+ ## Introduction
+
+ This is an example code that strives to give a consensual frame to
+ enable asistance, permanence and confortable stay to everyone using and
+ inhabiting [Sutty](https://sutty.nl/), and to welcome new users and
+ potential allies as well. It sets the floor for desirable and
+ acceptable, and undesirable and intolerable conducts for its community.
+ You can use it with or without changes, adapting it to your activities.
+ This code is in permanent and collective mutation and feeds, copies and
+ inspires on the following sources:
+
+ *
+
+ *
+
+ *
+
+ *
+
+ We strive to sustain and foment an open community, that invites and
+ attains participation from more people, in all their diversity. We know
+ that spaces related to computing and free software are mostly inhabited
+ by middle class cis white males, even when there's an acknowledgement of
+ the need to close the gender gap. In this sense, this is our little
+ contribution, made from collective practices on multiple dimentions,
+ reflections, readings, and experiences that grow every day.
+
+ ## That everyone needs to be well treated
+
+ Every being that we share space with deserves good treatment, respect
+ and compassion. Here we share basic criteria for introduction and care.
+
+ ### Towards humans
+
+ Everyone is deserving of care and greetings and we have a right to
+ assume good intentions from others.
+
+ When we refer to other humans, we try to be careful and respectul
+ towards their gender identity. For this, these principles are useful:
+
+ * Don't assume, judge or try to "interpret" the gender of others.
+
+ * Don't use gender beforehand. It's related to the previous item, but
+ puts emphasis towards naturalized gendering behaviour (ie. assuming
+ someone wearing a dress uses female pronouns...). The proposal is to
+ discard them.
+
+ * If a person explicits their pronouns and mode in which they want to be
+ referenced, we respect them, by listening and trying to use their
+ prefered pronouns.
+
+ * When presentations don't include pronouns, we can ask respectfully
+ for prefered pronouns. But be careful! This question must be asked
+ to everyone, otherwise the "suspicion" is loaded towards a person, and
+ it can become a form of harrassment.
+
+ * Do we need to know the gender of a person to relate with them? Maybe
+ a better practice is to evade gendering others. But if this means to
+ use "them" compulsively, some people may be made to feel bad. (For
+ instance, trans\* people who use female or male pronouns may feel
+ upset or outed when refering to them as "them", specially if they're
+ the only ones to be gendered like this in a group!
+
+ * When in doubt, asking and apologizing respectfully is a good way of
+ being careful towards each other.
+
+ ## Important points to guarantee our space from being expulsive
+
+ **Listening to and between everyone in a caring climate**
+
+ * Listen to what everyone has to say, being mindful that everyone has
+ something valuable to communicate.
+
+ * For active listening, we prefer to ask first, before making judgement.
+
+ * Sometimes being silent is a condition for others to be able to talk.
+ To listen is an exercise that requires practice. Also talking.
+
+ * We're interested in what everyone has to say. If you're more trained
+ in participating, talking, and having opinions, take into account that
+ not everyone does. Give them space if they want to take it. But
+ remember that encouraging is not the same as pressuring!
+
+ * We try to check and stop offensive practices to add to the respectful
+ climate. This doesn't mean to be submissive or to agree to
+ everything. At the least, it sets a floor of respect towards enabling
+ a dialogue when necessary.
+
+ * It's at the very least disrepectful to repeat damaging behaviour when
+ it was already identified as such. It can make others unconfortable,
+ or hurt and expel them. We'll make this point every time that is
+ needed and tolerable.
+
+ * We avoid this behaviour ourselves and we help others to notice their
+ own.
+
+ * When raising attention becomes insufficient, we need to review this
+ agreements to keep the coexistence. This implies to act in accord to
+ them, and that this code can be revised and updated when deemed
+ necessary (there's no consensus).
+
+ * One of the ways in which free software spaces can be and are expulsive
+ is with attitudes that don't contemplate diversity in knowledges and
+ interlocutors. By appealing to technicisms, many comrades are kept
+ out of what's happening, and no one verifies if everyone is keeping up
+ with the conversation.
+
+ Our fervent recommendation is to be attentive to this dynamic so we
+ can avoid or revert them.
+
+ * The counter to the previous situation is "mansplaining": a cis-male
+ person assuming the authoritative place of knowledge to (over-)explain
+ everything to others, in a patronizing way and without taking into
+ account what others want to listen or not, to say, what already know
+ or do, etc.
+
+ * We believe there's no "authoritative voice" to have an opinion and to
+ participate. Free culture is for everyone to share.
+
+ * "Sharing is caring" v. "Google is your friend". Meritocracy and other
+ traditional codes in cyber-communities work against free culture. We
+ support the pirate culture that onboards ever more pirates to their
+ ships. We believe that culture is for everyone and we defy elitisms.
+
+ * We don't assume that other people shares our likings, beliefs, class
+ position, sexuality, etc. We can be violent when we misread others.
+ We recommend to ask respectfully and to avoid comments or jokes that
+ can be hurtful to others.
+
+ * We speak and act gently and inclusively.
+
+ * We respect different points of view, experiences, beliefs, etc. and we
+ take them into account when we act collectively so it reflects in our
+ attitudes.
+
+ * We welcome criticism, specially the constructive kind ;)
+
+ * We focus in what's best for the community, without losing warmth,
+ respect and diversity amongst ourselves.
+
+ * We show empathy toward others. We want to share and communicate.
+
+ * It's useful to think everyone has different abilities, stories,
+ experiences... It's possible to not understand some comments. We try
+ to avoid acting in bad faith and to use every accessibility tool we
+ can.
+
+ * The last item includes neurodiverse people and those that have
+ experienced trauma. Sometimes, sarcasm or irony is not well received
+ or understood by others. We take this into our strategies to include
+ everyone in our communications. Even more, if we think some topics
+ could be sensitive to others (memory-triggering, phobias, untolerable,
+ explicit violence or body images, etc.), we use content warnings (cw)
+ before what we wanted to share. For instance: "cw: comments about
+ sexual and physical violence". This allows everyone to opt in to the
+ content instead of being taken by surprise.
+
+ * We're respectful of limits established by others (personal space,
+ physical contact, interaction mood, privacy, being photographed, etc.)
+
+ * We want to and believe in welcoming more pirates!
+
+ ## Consent for documenting and sharing in media
+
+ * If you're going to take pictures or record video, ask consent from
+ people involved.
+
+ * If there're minors, ask their responsible families.
+
+ ## Our commitment against harassment
+
+ In the interest of fomenting an open, diverse and welcoming community,
+ we contributors and admins make a commitment against harassment in our
+ projects and community for everyone, without regard of age, body
+ diversity, capacity, neuro-diversity, ethnicity, gender identity and
+ expression, experience level, nationality, physical appearance,
+ religion, sexual identity or orientation.
+
+ Examples of unacceptable behaviour from participants:
+
+ * Offensive comments about gender/s, gender identity and expression,
+ sexual orientation, capacity, mental sickness, neuro-(a)tipicality,
+ physical appearance, body size, ethnicity or religion.
+
+ * Unwelcomed comments related to personal and life choices, including
+ amongst others, those related to food, health, children upbringing,
+ drug use and employment.
+
+ * Insulting or despective comments and personal or political attacks.
+ **Trolling**.
+
+ * Assuming others' gender. If you're in doubt, ask politely about
+ pronouns. Don't use the name(s) that people don't use anymore, use
+ the name, _nickname_ or pseudonym that they prefer. Do you really
+ need the name, ID number, biometric data, birth certificate of others?
+
+ * Sexual comments, images or behaviour, unneeded or in spaces where they
+ weren't appropiate.
+
+ * Unconsented physical contact or repeated after being asked to stop.
+
+ * Threatening others.
+
+ * Inciting violence towards others, including self-damage.
+
+ * Deliberate intimidation.
+
+ * Stalking.
+
+ * To harass by photographing or recording without consent, including
+ uploading personal information to the Internet.
+
+ * Interrupting a conversation constantly.
+
+ * Making unwanted sexual comments.
+
+ * Unappropiate patterns of social contact, like asking/assuming
+ inappropiate intimacy levels with others.
+
+ * Trying to interact with a person after being asked not to.
+
+ * Exposing deliberately any aspect of a person identity without consent,
+ except when necessary for protecting others against intentional abuse.
+
+ * Making public any kind of private conversation.
+
+ * Other kinds of conduct that can be considered inappropiate in an
+ environment of camaraderie.
+
+ * Repeating attitudes that others find offensive or violatory of this
+ code.
+
+ ## Consequences
+
+ * Any person that has been asked to stop offensive behaviour is expected
+ to respond immediately, even when in disagreement.
+
+ * Admins can take any action deemed necessary and adequate, including
+ expelling the person or removing their site without advertence. This
+ decision is taken by consensus between admins and is reserved for
+ extreme cases that compromise the community or the permanence of
+ others without feeling wronged or threatened.
+
+ * Admins reserve the right to forbid participation to any future
+ activity or publication.
+
+ As we mentioned before, this code is in permanent collective mutation.
+ It's main objective is to generate an inclusive and non-expulsive
+ environment that is also transparent and open without [missing
+ stairs](https://en.wikipedia.org/wiki/Missing_stair) ("the missing stair
+ from a house that everyone knows about but no one wants to take
+ responsibility for"). It's important to adapt it to different
+ activities and that it nurtures from contributions from its users.
+ Receiving your comments and input will help us to achieve this
+ objective.
+
+ ## Let's keep in contact!
+
+ Si pasaste por alguna situación que quieras compartir --te hayas animado
+ o no a decirlo en el momento--, podés ponerte en contacto con nosotres.
+
+ Con respecto a quejas o avisos acerca de situaciones de violencia,
+ acoso, abuso o repetición de conductas que se advirtieron como
+ intolerables, tomamos la responsabilidad de tenerlas en cuenta y
+ trabajar en ellas para que el resultado sea el favorable al espíritu de
+ colectiva que elegimos y describimos aquí. Si bien consideramos que las
+ prácticas punitivistas no van con nosotres, nuestra decisión explícita
+ es escuchar a la persona que se manifiesta como violentada o víctima y
+ acompañarla.
+
+ You can contact us if you were part of a situation you want to share
+ --even if you didn't pointed it in the moment.
+
+ In regards to complaints or notices about violence, harassment, abuse or
+ repeated untolerable conducts, we take the responsibility of working on
+ them for a favorable result towards the collective spirit we defined
+ here. Even when we don't condone punitivist practices, our explicit
+ decision is for the victim to be listened and accompanied.
+ content_es: |
+ # Códigos para compartir
+
+ > Este código de convivencia está basado en los "[Códigos para
+ > compartir, hackear, piratear en
+ > libertad](https://utopia.partidopirata.com.ar/zines/codigos_para_compartir.html)"
+ > publicados por el [Partido Interdimensional
+ > Pirata](https://partidopirata.com.ar/).
+
+ > Utilizamos preferentemente la 'e' para referirnos a las personas en
+ > general. En ese sentido, alentamos las diferentes formas, estrategias
+ > y herramientas para incorporar prácticas no antropocéntricas,
+ > sexistas, ni cisexistas en la lengua. Otras alternativas que apoyamos
+ > --y eventualmente usamos-- son el uso del femenino, la letra e, arrobas,
+ > equis, asteriscos, etc.
+
+ ## Introducción
+
+ Este es un ejemplo de código que busca aportar un marco de consenso para
+ garantizar la asistencia, permanencia y cómoda estadía de todas las
+ personas que habitan y utilizan Sutty, así como para bienvenir a nueves
+ usuaries y potenciales aliades. Para esto, fija un piso de conductas
+ deseables, aceptables, indeseables y/o intolerables para la comunidad.
+ Podés usarlo sin cambios o modificarlo para adaptarlo a tus actividades.
+ Este código está en permanente mutación colectiva y se alimenta, copia e
+ inspira de las siguientes fuentes:
+
+ *
+
+ *
+
+ *
+
+ *
+
+ Procuramos mantener y fomentar una comunidad abierta, que invite y logre
+ la participación de cada vez más personas, en toda su diversidad.
+ Sabemos que los espacios de Software Libre, informática, sistemas, etc.
+ son habitados mayormente por varones cis, blancos y de clase media, pese
+ al reconocimiento de la necesidad de eliminar la brecha de géneros. En
+ este sentido, este es nuestro pequeño aporte, hecho de prácticas
+ colectivas de múltiples dimensiones, reflexiones, lecturas, experiencias
+ que crecen día a día.
+
+ ## Que todes les seres sean bien tratades
+
+ Cada ser con el que compartamos el espacio es merecedore de buen trato,
+ respeto y compasión. En otras palabras, compartimos a continuación los
+ criterios básicos de presentación y cuidados.
+
+ Para les humanes
+
+ Todes somos dignes de cuidados y de saludos y tenemos derecho a suponer
+ las buenas intenciones de le otre.
+
+ Para referirnos a otres humanes, trataremos de ser cuidadoses y
+ respuestuoses de su identidad de género. Para ello, son útiles los
+ siguientes principios:
+
+ * No presuponer, juzgar o "interpretar" el género de le otre.
+
+ * No generizar de antemano. Se desprende del punto anterior, pero hace
+ especial énfasis en comportamientos naturalizados de generización (EJ:
+ presuponer que porque una persona usa un vestido se nombra en
+ femenino...). La propuesta es desecharlos.
+
+ * Si la persona explicita sus pronombres y modos en que quiere ser
+ referenciade, lo respetamos, escuchando y procurando referirnos a elle
+ usando sus pronombres elegidos.
+
+ * Si no se incluye en la presentación los pronombres preferidos, podemos
+ preguntar respetuosamente qué pronombres se usan. ¡Pero atención! Es
+ una pregunta que debe dirigirse a todes por igual, de lo contrario,
+ carga la "sospecha" sobre la persona señalada y puede resultar en una
+ forma de hostigamiento.
+
+ * ¿Es necesario conocer el género de una persona para relacionarnos o
+ referirnos a ella? Quizás una buena práctica es evitar generizar para
+ todas las personas. Pero si esto implica el uso compulsivo de la "e"
+ para todes, puede ser que alguna persona se sienta molesta. (Por
+ ejemplo, las personas trans\* que se identifican en femenino o
+ masculino suelen sentirse molestas y "sacadas del clóset" u *outeadas*
+ si se refieren a ellas con la "e", ¡en especial si son las únicas en
+ ser generizadas de esta forma en un grupo!).
+
+ * Ante cualquier duda, preguntar respetuosamente y disculparse
+ respetuosamente es una buena idea para ayudar a cuidarnos.
+
+ ## Puntos importantes para garantizar que nuestro espacio no resulte expulsivo
+
+ **Escucharnos a todas y entre todas en un clima de cuidados**
+
+ * Escuchar lo que cada quien tiene para decir, conscientes de que todes
+ tenemos algo valioso para comunicar(nos).
+
+ * Para la escucha activa, preferimos preguntar primero, en lugar de
+ hacer juicios.
+
+ * Hacer silencio a veces es la condición para que otres puedan animarse
+ a hablar. Escuchar es un ejercicio que requiere práctica. También lo
+ es hablar.
+
+ * Nos interesa lo que todes tengan para decir. Por lo tanto, si estás
+ más entrenade en el ejercicio de participar, hablar, opinar, tené en
+ cuenta que quizás haya otres que no lo estén tanto: darles el espacio
+ si quieren tomarlo. ¡Pero recordá que incentivar no es lo mismo que
+ presionar!
+
+ * Tratamos de revisar y discontinuar alguna práctica que pueda haber
+ resultado ofensiva, para sumar al clima de respeto. Sin embargo, esto
+ no significa "bajar la cabeza" o estar necesariamente de acuerdo. Al
+ menos, fija un piso de respeto para comenzar un diálogo en el caso en
+ que sea necesario.
+
+ * Es --al menos-- una falta de respeto repetir un comportamiento dañino
+ que ya se identificó como tal. Puede incomodar, lastimar y expulsar a
+ otres, por lo que preferimos llamar la atención sobre este punto todas
+ las veces que sea necesario y tolerable.
+
+ * Evitamos esto nosotres y ayudamos a otres a darse cuenta cuando lo
+ están haciendo.
+
+ * En los casos en los que los llamados de atención resulten
+ insuficientes, hemos de revisar estos acuerdos para sostener la
+ convivencia. Eso implica actuar de acuerdo a ellos. Y también que
+ estos códigos pueden ser revisados y actualizados en caso de que se
+ considere necesario (deje de haber consenso).
+
+ * Una manera en la que los espacios de Software Libre y tecnologías
+ pueden y suelen ser expulsivos es mediante actitudes que no contemplan
+ la diversidad de saberes e interlocutor\*s. So pretexto de incluir
+ tecnicismos, muches compañeres quedan al margen de lo que está
+ sucediendo, muchas veces, sin que nadie tenga la mínima delicadeza de
+ verificar que todes estén siguiendo la conversación.
+
+ Recomendamos fervientemente estar atentes a estas dinámicas para poder
+ evitarlas y/o revertirlas.
+
+ * La otra cara de la situación anterior es el famoso _mansplaining_: un
+ tipo cis poniéndose en el lugar de la autoridad del saber para
+ (sobre-)explicar todo a le otre, de manera paternalista y sin tener en
+ cuenta lo que le otre quiere o no escuchar, decir, lo que sabe o hace,
+ etc.
+
+ * Creemos que no hace falta ser "una voz autorizada" para opinar y
+ participar. La cultura libre se comparte entre todes.
+
+ * "Compartir es bueno" vs. "Google es tu amigo". La meritocracia y
+ ciertos códigos tradicionales de ciertas ciber-comunidades suelen
+ operar de manera contraria a la propuesta de la cultura libre de
+ compartir. Apoyamos la cultura piratil que suma más piratas a los
+ barcos. Creemos que la cultura es para todes y desafiamos las
+ prácticas elitistas.
+
+ * No damos por sentado que la persona con la que estamos interactuando
+ comparte gustos, creencias, pertenencias de clase, sexualidad, etc.
+ Podemos ser violentes si hacemos una lectura equivocada de le otre.
+ Recomendamos siempre preguntar de manera respetuosa y evitar
+ comentarios o chistes que puedan herir a les otres.
+
+ * Usamos lenguaje amable e inclusivo y mostramos conductas amables e
+ inclusivas.
+
+ * Respetamos los diferentes puntos de vista, experiencias, creencias,
+ etc. y lo tenemos en cuenta cuando estamos en grupo para verlo
+ reflejado en nuestras actitudes.
+
+ * Aceptamos las críticas. En especial las constructivas ;)
+
+ * Nos enfocamos en lo que es mejor para la comunidad, sin por ello
+ perder de vista la calidez, el respeto y la diversidad entre cada une
+ de nosotres.
+
+ * Mostramos empatía con les otres. Queremos comunicarnos y compartir.
+
+ * Es útil tener en cuenta que las personas tenemos capacidades,
+ historias, recorridos... diferentes. Es posible que algunos
+ comentarios no sean comprendidos. Trataremos de evitar la mala fe y
+ sumar todas las herramientas de accesibilidad para todas las personas.
+
+ * El punto anterior incluye a personas neurodiversas y con experiencias
+ de trauma. A veces el sarcasmo o la ironía no es bien recibido o
+ comprendido por todes. Será útil tenerlo en cuenta para buscar
+ estrategias que no excluyan a las personas de nuestros intercambios.
+ Por otro lado, si creemos que determinados temas pueden ser sensibles
+ (desencadenantes de recuerdos, fobias, difíciles de tolerar o cargados
+ de violencia o imágenes corporales muy explícitas, por ejemplo) para
+ algunas personas y nos valemos de las advertencias de contenido o
+ _content warning_ (cw) (ej: "cw: comentarios de violencia sexual y
+ violencia física") antes del contenido a introducir. Esto permite que
+ cada cual pueda elegir si acceder o no a esos contenidos y que no le
+ tomen por sorpresa.
+
+ * Respetamos los límites que establecen otras personas (espacio
+ personal, contacto físico, ganas de interactuar, no querer dar datos
+ de contacto o ser fotografiades, etc.)
+
+ * ¡Queremos y (creemos) en sumar piratas!
+
+ ## Consentimiento para documentar o compartir en medios
+
+ * Si vas a publicar video o fotos, obtené el consentimiento de las
+ personas.
+
+ * Si hay menores, consultalo con su familia responsable.
+
+ ## Nuestro compromiso contra el acoso
+
+ En el interés de fomentar una comunidad abierta, diversa y hospitalaria,
+ nosotres como contribuyentes y administradores nos comprometemos a hacer
+ de la participación en nuestro proyecto y nuestra comunidad una
+ experiencia libre de acoso para todes, independientemente de la edad,
+ diversidad corporal, capacidades, neuro-diversidad, etnia, identidad y
+ expresión de género, nivel de experiencia, nacionalidad, apariencia
+ física, raza, religión, identidad u orientación sexual y otras.
+
+ Ejemplos de comportamiento inaceptable por parte de participantes:
+
+ * Comentarios ofensivos relacionados con el/los género/s, la identidad
+ y expresión de género, la orientación sexual, las capacidades, las
+ enfermedades mentales, la neuro(a)tipicalidad, la apariencia física,
+ el tamaño corporal, la raza o la religión.
+
+ * Comentarios indeseados relacionados con las elecciones y las prácticas
+ de estilo de vida de una persona, incluidas, entre otras, las
+ relacionadas con alimentos, salud, crianza de les hijes, drogas y
+ empleo.
+
+ * Comentarios insultantes o despectivos (_trolling_) y ataques
+ personales o políticos.
+
+ * Dar por sentado el género de las demás personas. En caso de duda,
+ preguntá educadamente por los pronombres. No uses nombres con los que
+ las personas no se identifican, usá el nombre, _nickname_ o apodo que
+ hayan elegido (¿Realmente necesitás el nombre y el número de DNI,
+ datos biométricos, carta natal, etc.?).
+
+ * Comentarios, imágenes o comportamientos sexuales innecesarios o fuera
+ de lugar en espacios en los que no son apropiados.
+
+ * Contacto físico sin consentimiento o reiterado tras un pedido de cese.
+ En el mismo sentido, invasión del espacio corporal (y espacios en
+ general).
+
+ * Amenazas contra otras personas.
+
+ * Incitación a la violencia contra otra persona, que también incluye
+ alentar a una persona a autolesionarse.
+
+ * Intimidación deliberada.
+
+ * Acechar (_stalkear_) o perseguir.
+
+ * Acosar fotografiando o grabando sin consentimiento, incluyendo también
+ subir información personal a Internet sobre alguien para acosarle.
+
+ * Interrumpir constantemente en una conversación.
+
+ * Hacer comentarios sexuales indeseados.
+
+ * Patrones de contacto social inapropiados, como por ejemplo
+ pedir/suponer niveles de intimidad inapropiados con les demás.
+
+ * Seguir tratando de entablar conversación con una persona cuando se te
+ pidió que no lo hagas.
+
+ * Divulgar deliberadamente cualquier aspecto de la identidad de una
+ persona sin su consentimiento, excepto que sea necesario para proteger
+ a otras personas de abuso intencional.
+
+ * Hacer pública una conversación privada de cualquier tipo.
+
+ * Otros tipos de conducta que pudieran considerarse inapropiadas en un
+ entorno de camaradería.
+
+ * Reiteración de actitudes que les participantes señalen como ofensivas
+ o violatorias de este código.
+
+ ## Consecuencias
+
+ * Se espera que la persona a la que se la haya pedido que cese un
+ comportamiento que infringe este código acate el pedido de forma
+ inmediata, incluso si no está de acuerdo con este.
+
+ * Les administradores pueden tomar cualquier acción que juzguen
+ necesaria y adecuada, incluyendo expulsar a la persona o dar de baja
+ sus sitios sin advertencia. Esta decisión la toman les administradores
+ en consenso y se reserva para casos extremos que comprometan la
+ continuidad de la comunidad o bien la posibilidad de permanencia en
+ ella de otres participantes sin sentirse agraviades o amenazades.
+
+ * Les administradores se reservan el derecho a prohibir la asistencia a
+ cualquier actividad futura o publicación de sitios.
+
+ Como mencionamos antes, este código está en permanente mutación
+ colectiva. El objetivo principal es generar un ambiente inclusivo y no
+ expulsivo, un ambiente transparente y abierto en el que no haya
+ escalones faltantes ("el escalón que falta en la escalera y todo el
+ mundo sabe y avisa pero nadie se quiere hacer cargo"). Es importante que
+ se adapte a las actividades y se nutra de las contribuciones de les
+ usuaries. Recibir tus comentarios y aportes nos ayudará a cumplir con
+ su objetivo principal.
+
+ ## ¡Sigamos en contacto!
+
+ Si pasaste por alguna situación que quieras compartir --te hayas animado
+ o no a decirlo en el momento--, podés ponerte en contacto con nosotres.
+
+ Con respecto a quejas o avisos acerca de situaciones de violencia,
+ acoso, abuso o repetición de conductas que se advirtieron como
+ intolerables, tomamos la responsabilidad de tenerlas en cuenta y
+ trabajar en ellas para que el resultado sea el favorable al espíritu de
+ colectiva que elegimos y describimos aquí. Si bien consideramos que las
+ prácticas punitivistas no van con nosotres, nuestra decisión explícita
+ es escuchar a la persona que se manifiesta como violentada o víctima y
+ acompañarla.
diff --git a/db/seeds/designs.yml b/db/seeds/designs.yml
index 126a9b12..a04c99c1 100644
--- a/db/seeds/designs.yml
+++ b/db/seeds/designs.yml
@@ -6,6 +6,7 @@
disabled: true
description_en: "Upload your own theme. [This feature is in development, help us!](https://sutty.nl/en/#contact)"
description_es: "Subir tu propio diseño. [Esta posibilidad está en desarrollo, ¡ayudanos!](https://sutty.nl/#contacto)"
+ priority: '0'
- name_en: 'I want you to develop a site for me'
name_es: 'Quiero que desarrollen mi sitio'
gem: 'sutty-theme-custom'
@@ -13,6 +14,7 @@
disabled: true
description_en: "If you want us to develop your site, you're welcome to [contact us!](https://sutty.nl/en/#contact) :)"
description_es: "Si querés que desarrollemos tu sitio, [escribinos](https://sutty.nl/#contacto) :)"
+ priority: '2'
- name_en: 'Minima'
name_es: 'Mínima'
gem: 'sutty-minima'
@@ -20,15 +22,17 @@
description_en: "Sutty Minima is based on [Minima](https://jekyll.github.io/minima/), a blog-focused theme for Jekyll."
description_es: 'Sutty Mínima es una plantilla para blogs basada en [Mínima](https://jekyll.github.io/minima/).'
license: 'https://0xacab.org/sutty/jekyll/minima/-/blob/master/LICENSE.txt'
+ priority: '100'
- name_en: 'Sutty'
name_es: 'Sutty'
gem: 'sutty-jekyll-theme'
- url: 'https://rubygems.org/gems/sutty-jekyll-theme/'
+ url: "https://anarres.sutty.nl"
description_en: "The Sutty design"
description_es: 'El diseño de Sutty'
license: 'https://0xacab.org/sutty/jekyll/sutty-jekyll-theme/-/blob/master/LICENSE.txt'
credits_es: 'Sutty es parte de la economía solidaria :)'
credits_en: 'Sutty is a solidarity economy project!'
+ priority: '90'
- name_en: 'Self-managed Book Publisher'
name_es: 'Editorial Autogestiva'
gem: 'editorial-autogestiva-jekyll-theme'
@@ -38,6 +42,7 @@
license: 'https://0xacab.org/sutty/jekyll/editorial-autogestiva-jekyll-theme/-/blob/master/LICENSE.txt'
credits_es: 'Esta plantilla fue inspirada en el trabajo de las [editoriales autogestivas](https://sutty.nl/plantillas-para-crear-cat%C3%A1logos-de-editoriales-autogestivas/)'
credits_en: 'This theme is inspired by [independent publishing projects](https://sutty.nl/en/new-template-for-publishing-projects/)'
+ priority: '50'
- name_en: 'Donations'
name_es: 'Donaciones'
gem: 'sutty-donaciones-jekyll-theme'
@@ -47,6 +52,7 @@
license: 'https://0xacab.org/sutty/jekyll/sutty-donaciones-jekyll-theme/-/blob/master/LICENSE.txt'
credits_es: 'Diseñamos esta plantilla para [visibilizar campañas de donaciones](https://sutty.nl/plantilla-para-donaciones/) durante la cuarentena.'
credits_en: 'We designed this theme to increase [visibility for donation requests](https://sutty.nl/template-for-donations/) during the quarantine.'
+ priority: '80'
- name_en: 'Support campaign'
name_es: 'Adhesiones'
gem: 'adhesiones-jekyll-theme'
@@ -57,6 +63,7 @@
credits_es: 'Desarrollamos esta plantilla junto con [Librenauta](https://sutty.nl/plantilla-para-campa%C3%B1as-de-adhesiones/)'
credits_en: 'This template was made in collaboration with Librenauta'
designer_url: 'https://copiona.com/donaunbit/'
+ priority: '60'
- name_en: 'Community Radio'
name_es: 'Radio comunitaria'
gem: 'radios-comunitarias-jekyll-theme'
@@ -67,6 +74,7 @@
credits_es: 'Desarrollamos esta plantilla junto con Librenauta en 15 horas :)'
credits_en: 'This template was made in collaboration with Librenauta in 15 hours!'
designer_url: 'https://copiona.com/donaunbit/'
+ priority: '70'
- name_en: 'Resource toolkit'
name_es: 'Recursero'
gem: 'recursero-jekyll-theme'
@@ -74,10 +82,12 @@
disabled: true
description_en: "We're working towards adding more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)"
description_es: "Estamos trabajando para que puedas tener más diseños. [¡Escribinos!](https://sutty.nl/#contacto)"
-- name_en: 'Other themes'
- name_es: 'Mi propio diseño'
+ priority: '3'
+- name_en: 'More themes'
+ name_es: 'Más plantillas'
gem: 'sutty-theme-own'
url: 'https://jekyllthemes.org'
disabled: true
description_en: "We're working towards adding more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)"
description_es: "Estamos trabajando para que puedas tener más diseños. [¡Escribinos!](https://sutty.nl/#contacto)"
+ priority: '1'
diff --git a/db/seeds/licencias.yml b/db/seeds/licencias.yml
index cbe3bace..f6b76296 100644
--- a/db/seeds/licencias.yml
+++ b/db/seeds/licencias.yml
@@ -1,6 +1,19 @@
---
+- name_en: "Custom license"
+ name_es: "Licencia personalizada"
+ url_en: ""
+ url_es: ""
+ icons: "custom"
+ short_description_en: ""
+ short_description_es: ""
+ description_en: "The license terms are provided by you."
+ description_es: "Los términos de la licencia fueron provistos por vos."
+ deed_en: ""
+ deed_es: ""
- name_en: 'Peer Production License'
name_es: 'Licencia de Producción de Pares'
+ short_description_en: "This work is licensed under a Peer Production License"
+ short_description_es: "Esta obra está bajo una Licencia de Producción de Pares"
icons: "/images/ppl.png"
url_en: 'https://wiki.p2pfoundation.net/Peer_Production_License'
url_es: 'https://endefensadelsl.org/ppl_es.html'
@@ -100,6 +113,8 @@
hacerlo es enlazar a esta página.
- icons: "/images/by.png"
+ short_description_en: "This work is licensed under a Creative Commons Attribution 4.0 International License."
+ short_description_es: "Esta obra está bajo una Licencia Creative Commons Atribución 4.0 Internacional."
name_en: 'Creative Commons Attribution 4.0 International (CC BY 4.0)'
description_en: "This license gives everyone the freedom to use,
adapt, and redistribute the contents of your site by requiring
@@ -194,6 +209,8 @@
- icons: "/images/sa.png"
name_en: "Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)"
name_es: "Creative Commons Atribución-CompartirIgual 4.0 Internacional (CC BY-SA 4.0)"
+ short_description_en: "This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License."
+ short_description_es: "Esta obra está bajo una Licencia Creative Commons Atribución-CompartirIgual 4.0 Internacional."
url_en: 'https://creativecommons.org/licenses/by-sa/4.0/'
url_es: 'https://creativecommons.org/licenses/by-sa/4.0/deed.es'
description_en: "This license is the same as the CC-BY 4.0 but it adds
diff --git a/db/seeds/privacy_policies.yml b/db/seeds/privacy_policies.yml
new file mode 100644
index 00000000..98ce8379
--- /dev/null
+++ b/db/seeds/privacy_policies.yml
@@ -0,0 +1,113 @@
+---
+- title_en: "Privacy Policy"
+ title_es: "Políticas de privacidad"
+ description_en: "With what care does this site handles personal data of its users and visitors?"
+ description_es: "¿Cuáles son los cuidados de este sitio con respecto a sus usuaries y visitantes?"
+ content_en: |
+ > We use "them" as neutral pronoun to refer to people regardless of
+ > gender identity.
+
+ This document details Sutty's privacy policy, including web site,
+ platform, other infrastructure (support channels, etc.) and web sites
+ generated by users.
+
+ ## This is too long!
+
+ * Sutty doesn't collect any kind of personal data.
+
+ * Sutty may only collect statistical data that doesn't identify
+ individuals.
+
+ ## Analytic data
+
+ Sutty may only collect data for analytics (number of visits, duration,
+ etc.), not associated to personal data.
+
+ Analytical data collected for every web site can only be used internally
+ by Sutty. Sutty doesn't share any data privately with any third
+ parties. Selected analytical data could be used publicly.
+
+ Sutty doesn't recommend personal data collection in any way, but it
+ doesn't monitor if its users use third party services with their own
+ privacy policies. We recommend users and visitors to inform themselves
+ before using third parties analytics services.
+
+ ## No personal data collection
+
+ Sutty doesn't collect IP addresses from users nor visitors in any way.
+
+ Sutty doesn't ask for personal data for registering user accounts in its
+ platform.
+
+ Sutty only uses session "cookies" to identify users during their use of
+ the platform. It doesn't use "cookies" to identify visitors of web
+ sites hosted by Sutty.
+
+ The only exception where Sutty could collect personal data is during
+ service payment. Digital safety measures will be taken to keep this
+ information and to discard it if possible after needed.
+
+ Users will be notified when their personal data is removed.
+
+ If users decide to host their web sites with third parties, they must
+ inform themselves about the corresponding privacy policies. Sutty only
+ recommends third parties with privacy policies compatible with these.
+ content_es: |
+ > Utilizamos la e como pronombre neutro para referirnos a personas
+ > independientemente de su identidad de género, por ejemplo “usuarie”.
+
+ Este documento detalla la política de privacidad de Sutty, incluyendo
+ sitio web, plataforma de edición, infraestructura relacionada (salas de
+ chat, etc.) y sitios creados por sus usuaries a través de la plataforma,
+ en adelante "Sutty".
+
+ ## ¡Esto es demasiado largo!
+
+ Un resumen:
+
+ * Sutty no recolecta datos personales de ningún tipo
+
+ * Sutty solo recolectaría datos analíticos que no identifican a
+ personas
+
+ ## Datos analíticos
+
+ La única recolección de datos realizada por Sutty es con fines
+ analíticos (cantidad de visitas, duración, etc.), no asociados a datos
+ personales.
+
+ Los datos analíticos recolectados por cada sitio podrán ser utilizados
+ internamente por Sutty. Sutty no comparte datos analíticos con
+ terceros en forma privada. Datos analíticos seleccionados podrán ser
+ utilizados públicamente.
+
+ Sutty no recomienda la recolección de datos personales de ninguna forma,
+ pero no monitorea que les usuaries utilicen servicios de terceros con
+ sus propias políticas de privacidad. Recomendamos a les usuaries y
+ visitantes informarse antes de utilizar servicios de estadísticas de
+ terceros.
+
+ ## No registro de datos personales
+
+ Sutty no registra direcciones IP de usuaries ni de visitantes de ninguna
+ forma.
+
+ Sutty no solicita datos personales para el registro de cuentas de
+ usuarie en su plataforma.
+
+ Sutty solo utiliza “cookies” de sesión para identificar usuaries
+ mientras utilicen la plataforma. No se utilizan “cookies” para
+ identificar visitantes a los sitios alojados por Sutty.
+
+ El único caso en el que Sutty podría solicitar datos personales es
+ durante el pago de servicios. Se tomarán medidas de seguridad digital
+ para salvaguardar esta información y descartar lo que sea posible una
+ vez que ya no sea necesaria.
+
+ Se notificará a les usuaries cuando su información personal sea
+ eliminada.
+
+ Si les usuaries deciden alojar sus sitios con terceros, deberán
+ informarse de las políticas de privacidad correspondientes. Sutty
+ recomienda servicios de terceros con políticas de privacidad coherentes
+ con estas.
diff --git a/lib/tasks/cleanup.rake b/lib/tasks/cleanup.rake
new file mode 100644
index 00000000..e14693bc
--- /dev/null
+++ b/lib/tasks/cleanup.rake
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+namespace :cleanup do
+ desc 'Cleanup sites'
+ task everything: :environment do
+ before = ENV.fetch('BEFORE', '30').to_i.days.ago
+ service = CleanupService.new(before: before)
+
+ service.cleanup_everything!
+ end
+end
diff --git a/lib/tasks/distributed_press.rake b/lib/tasks/distributed_press.rake
new file mode 100644
index 00000000..8ba270ec
--- /dev/null
+++ b/lib/tasks/distributed_press.rake
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+namespace :distributed_press do
+ namespace :tokens do
+ desc 'Renew tokens'
+ task renew: :environment do
+ RenewDistributedPressTokensJob.perform_now
+ end
+ end
+end
diff --git a/monit.conf b/monit.conf
index 83d17449..0bd18907 100644
--- a/monit.conf
+++ b/monit.conf
@@ -1,29 +1,12 @@
-check process sutty with pidfile /srv/tmp/puma.pid
- start program = "/usr/local/bin/sutty start"
- stop program = "/usr/local/bin/sutty stop"
-
-check process prometheus with pidfile /tmp/prometheus.pid
- start program = "/usr/local/bin/sutty prometheus start"
- stop program = "/usr/local/bin/sutty prometheus start"
-
-check program blazer_5m
- with path "/usr/local/bin/sutty blazer 5m"
- every 5 cycles
+# Limpiar mensualmente
+check program cleanup
+ with path "/usr/bin/foreman run -f /srv/Procfile -d /srv cleanup" as uid "rails" gid "www-data"
+ every "0 3 1 * *"
if status != 0 then alert
-check program blazer_1h
- with path "/usr/local/bin/sutty blazer 1h"
- every 60 cycles
- if status != 0 then alert
-
-check program blazer_1d
- with path "/usr/local/bin/sutty blazer 1d"
- every 1440 cycles
- if status != 0 then alert
-
-check program blazer
- with path "/usr/local/bin/sutty blazer"
- every 61 cycles
+check program distributed_press_tokens_renew
+ with path "/usr/bin/foreman run -f /srv/Procfile -d /srv distributed_press_tokens_renew" as uid "rails" gid "www-data"
+ every "0 3 * * *"
if status != 0 then alert
check program access_logs
@@ -35,3 +18,8 @@ check program stats
with path "/usr/bin/foreman run -f /srv/Procfile -d /srv stats" as uid "rails" gid "www-data"
every "0 1 * * *"
if status != 0 then alert
+
+check program distributed_press_tokens_renew
+ with path "/usr/bin/foreman run -f /srv/Procfile -d /srv distributed_press_tokens_renew" as uid "rails" gid "www-data"
+ every "0 3 * * *"
+ if status != 0 then alert
diff --git a/package.json b/package.json
index 4b81ff15..2beb5589 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,6 @@
{
"name": "sutty",
+ "author": "Sutty ",
"private": true,
"dependencies": {
"@airbrake/browser": "^1.4.1",
diff --git a/public/500.html b/public/500.html
index 2d69baf6..9e8ea780 100644
--- a/public/500.html
+++ b/public/500.html
@@ -19,7 +19,11 @@