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

Gracias por ayudarnos a encontrar errores :)

-

Volver al panel

+

+ Volver al panel + | + Contáctanos +

@@ -31,7 +35,11 @@

Thanks for helping us in finding errors! :)

-

Go back to panel

+

+ Go back to panel + | + Contact us +