5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-16 16:21:41 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into issue-10491
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
f 2023-04-10 12:14:21 -03:00
commit e4fdd611ee
102 changed files with 1440 additions and 282 deletions

View file

@ -2,3 +2,4 @@
* *
# Solo agregar lo que usamos en COPY # Solo agregar lo que usamos en COPY
# !./archivo # !./archivo
!./monit.conf

View file

@ -1,7 +1,9 @@
# pwgen -1 32
RAILS_MASTER_KEY=11111111111111111111111111111111
RAILS_GROUPS=assets RAILS_GROUPS=assets
DELEGATE=athshe.sutty.nl DELEGATE=athshe.sutty.nl
HAINISH=../haini.sh/haini.sh HAINISH=../haini.sh/haini.sh
DATABASE= DATABASE_URL=postgres://suttier@postgresql.sutty.local/sutty
RAILS_ENV=development RAILS_ENV=development
IMAP_SERVER= IMAP_SERVER=
DEFAULT_FROM= DEFAULT_FROM=

71
.woodpecker.yml Normal file
View file

@ -0,0 +1,71 @@
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}"
- "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"
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"

View file

@ -1,5 +1,9 @@
FROM registry.nulo.in/sutty/rails:3.13.6-2.7.5 ARG RUBY_VERSION=2.7
ARG PANDOC_VERSION=2.17.1.1 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 ENV RAILS_ENV production
# Instalar las dependencias, separamos la librería de base de datos para # Instalar las dependencias, separamos la librería de base de datos para
@ -10,10 +14,13 @@ ENV RAILS_ENV production
# principal # principal
RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \ RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \ 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 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 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
VOLUME "/srv" VOLUME "/srv"

View file

@ -38,6 +38,8 @@ gem 'commonmarker'
gem 'devise' gem 'devise'
gem 'devise-i18n' gem 'devise-i18n'
gem 'devise_invitable' gem 'devise_invitable'
gem 'distributed-press-api-client', '~> 0.2.2'
gem 'njalla-api-client'
gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n' gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n'
gem 'exception_notification' gem 'exception_notification'
gem 'fast_blank' gem 'fast_blank'
@ -89,7 +91,7 @@ gem 'stackprof'
gem 'prometheus_exporter' gem 'prometheus_exporter'
# debug # debug
gem 'fast_jsonparser' gem 'fast_jsonparser', '~> 0.5.0'
gem 'down' gem 'down'
gem 'sourcemap' gem 'sourcemap'
gem 'rack-cors' gem 'rack-cors'

View file

@ -115,6 +115,7 @@ GEM
xpath (>= 2.0, < 4.0) xpath (>= 2.0, < 4.0)
chartkick (4.1.2) chartkick (4.1.2)
childprocess (4.1.0) childprocess (4.1.0)
climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
colorator (1.1.0) colorator (1.1.0)
commonmarker (0.21.2-x86_64-linux-musl) commonmarker (0.21.2-x86_64-linux-musl)
@ -153,12 +154,45 @@ GEM
devise_invitable (2.0.5) devise_invitable (2.0.5)
actionmailer (>= 5.0) actionmailer (>= 5.0)
devise (>= 4.6) 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 (2.7.6)
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
down (5.2.4) down (5.2.4)
addressable (~> 2.8) 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) ed25519 (1.2.4-x86_64-linux-musl)
em-websocket (0.5.3) em-websocket (0.5.3)
eventmachine (>= 0.12.9) eventmachine (>= 0.12.9)
@ -216,8 +250,8 @@ GEM
thor thor
hiredis (0.6.3-x86_64-linux-musl) hiredis (0.6.3-x86_64-linux-musl)
http_parser.rb (0.8.0-x86_64-linux-musl) http_parser.rb (0.8.0-x86_64-linux-musl)
httparty (0.18.1) httparty (0.21.0)
mime-types (~> 3.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
i18n (1.8.11) i18n (1.8.11)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@ -261,7 +295,7 @@ GEM
jekyll (~> 4) jekyll (~> 4)
jekyll-ignore-layouts (0.1.2) jekyll-ignore-layouts (0.1.2)
jekyll (~> 4) jekyll (~> 4)
jekyll-images (0.3.0) jekyll-images (0.3.2)
jekyll (~> 4) jekyll (~> 4)
ruby-filemagic (~> 0.7) ruby-filemagic (~> 0.7)
ruby-vips (~> 2) ruby-vips (~> 2)
@ -292,6 +326,7 @@ GEM
jekyll-write-and-commit-changes (0.2.1) jekyll-write-and-commit-changes (0.2.1)
jekyll (~> 4) jekyll (~> 4)
rugged (~> 1) rugged (~> 1)
jwt (2.6.0)
kaminari (1.2.1) kaminari (1.2.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1) kaminari-actionview (= 1.2.1)
@ -352,6 +387,9 @@ GEM
nokogiri (1.12.5-x86_64-linux-musl) nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1) mini_portile2 (~> 2.6.1)
racc (~> 1.4) racc (~> 1.4)
njalla-api-client (0.1.0)
dry-schema
httparty (~> 0.18)
orm_adapter (0.5.0) orm_adapter (0.5.0)
parallel (1.21.0) parallel (1.21.0)
parser (3.0.2.0) parser (3.0.2.0)
@ -578,6 +616,7 @@ DEPENDENCIES
devise devise
devise-i18n devise-i18n
devise_invitable devise_invitable
distributed-press-api-client (~> 0.2.2)
dotenv-rails dotenv-rails
down down
ed25519 ed25519
@ -612,6 +651,7 @@ DEPENDENCIES
mini_magick mini_magick
mobility mobility
net-ssh net-ssh
njalla-api-client
nokogiri nokogiri
pg pg
pg_search pg_search

View file

@ -5,4 +5,6 @@ blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day" blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_" 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 stats: bundle exec rake stats:process_all

View file

@ -25,6 +25,10 @@ $spacers: (
2-plus: 0.75rem 2-plus: 0.75rem
); );
$sizes: (
"70ch": 70ch,
);
@import "bootstrap"; @import "bootstrap";
@import "editor"; @import "editor";
@ -410,6 +414,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
@each $prop, $abbrev in (width: w, height: h) { @each $prop, $abbrev in (width: w, height: h) {
@each $size, $length in $sizes { @each $size, $length in $sizes {
.#{$abbrev}-#{$grid-breakpoint}-#{$size} { #{$prop}: $length !important; } .#{$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; }
} }
} }

View file

@ -15,7 +15,7 @@ module Api
params: airbrake_params.to_h params: airbrake_params.to_h
end end
render status: 201, json: { id: 1, url: root_url } render status: 201, json: { id: 1, url: '' }
end end
private private

View file

@ -9,7 +9,7 @@ module Api
# Lista de nombres de dominios a emitir certificados # Lista de nombres de dominios a emitir certificados
def index def index
render json: sites_names + alternative_names + api_names render json: sites_names + alternative_names + api_names + www_names
end end
# Sitios con hidden service de Tor # Sitios con hidden service de Tor
@ -28,7 +28,7 @@ module Api
site = Site.find_by(name: params[:name]) site = Site.find_by(name: params[:name])
if site if site
usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor' usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie, service = SiteService.new site: site, usuarie: usuarie,
params: params params: params
service.add_onion service.add_onion
@ -39,14 +39,22 @@ module Api
private private
def canonicalize(name)
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
end
# Nombres de los sitios # Nombres de los sitios
def sites_names def sites_names
Site.all.order(:name).pluck(:name) Site.all.order(:name).pluck(:name).map do |name|
canonicalize name
end
end end
# Dominios alternativos # Dominios alternativos
def alternative_names def alternative_names
DeployAlternativeDomain.all.map(&:hostname) (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
canonicalize name
end
end end
# Obtener todos los sitios con API habilitada, es decir formulario # Obtener todos los sitios con API habilitada, es decir formulario
@ -56,7 +64,16 @@ module Api
def api_names def api_names
Site.where(contact: true) Site.where(contact: true)
.or(Site.where(colaboracion_anonima: 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 end
end end

View file

@ -46,17 +46,19 @@ class ApplicationController < ActionController::Base
# defecto. # defecto.
# #
# Esto se refiere al idioma de la interfaz, no de los artículos. # 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 end
# El idioma es el preferido por le usuarie, pero no necesariamente se # El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer # corresponde con el idioma de los artículos, porque puede querer
# traducirlos. # traducirlos.
def set_locale(&action) def set_locale(&action)
I18n.with_locale(current_locale(include_params: false), &action) I18n.with_locale(current_locale, &action)
end end
# Muestra una página 404 # Muestra una página 404
@ -88,4 +90,12 @@ class ApplicationController < ActionController::Base
def prepare_exception_notifier def prepare_exception_notifier
request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie } request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie }
end 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 end

View file

@ -12,7 +12,7 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie # Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options def default_url_options
{ locale: current_locale } { locale: locale }
end end
def index def index

View file

@ -68,9 +68,7 @@ class SitesController < ApplicationController
def enqueue def enqueue
authorize site authorize site
# XXX: Convertir en una máquina de estados? SiteService.new(site: site).deploy
site.enqueue!
DeployJob.perform_async site.id
redirect_to site_posts_path(site, locale: site.default_locale) redirect_to site_posts_path(site, locale: site.default_locale)
end end

View file

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

View file

@ -3,9 +3,14 @@
# Realiza el deploy de un sitio # Realiza el deploy de un sitio
class DeployJob < ApplicationJob class DeployJob < ApplicationJob
class DeployException < StandardError; end class DeployException < StandardError; end
class DeployTimedOutException < DeployException; end
discard_on ActiveRecord::RecordNotFound
# rubocop:disable Metrics/MethodLength # 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 ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site) @site = Site.find(site)
@ -16,32 +21,39 @@ class DeployJob < ApplicationJob
# hora original para poder ir haciendo timeouts. # hora original para poder ir haciendo timeouts.
if @site.building? if @site.building?
if 10.minutes.ago >= time if 10.minutes.ago >= time
@site.update status: 'waiting' notify = false
raise DeployException, raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" "#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
end end
DeployJob.perform_in(60, site, notify, time) DeployJob.perform_in(60, site, notify: notify, time: time, output: output)
return return
end end
@site.update status: 'building' @site.update status: 'building'
# Asegurarse que DeployLocal sea el primero! # Asegurarse que DeployLocal sea el primero!
@deployed = { deploy_local: deploy_locally } @deployed = {
deploy_local: {
status: deploy_locally,
seconds: deploy_local.build_stats.last.seconds,
size: deploy_local.size,
urls: [deploy_local.url]
}
}
# No es opcional # No es opcional
unless @deployed[:deploy_local] unless @deployed[:deploy_local][:status]
@site.update status: 'waiting'
notify_usuaries if notify
# Hacer fallar la tarea # Hacer fallar la tarea
raise DeployException, deploy_local.build_stats.last.log raise DeployException, "#{@site.name}: Falló la compilación"
end end
deploy_others deploy_others
rescue DeployTimedOutException => e
# Volver a la espera notify_exception e
@site.update status: 'waiting' rescue DeployException => e
notify_exception e, deploy_local
ensure
@site&.update status: 'waiting'
notify_usuaries if notify notify_usuaries if notify
end end
@ -50,17 +62,44 @@ class DeployJob < ApplicationJob
private private
# @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
}
ExceptionNotifier.notify_exception(exception, data: data)
end
def deploy_local def deploy_local
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal') @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end end
def deploy_locally def deploy_locally
deploy_local.deploy deploy_local.deploy(output: @output)
end end
def deploy_others def deploy_others
@site.deploys.where.not(type: 'DeployLocal').find_each do |d| @site.deploys.where.not(type: 'DeployLocal').find_each do |d|
@deployed[d.type.underscore.to_sym] = d.deploy begin
status = d.deploy(output: @output)
seconds = d.build_stats.last.try(:seconds)
rescue StandardError => e
status = false
seconds = 0
notify_exception e, d
end
@deployed[d.type.underscore.to_sym] = {
status: status,
seconds: seconds || 0,
size: d.size,
urls: d.respond_to?(:urls) ? d.urls : [d.url].compact
}
end end
end end

View file

@ -3,6 +3,8 @@
# Notifica excepciones a una instancia de Gitlab, como incidencias # Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes. # nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob class GitlabNotifierJob < ApplicationJob
class GitlabNotifierError < StandardError; end
include ExceptionNotifier::BacktraceCleaner include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego # Variables que vamos a acceder luego
@ -18,22 +20,28 @@ class GitlabNotifierJob < ApplicationJob
@issue_data = { count: 1 } @issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía # Necesitamos saber si el issue ya existía
@cached = false @cached = false
@issue = {}
# Traemos los datos desde la caché si existen, sino generamos un # Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché # issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do @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 @cached = true
{ {
count: 1, count: 1,
issue: issue['iid'], issue: @issue['iid'],
user_agents: [user_agent].compact, user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact, params: [request&.filtered_parameters].compact,
urls: [url].compact urls: [url].compact
} }
end end
unless @issue['iid']
Rails.cache.delete(cache_key)
raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ')
end
# No seguimos actualizando si acabamos de generar el issue # No seguimos actualizando si acabamos de generar el issue
return if cached return if cached
@ -104,6 +112,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String] # @return [String]
def description def description
@description ||= ''.dup.tap do |d| @description ||= ''.dup.tap do |d|
d << log_section
d << request_section d << request_section
d << javascript_section d << javascript_section
d << javascript_footer d << javascript_footer
@ -151,6 +160,19 @@ class GitlabNotifierJob < ApplicationJob
@client ||= GitlabApiClient.new @client ||= GitlabApiClient.new
end end
# @return [String]
def log_section
return '' unless options[:log]
<<~LOG
# Log
```
#{options[:log]}
```
LOG
end
# Muestra información de la petición # Muestra información de la petición
# #
# @return [String] # @return [String]

View file

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

View file

@ -20,6 +20,18 @@ module ActiveStorage
end end
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
IO.copy_stream(io, make_path_for(key)) unless exist?(key)
ensure_integrity_of(key, checksum) if checksum
end
end
# Lo mismo que en DiskService agregando el nombre de archivo en la # Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde # firma. Esto permite que luego podamos guardar el archivo donde
# corresponde. # corresponde.
@ -67,7 +79,9 @@ module ActiveStorage
# @param :key [String] # @param :key [String]
# @return [String] # @return [String]
def filename_for(key) def filename_for(key)
ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first.tap do |filename|
raise ArgumentError, "Filename for key #{key} is blank" if filename.blank?
end
end end
# Crea una ruta para la llave con un nombre conocido. # Crea una ruta para la llave con un nombre conocido.

View file

@ -8,21 +8,66 @@
# TODO: Agregar firma GPG y header Autocrypt # TODO: Agregar firma GPG y header Autocrypt
# TODO: Cifrar con GPG si le usuarie nos dio su llave # TODO: Cifrar con GPG si le usuarie nos dio su llave
class DeployMailer < ApplicationMailer class DeployMailer < ApplicationMailer
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
# rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/AbcSize
def deployed(which_ones) def deployed(deploys = {})
@usuarie = Usuarie.find(params[:usuarie]) usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site]) site = usuarie.sites.find(params[:site])
@deploys = which_ones hostname = site.hostname
@deploy_local = @site.deploys.find_by(type: 'DeployLocal') deploys ||= {}
# Informamos a cada quien en su idioma y damos una dirección de # Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben # respuesta porque a veces les usuaries nos escriben
I18n.with_locale(@usuarie.lang) do I18n.with_locale(usuarie.lang) do
mail(to: @usuarie.email, subject = t('.subject', site: site.name)
reply_to: "sutty@#{Site.domain}",
subject: I18n.t('deploy_mailer.deployed.subject', @hi = t('.hi')
site: @site.name)) @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
end end
# rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/AbcSize
private
def t(key, **args)
I18n.t("deploy_mailer.deployed#{key}", **args)
end
end end

View file

@ -11,7 +11,11 @@ class Deploy < ApplicationRecord
belongs_to :site belongs_to :site
has_many :build_stats, dependent: :destroy has_many :build_stats, dependent: :destroy
def deploy def deploy(**)
raise NotImplementedError
end
def url
raise NotImplementedError raise NotImplementedError
end end
@ -23,6 +27,9 @@ class Deploy < ApplicationRecord
raise NotImplementedError raise NotImplementedError
end end
# Realizar tareas de limpieza.
def cleanup!; end
def time_start def time_start
@start = Time.now @start = Time.now
end end
@ -39,6 +46,7 @@ class Deploy < ApplicationRecord
site.path site.path
end end
# XXX: Ver DeployLocal#bundle
def gems_dir def gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name) @gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end end
@ -48,20 +56,26 @@ class Deploy < ApplicationRecord
# #
# @param [String] # @param [String]
# @return [Boolean] # @return [Boolean]
def run(cmd) def run(cmd, output: false)
r = nil r = nil
lines = [] lines = []
time_start time_start
Dir.chdir(site.path) do Dir.chdir(site.path) do
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| 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? # TODO: Enviar a un websocket para ver el proceso en vivo?
Thread.new do
o.each do |line| o.each do |line|
lines << line lines << line
puts line if output
end end
rescue IOError => e
lines << e.message
puts e.message if output
end
r = t.value
end end
end end
time_stop time_stop
@ -75,6 +89,13 @@ class Deploy < ApplicationRecord
r&.success? r&.success?
end end
# Variables de entorno
#
# @return [Hash]
def local_env
@local_env ||= {}
end
private private
# @param [String] # @param [String]
@ -82,4 +103,12 @@ class Deploy < ApplicationRecord
def readable_cmd(cmd) def readable_cmd(cmd)
cmd.split(' -', 2).first.tr(' ', '_') cmd.split(' -', 2).first.tr(' ', '_')
end 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 end

View file

@ -5,7 +5,7 @@ class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON store :values, accessors: %i[hostname], coder: JSON
# Generar un link simbólico del sitio principal al alternativo # Generar un link simbólico del sitio principal al alternativo
def deploy def deploy(**)
File.symlink?(destination) || File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero? File.symlink(site.hostname, destination).zero?
end end
@ -18,6 +18,10 @@ class DeployAlternativeDomain < Deploy
end end
def destination def destination
File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, '')) @destination ||= File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
end
def url
"https://#{File.basename destination}"
end end
end end

View file

@ -0,0 +1,166 @@
# 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!
# 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! if remote_info['njalla'].blank?
save
if remote_site_id.blank? || remote_info['njalla'].blank?
raise DeployJob::DeployException, ''
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
update remote_info: c.show(publishing_site).to_h
status = c.publish(publishing_site, deploy_local.destination)
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
remote_info[:links].values.map do |protocol|
[ protocol[:link], protocol[:gateway] ]
end.flatten.compact.select do |link|
link.include? '://'
end
end
private
# 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.first
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 = created_site.to_h
rescue DistributedPress::V1::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
ensure
nil
end
# Crea los registros en Njalla
#
# @return [nil]
def create_njalla_records!
# XXX: Esto depende de nuestro DNS actual, cuando lo migremos hay
# que eliminarlo.
unless site.name.end_with? '.'
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']['ns'] = njalla.add_record(name: "_dnslink.#{site.name}", type: 'NS', content: "#{publisher.hostname}.").to_h
end
rescue HTTParty::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
self.remote_info['njalla'] = nil
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
# Actualizar registros en Njalla
#
# @return [Njalla::V1::Domain]
def njalla
@njalla ||=
begin
client = Njalla::V1::Client.new(token: ENV['NJALLA_TOKEN'])
Njalla::V1::Domain.new(domain: Site.domain, client: client)
end
end
end

View file

@ -2,7 +2,7 @@
# Genera una versión onion # Genera una versión onion
class DeployHiddenService < DeployWww class DeployHiddenService < DeployWww
def deploy def deploy(**)
return true if fqdn.blank? return true if fqdn.blank?
super super
@ -13,6 +13,6 @@ class DeployHiddenService < DeployWww
end end
def url def url
'http://' + fqdn "http://#{fqdn}"
end end
end end

View file

@ -12,12 +12,13 @@ class DeployLocal < Deploy
# #
# Pasamos variables de entorno mínimas para no filtrar secretos de # Pasamos variables de entorno mínimas para no filtrar secretos de
# Sutty # Sutty
def deploy def deploy(output: false)
return false unless mkdir return false unless mkdir
return false unless yarn return false unless yarn(output: output)
return false unless bundle return false unless pnpm(output: output)
return false unless bundle(output: output)
jekyll_build jekyll_build(output: output)
end end
# Sólo permitimos un deploy local # Sólo permitimos un deploy local
@ -25,6 +26,10 @@ class DeployLocal < Deploy
1 1
end end
def url
site.url
end
# Obtener el tamaño de todos los archivos y directorios (los # Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :) # directorios son archivos :)
def size def size
@ -45,6 +50,17 @@ class DeployLocal < Deploy
File.join(Rails.root, '_deploy', site.hostname) File.join(Rails.root, '_deploy', site.hostname)
end 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 private
def mkdir def mkdir
@ -52,11 +68,14 @@ class DeployLocal < Deploy
end end
# Un entorno que solo tiene lo que necesitamos # Un entorno que solo tiene lo que necesitamos
#
# @return [Hash]
def env def env
# XXX: This doesn't support Windows paths :B # 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']
{ # Las variables de entorno extra no pueden superponerse al local.
extra_env.merge({
'HOME' => home_dir, 'HOME' => home_dir,
'PATH' => paths.join(':'), 'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key, 'SPREE_API_KEY' => site.tienda_api_key,
@ -66,13 +85,17 @@ class DeployLocal < Deploy
'JEKYLL_ENV' => Rails.env, 'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG'], 'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir 'YARN_CACHE_FOLDER' => yarn_cache_dir
} })
end end
def yarn_cache_dir def yarn_cache_dir
Rails.root.join('_yarn_cache').to_s Rails.root.join('_yarn_cache').to_s
end end
def pnpm_cache_dir
Rails.root.join('_pnpm_cache').to_s
end
def yarn_lock def yarn_lock
File.join(site.path, 'yarn.lock') File.join(site.path, 'yarn.lock')
end end
@ -81,23 +104,38 @@ class DeployLocal < Deploy
File.exist? yarn_lock File.exist? yarn_lock
end end
def gem def pnpm_lock
run %(gem install bundler --no-document) File.join(site.path, 'pnpm-lock.yaml')
end
def pnpm_lock?
File.exist? pnpm_lock
end
def gem(output: false)
run %(gem install bundler --no-document), output: output
end end
# Corre yarn dentro del repositorio # Corre yarn dentro del repositorio
def yarn def yarn(output: false)
return true unless yarn_lock? return true unless yarn_lock?
run 'yarn install --production' run 'yarn install --production', output: output
end end
def bundle def pnpm(output: false)
run %(bundle install --no-cache --path="#{gems_dir}") return true unless pnpm_lock?
run %(pnpm config set store-dir "#{pnpm_cache_dir}"), output: output
run 'pnpm install --production', output: output
end end
def jekyll_build def bundle(output: false)
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") run %(bundle install --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 end
# no debería haber espacios ni caracteres especiales, pero por si # no debería haber espacios ni caracteres especiales, pero por si
@ -110,4 +148,17 @@ class DeployLocal < Deploy
def remove_destination! def remove_destination!
FileUtils.rm_rf destination FileUtils.rm_rf destination
end end
# Consigue todas las variables de entorno configuradas por otros
# deploys.
#
# @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 end

View file

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

View file

@ -7,8 +7,8 @@
# jekyll-private-data # jekyll-private-data
class DeployPrivate < DeployLocal class DeployPrivate < DeployLocal
# No es necesario volver a instalar dependencias # No es necesario volver a instalar dependencias
def deploy def deploy(output: false)
jekyll_build jekyll_build(output: output)
end end
# Hacer el deploy a un directorio privado # Hacer el deploy a un directorio privado
@ -16,6 +16,10 @@ class DeployPrivate < DeployLocal
File.join(Rails.root, '_private', site.name) File.join(Rails.root, '_private', site.name)
end end
def url
"#{ENV['PANEL_URL']}/sites/private/#{site.name}"
end
# No usar recursos en compresión y habilitar los datos privados # No usar recursos en compresión y habilitar los datos privados
def env def env
@env ||= super.merge({ @env ||= super.merge({

View file

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

View file

@ -5,8 +5,8 @@
class DeployRsync < Deploy class DeployRsync < Deploy
store :values, accessors: %i[destination host_keys], coder: JSON store :values, accessors: %i[destination host_keys], coder: JSON
def deploy def deploy(output: false)
ssh? && rsync ssh? && rsync(output: output)
end end
# El espacio remoto es el mismo que el local # El espacio remoto es el mismo que el local
@ -83,8 +83,8 @@ class DeployRsync < Deploy
# Sincroniza hacia el directorio remoto # Sincroniza hacia el directorio remoto
# #
# @return [Boolean] # @return [Boolean]
def rsync def rsync(output: output)
run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/) run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
end end
# El origen es el destino de la compilación # El origen es el destino de la compilación

View file

@ -6,7 +6,7 @@ class DeployWww < Deploy
before_destroy :remove_destination! before_destroy :remove_destination!
def deploy def deploy(**)
File.symlink?(destination) || File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero? File.symlink(site.hostname, destination).zero?
end end
@ -27,6 +27,10 @@ class DeployWww < Deploy
"www.#{site.hostname}" "www.#{site.hostname}"
end end
def url
"https://www.#{site.hostname}/"
end
private private
def remove_destination! def remove_destination!

View file

@ -12,7 +12,7 @@ class DeployZip < Deploy
# y generar un zip accesible públicamente. # y generar un zip accesible públicamente.
# #
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def deploy def deploy(**)
FileUtils.rm_f path FileUtils.rm_f path
time_start time_start
@ -49,6 +49,10 @@ class DeployZip < Deploy
"#{site.hostname}.zip" "#{site.hostname}.zip"
end end
def url
"#{site.url}#{file}"
end
def path def path
File.join(destination, file) File.join(destination, file)
end end

View file

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

View file

@ -72,6 +72,17 @@ class MetadataContent < MetadataTemplate
resource['controls'] = true resource['controls'] = true
end end
# Elimina los estilos salvo los que asigne el editor
html.css('*').each do |element|
next if elements_with_style.include? element.name.downcase
element.remove_attribute('style')
end
html.to_s.html_safe html.to_s.html_safe
end end
def elements_with_style
@elements_with_style ||= %w[div mark].freeze
end
end end

View file

@ -23,7 +23,6 @@ class MetadataFile < MetadataTemplate
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid? 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}.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 << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact! errors.compact!
@ -41,14 +40,14 @@ class MetadataFile < MetadataTemplate
end end
# Asociar la imagen subida al sitio y obtener la ruta # Asociar la imagen subida al sitio y obtener la ruta
# # @return [Boolean]
# 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.
def save def save
if value['path'].blank?
self[:value] = default_value
else
value['description'] = sanitize value['description'] value['description'] = sanitize value['description']
value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil value['path'] = relative_destination_path_with_filename.to_s if static_file
end
true true
end 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 que apunta a un archivo asociado al sitio
# * El archivo es una ruta a un archivo dentro del repositorio # * 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 # @todo encontrar una forma de obtener el attachment sin tener que
# recurrir al último subido. # recurrir al último subido.
# #
@ -75,13 +71,7 @@ class MetadataFile < MetadataTemplate
when ActionDispatch::Http::UploadedFile when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path']) site.static_files.last if site.static_files.attach(value['path'])
when String when String
if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first) site.static_files.find_by(blob_id: blob_id) || migrate_static_file!
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
end end
end end
@ -98,7 +88,7 @@ class MetadataFile < MetadataTemplate
# #
# @return [String] # @return [String]
def key_from_path def key_from_path
pathname.dirname.basename.to_s @key_from_path ||= pathname.dirname.basename.to_s
end end
def path? def path?
@ -127,13 +117,22 @@ class MetadataFile < MetadataTemplate
# devolvemos la ruta original, que puede ser el archivo que no existe # devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno. # o vacía si se está subiendo uno.
rescue Errno::ENOENT => e 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 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 def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath) 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 end
def static_file_path def static_file_path
@ -145,8 +144,31 @@ class MetadataFile < MetadataTemplate
end end
end end
# No hay archivo pero se lo describió # Obtiene el id del blob asociado
def no_file_for_description? #
!path? && description? # @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
end end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
class MetadataNonGeo < MetadataGeo; end

View file

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

View file

@ -2,12 +2,6 @@
# Este metadato permite generar rutas manuales. # Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString 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 # Los permalinks nunca pueden ser privados
def private? def private?
false false

View file

@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar # Trae el slug desde el título si existe o una string al azar
def default_value def default_value
title ? Jekyll::Utils.slugify(title) : SecureRandom.uuid title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid
end end
def value def value
@ -39,6 +39,6 @@ class MetadataSlug < MetadataTemplate
return if post.title&.private? return if post.title&.private?
return if post.title&.value&.blank? return if post.title&.value&.blank?
post.title&.value&.to_s post.title&.value&.to_s&.unicode_normalize
end end
end end

View file

@ -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 # En caso de que algún campo necesite realizar acciones antes de ser
# guardado # guardado
def save def save
return true unless changed? if !changed?
self[:value] = document_value if private?
return true
end
self[:value] = sanitize value self[:value] = sanitize value
self[:value] = encrypt(value) if private? self[:value] = encrypt(value) if private?

View file

@ -29,7 +29,7 @@ class Post
# TODO: Reemplazar cuando leamos el contenido del Document # TODO: Reemplazar cuando leamos el contenido del Document
# a demanda? # a demanda?
def find_layout(path) 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
end end
@ -90,16 +90,21 @@ class Post
'page' => document.to_liquid '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 # Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores. # poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document 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. # no son públicas hasta que se publica el artículo.
html.css('img').each do |img| html.css('img,audio,video,iframe').each do |element|
next if %r{\Ahttps?://} =~ img.attributes['src'] src = element.attributes['src']
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site, next unless src&.value&.start_with? 'public/'
file: img.attributes['src'].value)
src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: src.value)
end end
# Notificar a les usuaries que están viendo una previsualización # Notificar a les usuaries que están viendo una previsualización
@ -108,12 +113,16 @@ class Post
# Cacofonía # Cacofonía
html.to_html.html_safe html.to_html.html_safe
rescue Liquid::Error => e
ExceptionNotifier.notify(e, data: { site: site.name, post: post.id })
''
end end
end end
# Devuelve una llave para poder guardar el post en una cache # Devuelve una llave para poder guardar el post en una cache
def cache_key def cache_key
'posts/' + uuid.value "posts/#{uuid.value}"
end end
def cache_version def cache_version
@ -123,7 +132,7 @@ class Post
# Agregar el timestamp para saber si cambió, siguiendo el módulo # Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration # ActiveRecord::Integration
def cache_key_with_version def cache_key_with_version
cache_key + '-' + cache_version "#{cache_key}-#{cache_version}"
end end
# TODO: Convertir a UUID? # TODO: Convertir a UUID?

View file

@ -14,9 +14,8 @@ class Post
# #
# @return [IndexedPost] # @return [IndexedPost]
def to_index 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.layout = layout.name
indexed_post.site_id = site.id
indexed_post.path = path.basename indexed_post.path = path.basename
indexed_post.locale = locale.value indexed_post.locale = locale.value
indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value) indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value)
@ -28,8 +27,6 @@ class Post
end end
end end
private
# Indexa o reindexa el Post # Indexa o reindexa el Post
# #
# @return [Boolean] # @return [Boolean]
@ -41,6 +38,8 @@ class Post
to_index.destroy.destroyed? to_index.destroy.destroyed?
end end
private
# Los metadatos que se almacenan como objetos JSON. Empezamos con # Los metadatos que se almacenan como objetos JSON. Empezamos con
# las categorías porque se usan para filtrar en el listado de # las categorías porque se usan para filtrar en el listado de
# artículos. # artículos.

View file

@ -17,7 +17,7 @@ class Site < ApplicationRecord
# TODO: Hacer que los diferentes tipos de deploy se auto registren # TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb # @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: { validates :name, uniqueness: true, hostname: {
allow_root_label: true allow_root_label: true
@ -179,10 +179,20 @@ class Site < ApplicationRecord
# Siempre tiene que tener algo porque las traducciones están # Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios. # sus sitios.
#
# @return [Array]
def locales def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym) @locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
end 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 # Similar a site.i18n en jekyll-locales
# #
# @return [Hash] # @return [Hash]
@ -250,6 +260,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)] layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang) @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 end
@posts[lang] @posts[lang]
@ -425,7 +437,7 @@ class Site < ApplicationRecord
# El directorio donde se almacenan los sitios # El directorio donde se almacenan los sitios
def self.site_path 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 end
def self.default def self.default
@ -484,6 +496,7 @@ class Site < ApplicationRecord
config.title = title config.title = title
config.url = url(slash: false) config.url = url(slash: false)
config.hostname = hostname config.hostname = hostname
config.locales = locales.map(&:to_s)
end end
# Valida si el sitio tiene al menos una forma de alojamiento asociada # Valida si el sitio tiene al menos una forma de alojamiento asociada

View file

@ -33,11 +33,11 @@ class Site
def write def write
return if persisted? return if persisted?
@saved = Site::Writer.new(site: site, file: path, @saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result|
content: content.to_yaml).save
# Actualizar el hash para no escribir dos veces # Actualizar el hash para no escribir dos veces
@hash = content.hash @hash = content.hash
end end
end
alias save write alias save write
# Detecta si la configuración cambió comparando con el valor inicial # Detecta si la configuración cambió comparando con el valor inicial

View file

@ -14,9 +14,7 @@ class Site
def index_posts! def index_posts!
Site.transaction do Site.transaction do
docs.each do |post| docs.each(&:index!)
post.to_index.save
end
end end
end end
end end

View file

@ -147,6 +147,23 @@ class Site
rugged.index.remove(relativize(file)) rugged.index.remove(relativize(file))
end 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 private
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las # Si Sutty tiene una llave privada de tipo ED25519, devuelve las

View file

@ -9,6 +9,8 @@ class Usuarie < ApplicationRecord
validates_uniqueness_of :email validates_uniqueness_of :email
validates_with EmailAddress::ActiveRecordValidator, field: :email validates_with EmailAddress::ActiveRecordValidator, field: :email
before_create :lang_from_locale!
has_many :roles has_many :roles
has_many :sites, through: :roles has_many :sites, through: :roles
has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit' has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit'
@ -38,4 +40,10 @@ class Usuarie < ApplicationRecord
increment_failed_attempts increment_failed_attempts
lock_access! if attempts_exceeded? && !access_locked? lock_access! if attempts_exceeded? && !access_locked?
end end
private
def lang_from_locale!
self.lang = I18n.locale.to_s
end
end end

View file

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

View file

@ -3,6 +3,11 @@
# Se encargar de guardar cambios en sitios # Se encargar de guardar cambios en sitios
# TODO: Implementar rollback en la configuración # TODO: Implementar rollback en la configuración
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
def deploy
site.enqueue!
DeployJob.perform_async site.id
end
# Crea un sitio, agrega un rol nuevo y guarda los cambios a la # Crea un sitio, agrega un rol nuevo y guarda los cambios a la
# configuración en el repositorio git # configuración en el repositorio git
def create def create
@ -11,7 +16,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_role temporal: false, rol: 'usuarie' add_role temporal: false, rol: 'usuarie'
sync_nodes sync_nodes
I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do 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
site.save && site.save &&
site.config.write && site.config.write &&
commit_config(action: :create) commit_config(action: :create)
@ -19,6 +31,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias add_licencias
deploy
site site
end end

View file

@ -0,0 +1,2 @@
.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] }
= yield

View file

@ -11,7 +11,6 @@
url: site_collaborate_path(@site), url: site_collaborate_path(@site),
method: :post) do |f| method: :post) do |f|
- unless current_usuarie - unless current_usuarie
= render 'layouts/flash'
.form-group .form-group
= f.label :email = f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email', = f.email_field :email, autofocus: true, autocomplete: 'email',

View file

@ -1,17 +1,21 @@
%h1= t('.hi') %h1= @hi
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname), = sanitize_markdown @explanation, tags: %w[p a strong em]
tags: %w[p a strong em]
%table %table
%thead %thead
%tr %tr
%th= t('.th.type') - @headers.each do |header|
%th= t('.th.status') %th= header
%tbody %tbody
- @deploys.each do |deploy, value| - @table.each do |row|
- row[:urls].each do |url|
%tr %tr
%td= t(".#{deploy}.title") %td= row[:title]
%td= value ? t(".#{deploy}.success") : t(".#{deploy}.error") %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]

View file

@ -1,12 +1,7 @@
= '# ' + t('.hi') = "# #{@hi}"
\ \
= t('.explanation', fqdn: @deploy_local.site.hostname) = @explanation
\ \
= Terminal::Table.new do |table| = @terminal_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")]
\ \
= t('.help') = @help

View file

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

View file

@ -0,0 +1 @@
-# NADA

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-4.align-self-center .col-md-4.align-self-center
.sr-only .sr-only
@ -11,8 +13,6 @@
url: confirmation_path(resource_name), url: confirmation_path(resource_name),
html: { method: :post }) do |f| html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
:ruby :ruby
value = if resource.pending_reconfirmation? value = if resource.pending_reconfirmation?
resource.unconfirmed_email resource.unconfirmed_email

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
%h2= t 'devise.invitations.edit.header' %h2= t 'devise.invitations.edit.header'
@ -8,7 +10,6 @@
as: resource_name, as: resource_name,
url: invitation_path(resource_name), url: invitation_path(resource_name),
html: { method: :put }) do |f| html: { method: :put }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.hidden_field :invitation_token, readonly: true = f.hidden_field :invitation_token, readonly: true
- if f.object.class.require_password_on_accepting - if f.object.class.require_password_on_accepting
.form-group .form-group

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
%h2= t 'devise.invitations.new.header' %h2= t 'devise.invitations.new.header'
@ -8,7 +10,6 @@
as: resource_name, as: resource_name,
url: invitation_path(resource_name), url: invitation_path(resource_name),
html: { method: :post }) do |f| html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
- resource.class.invite_key_fields.each do |field| - resource.class.invite_key_fields.each do |field|
.form-group .form-group
= f.label field = f.label field

View file

@ -1,3 +1,3 @@
%p= t('.greeting', recipient: @email) %p= t('.greeting', recipient: @email)
%p= t('.instruction') %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)

View file

@ -2,4 +2,4 @@
\ \
= t('.instruction') = t('.instruction')
\ \
= confirmation_url(@resource, confirmation_token: @token) = confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang)

View file

@ -9,7 +9,7 @@
%p= site.description %p= site.description
%p= link_to t('devise.mailer.invitation_instructions.accept'), %p= link_to t('devise.mailer.invitation_instructions.accept'),
accept_invitation_url(@resource, invitation_token: @token) accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
- if @resource.invitation_due_at - if @resource.invitation_due_at
%p= t('devise.mailer.invitation_instructions.accept_until', %p= t('devise.mailer.invitation_instructions.accept_until',

View file

@ -9,7 +9,7 @@
\ \
= site.description = site.description
\ \
= accept_invitation_url(@resource, invitation_token: @token) = accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
\ \
- if @resource.invitation_due_at - if @resource.invitation_due_at
= t('devise.mailer.invitation_instructions.accept_until', = t('devise.mailer.invitation_instructions.accept_until',

View file

@ -1,5 +1,5 @@
%p= t('.greeting', recipient: @resource.email) %p= t('.greeting', recipient: @resource.email)
%p= t('.instruction') %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_2')
%p= t('.instruction_3') %p= t('.instruction_3')

View file

@ -2,7 +2,7 @@
\ \
= t('.instruction') = 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') = t('.instruction_2')
\ \

View file

@ -1,4 +1,4 @@
%p= t('.greeting', recipient: @resource.email) %p= t('.greeting', recipient: @resource.email)
%p= t('.message') %p= t('.message')
%p= t('.instruction') %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)

View file

@ -4,4 +4,4 @@
\ \
= t('.instruction') = t('.instruction')
\ \
= unlock_url(@resource, unlock_token: @token) = unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang)

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
.sr-only .sr-only
@ -10,7 +12,6 @@
= form_for(resource, as: resource_name, = form_for(resource, as: resource_name,
url: password_path(resource_name), url: password_path(resource_name),
html: { method: :put }) do |f| html: { method: :put }) do |f|
= render 'devise/shared/error_messages', resource: resource
= f.hidden_field :reset_password_token = f.hidden_field :reset_password_token

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
.sr-only .sr-only
@ -11,7 +13,6 @@
as: resource_name, as: resource_name,
url: password_path(resource_name), url: password_path(resource_name),
html: { method: :post }) do |f| html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
.form-group .form-group
= f.label :email, class: 'sr-only' = f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email', = f.email_field :email, autofocus: true, autocomplete: 'email',

View file

@ -3,6 +3,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center .col-md-6.align-self-center
%h2= t('.title') %h2= t('.title')
@ -11,8 +13,6 @@
url: registration_path(resource_name), url: registration_path(resource_name),
html: { method: :put }) do |f| html: { method: :put }) do |f|
= render 'devise/shared/error_messages', resource: resource
.form-group .form-group
= f.label :email = f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email', = f.email_field :email, autofocus: true, autocomplete: 'email',

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
%h2= t('.sign_up') %h2= t('.sign_up')
@ -8,9 +10,7 @@
= form_for(resource, = form_for(resource,
as: resource_name, as: resource_name,
url: registration_path(resource_name)) do |f| url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
= render 'devise/shared/error_messages', resource: resource
.form-group .form-group
= f.label :email, class: 'sr-only' = f.label :email, class: 'sr-only'

View file

@ -3,8 +3,6 @@
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
= render 'layouts/flash'
.sr-only .sr-only
%h2= t('.sign_in') %h2= t('.sign_in')
%p= t('.help') %p= t('.help')

View file

@ -1,9 +1,4 @@
- if resource.errors.any? - if resource.errors.any?
#error_explanation = render 'bootstrap/alert' do
%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| - resource.errors.full_messages.each do |message|
%li= message %p= message

View file

@ -1,35 +1,38 @@
%hr/ %hr/
- locale = params.permit(:locale)
- if controller_name != 'sessions' - 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/ %br/
- if devise_mapping.registerable? && controller_name != 'registrations' - 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' class: 'btn btn-lg btn-block btn-success'
%br/ %br/
- if devise_mapping.recoverable? - if devise_mapping.recoverable?
- unless %w[passwords registrations].include?(controller_name) - unless %w[passwords registrations].include?(controller_name)
= link_to t('.forgot_your_password'), = link_to t('.forgot_your_password'),
new_password_path(resource_name) new_password_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.confirmable? && controller_name != 'confirmations' - if devise_mapping.confirmable? && controller_name != 'confirmations'
= link_to t('.didn_t_receive_confirmation_instructions'), = link_to t('.didn_t_receive_confirmation_instructions'),
new_confirmation_path(resource_name) new_confirmation_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.lockable? - if devise_mapping.lockable?
- if resource_class.unlock_strategy_enabled?(:email) - if resource_class.unlock_strategy_enabled?(:email)
- if controller_name != 'unlocks' - if controller_name != 'unlocks'
= link_to t('.didn_t_receive_unlock_instructions'), = link_to t('.didn_t_receive_unlock_instructions'),
new_unlock_path(resource_name) new_unlock_path(resource_name, params: locale)
%br/ %br/
- if devise_mapping.omniauthable? - if devise_mapping.omniauthable?
- resource_class.omniauth_providers.each do |provider| - resource_class.omniauth_providers.each do |provider|
= link_to t('.sign_in_with_provider', = link_to t('.sign_in_with_provider',
provider: OmniAuth::Utils.camelize(provider)), provider: OmniAuth::Utils.camelize(provider)),
omniauth_authorize_path(resource_name, provider) omniauth_authorize_path(resource_name, provider, params: locale)
%br/ %br/

View file

@ -1,6 +1,8 @@
= content_for :body do = content_for :body do
- 'black-bg' - 'black-bg'
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center .col-md-5.align-self-center
.sr-only .sr-only
@ -11,7 +13,6 @@
as: resource_name, as: resource_name,
url: unlock_path(resource_name), url: unlock_path(resource_name),
html: { method: :post }) do |f| html: { method: :post }) do |f|
= render 'devise/shared/error_messages', resource: resource
.form-group .form-group
= f.label :email, class: 'sr-only' = f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email', = f.email_field :email, autofocus: true, autocomplete: 'email',

View file

@ -1,4 +1,4 @@
.row.align-items-center.justify-content-center.full-height .row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center .col-md-6.align-self-center
.alert{role: 'alert', class: "alert-success"} = render 'bootstrap/alert' do
= t('.confirmation_sent') = t('.confirmation_sent')

View file

@ -22,3 +22,7 @@
%li.nav-item %li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path, = link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn' method: :delete, role: 'button', class: 'btn'
- else
- I18n.available_locales.each do |locale|
- next if locale == I18n.locale
= link_to t(locale), "?change_locale_to=#{locale}"

View file

@ -1,3 +1,4 @@
- flash.each do |type, message| - flash.each do |type, message|
- unless type == 'js' - unless type == 'js'
.alert{ role: 'alert', class: "alert-#{type}" }= message = render 'bootstrap/alert' do
= message

View file

@ -1,3 +1,4 @@
-# DEPRECADO
.alert.alert-info.alert-dismissible.fade.show{role: 'alert'} .alert.alert-info.alert-dismissible.fade.show{role: 'alert'}
- if help.respond_to? :each - if help.respond_to? :each
%ul %ul

View file

@ -21,6 +21,9 @@
%body{ class: yield(:body) } %body{ class: yield(:body) }
.container-fluid#sutty .container-fluid#sutty
= render 'layouts/breadcrumb' = render 'layouts/breadcrumb'
= render 'layouts/flash'
= yield = yield
- if flash[:js] - if flash[:js]
.js-flash.d-none{ data: flash[:js] } .js-flash.d-none{ data: flash[:js] }

View file

@ -1,7 +1,9 @@
- unless post.errors.empty? - unless post.errors.empty?
.alert.alert-danger - title = t('.errors.title')
%h4= t('.errors.title') - help = t('.errors.help')
%p= t('.errors.help') = render 'bootstrap/alert' do
%h4= title
%p= help
%ul %ul
- post.errors.each do |attribute, errors| - post.errors.each do |attribute, errors|

View file

@ -1,6 +1,6 @@
.form-group .form-group
= submit_tag t('.save'), class: 'btn submit-post' = submit_tag t('.save'), class: 'btn submit-post'
.invalid-help.alert.alert-danger.d-none = render 'bootstrap/alert', class: 'invalid-help d-none' do
= site.config.fetch('invalid_help', t('.invalid_help')) = site.config.fetch('invalid_help', t('.invalid_help'))
.sending-help.alert.alert-success.d-none = render 'bootstrap/alert', class: 'sending-help d-none' do
= site.config.fetch('sending_help', t('.sending_help')) = site.config.fetch('sending_help', t('.sending_help'))

View file

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

View file

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

View file

@ -6,7 +6,7 @@
.editor{ id: attribute, data: { editor: '' } } .editor{ id: attribute, data: { editor: '' } }
-# Esto es para luego decirle al navegador que se olvide estas cosas. -# 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' } = hidden_field_tag 'storage_keys[]', "#{request.original_url}##{attribute}", data: { target: 'storage-key' }
.alert.alert-info = render 'bootstrap/alert' do
:markdown :markdown
#{t('editor.alert')} #{t('editor.alert')}
= text_area_tag "#{base}[#{attribute}]", '', = text_area_tag "#{base}[#{attribute}]", '',
@ -123,7 +123,7 @@
%label{ for: 'link-url' }= t('editor.url') %label{ for: 'link-url' }= t('editor.url')
%input.form-control{ type: 'url', id: 'link-url', name: 'link-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') %p= t('editor.word')
.editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' } .editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' }

View file

@ -1,4 +1,8 @@
.row{ data: { controller: 'geo' } } .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 .col
.form-group .form-group
= label_tag "#{base}_#{attribute}_lat", = label_tag "#{base}_#{attribute}_lat",

View file

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

View file

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

View file

@ -34,14 +34,13 @@
= render 'sites/build', site: @site = render 'sites/build', site: @site
- if @site.design.credits - if @site.design.credits
.alert.alert-primary{ role: 'alert' } = render 'bootstrap/alert' do
= sanitize_markdown @site.design.credits = sanitize_markdown @site.design.credits
= link_to t('sites.donations.text'), t('sites.donations.url'), class: 'btn' = link_to t('sites.donations.text'), t('sites.donations.url'), class: 'btn'
- if @site.design.designer_url - if @site.design.designer_url
= link_to t('sites.designer_url'), @site.design.designer_url, class: 'btn' = link_to t('sites.designer_url'), @site.design.designer_url, class: 'btn'
%section.col %section.col
= render 'layouts/flash'
.d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2 .d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2
%form{ action: site_posts_path } %form{ action: site_posts_path }
- @filter_params.each do |param, value| - @filter_params.each do |param, value|
@ -89,22 +88,22 @@
%div %div
%tbody %tbody
- dir = t("locales.#{@locale}.dir") - dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size - size = @posts.size
- @posts.each_with_index do |post, i| - @posts.each_with_index do |post, i|
-# -#
TODO: Solo les usuaries cachean porque tenemos que separar TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos. les botones por permisos.
- cache_if @usuarie, [post, I18n.locale] do - cache_if @usuarie, [post, I18n.locale] do
- checkbox_id = "checkbox-#{post.id}" - checkbox_id = "checkbox-#{post.post_id}"
%tr{ id: post.id, data: { target: 'reorder.row' } } %tr{ id: post.post_id, data: { target: 'reorder.row' } }
%td %td
.custom-control.custom-checkbox .custom-control.custom-checkbox
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } } %input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
%label.custom-control-label{ for: checkbox_id } %label.custom-control-label{ for: checkbox_id }
%span.sr-only= t('posts.reorder.select') %span.sr-only= t('posts.reorder.select')
-# Orden más alto es mayor prioridad -# Orden más alto es mayor prioridad
= hidden_field 'post[reorder]', post.id, = hidden_field 'post[reorder]', post.post_id,
value: size - i, value: size - i,
data: { reorder: true } data: { reorder: true }
%td.w-100{ class: dir } %td.w-100{ class: dir }

View file

@ -1,4 +1,4 @@
- dir = t("locales.#{@locale}.dir") - dir = @site.data.dig(params[:locale], 'dir')
.row.justify-content-center .row.justify-content-center
.col-md-8 .col-md-8
%article.content.table-responsive-md %article.content.table-responsive-md
@ -6,13 +6,6 @@
edit_site_post_path(@site, @post.id), edit_site_post_path(@site, @post.id),
class: 'btn btn-block' 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 %table.table.table-condensed
%thead %thead
%tr %tr

View file

@ -1,7 +1,9 @@
- unless site.errors.empty? - unless site.errors.empty?
.alert.alert-info - title = t('.errors.title')
%h4= t('.errors.title') - help = t('.errors.help')
%p.lead= t('.errors.help') = render 'bootstrap/alert' do
%h4= title
%p.lead= help
%ul %ul
- site.errors.messages.each_pair do |attr, error| - site.errors.messages.each_pair do |attr, error|
- attr = attr.to_s - attr = attr.to_s
@ -48,7 +50,7 @@
%h2= t('.design.title') %h2= t('.design.title')
%p.lead= t('.help.design') %p.lead= t('.help.design')
- if invalid? site, :design_id - if invalid? site, :design_id
.alert.alert-info = render 'bootstrap/alert' do
= t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help', = t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help',
layouts: site.incompatible_layouts.to_sentence) layouts: site.incompatible_layouts.to_sentence)
.row.designs .row.designs
@ -104,6 +106,7 @@
%hr/ %hr/
- if site.persisted?
.form-group#tienda .form-group#tienda
%h2= t('.tienda.title') %h2= t('.tienda.title')
%p.lead %p.lead
@ -124,7 +127,6 @@
%hr/ %hr/
- if site.persisted?
.form-group#contact .form-group#contact
%h2= t('.contact.title') %h2= t('.contact.title')
%p.lead= t('.contact.help') %p.lead= t('.contact.help')

View file

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

View file

@ -104,7 +104,7 @@ en:
new: new:
sign_up: Sign up 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. 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. 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_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_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. 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 +138,7 @@ en:
errors: errors:
messages: messages:
already_confirmed: was already confirmed, please try signing in 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 expired: has expired, please request a new one
not_found: not found not_found: not found
not_locked: was not locked not_locked: was not locked

View file

@ -104,7 +104,7 @@ es:
new: new:
sign_up: Registrarme 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. 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. 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_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_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. 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 +138,7 @@ es:
errors: errors:
messages: messages:
already_confirmed: ya ha sido confirmada, por favor intenta iniciar sesión 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 expired: ha expirado, por favor solicita una nueva
not_found: no se ha encontrado not_found: no se ha encontrado
not_locked: no estaba bloqueada not_locked: no estaba bloqueada

View file

@ -16,6 +16,15 @@ en:
ur: ur:
name: Urdu name: Urdu
dir: rtl dir: rtl
zh:
name: Chinese
dir: ltr
de:
name: German
dir: ltr
fr:
name: French
dir: ltr
login: login:
email: E-mail address email: E-mail address
password: Password password: Password
@ -81,6 +90,9 @@ en:
th: th:
type: Type type: Type
status: Status status: Status
seconds: Duration
size: Space used
url: Address
deploy_local: deploy_local:
title: Build the site title: Build the site
success: Success! success: Success!
@ -105,6 +117,18 @@ en:
title: Alternative domain name title: Alternative domain name
success: Success! success: Success!
error: Error 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: deploy_rsync:
title: Synchronize to backup server title: Synchronize to backup server
success: Success! success: Success!
@ -255,9 +279,26 @@ en:
Only accessible through [Tor Only accessible through [Tor
Browser](https://www.torproject.org/download/) 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://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/)
stats: stats:
index: index:
title: Statistics title: Statistics
filter: "Filter"
help: | help: |
These statistics show information about how your site is generated and These statistics show information about how your site is generated and
how many resources it uses. how many resources it uses.
@ -433,6 +474,8 @@ en:
attribute_ro: attribute_ro:
file: file:
download: Download file download: Download file
password:
safety: Passwords are stored safely
show: show:
front_matter: Post metadata front_matter: Post metadata
submit: submit:
@ -494,7 +537,7 @@ en:
preview: preview:
btn: 'Preliminary version' btn: 'Preliminary version'
alert: 'Not every article type has a 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' open: 'Tip: You can add new options by typing them and pressing Enter'
private: '&#128274; The values of this field will remain private' private: '&#128274; The values of this field will remain private'
select: select:

View file

@ -16,6 +16,15 @@ es:
ur: ur:
name: Urdu name: Urdu
dir: rtl dir: rtl
zh:
name: Chino
dir: ltr
de:
name: Alemán
dir: ltr
fr:
name: Francés
dir: ltr
login: login:
email: Correo electrónico email: Correo electrónico
password: Contraseña password: Contraseña
@ -81,6 +90,9 @@ es:
th: th:
type: Tipo type: Tipo
status: Estado status: Estado
seconds: Duración
size: Espacio ocupado
url: Dirección
deploy_local: deploy_local:
title: Generar el sitio title: Generar el sitio
success: ¡Éxito! success: ¡Éxito!
@ -105,6 +117,18 @@ es:
title: Dominio alternativo title: Dominio alternativo
success: ¡Éxito! success: ¡Éxito!
error: Hubo un error 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: deploy_rsync:
title: Sincronizar al servidor alternativo title: Sincronizar al servidor alternativo
success: ¡Éxito! success: ¡Éxito!
@ -260,9 +284,26 @@ es:
Sólo será accesible a través del [Navegador Sólo será accesible a través del [Navegador
Tor](https://www.torproject.org/es/download/). 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 (en inglés)](https://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/)
stats: stats:
index: index:
title: Estadísticas title: Estadísticas
filter: "Filtrar"
help: | help: |
Las estadísticas visibilizan información sobre cómo se genera y Las estadísticas visibilizan información sobre cómo se genera y
cuántos recursos utiliza tu sitio. cuántos recursos utiliza tu sitio.
@ -441,6 +482,8 @@ es:
attribute_ro: attribute_ro:
file: file:
download: Descargar archivo download: Descargar archivo
password:
safety: Las contraseñas se almacenan de forma segura
show: show:
front_matter: Metadatos del artículo front_matter: Metadatos del artículo
submit: submit:
@ -502,7 +545,7 @@ es:
preview: preview:
btn: 'Versión preliminar' btn: 'Versión preliminar'
alert: 'No todos los tipos de artículos poseen vista 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' open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar'
private: '&#128274; Los valores de este campo serán privados' private: '&#128274; Los valores de este campo serán privados'
select: select:

View file

@ -55,7 +55,7 @@ Rails.application.routes.draw do
# Gestionar artículos según idioma # Gestionar artículos según idioma
nested do 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' post :'posts/reorder', to: 'posts#reorder'
resources :posts do resources :posts do
get 'p/:page', action: :index, on: :collection get 'p/:page', action: :index, on: :collection

View file

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

View file

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

View file

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

View file

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

11
lib/tasks/cleanup.rake Normal file
View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more